From fb86ea2012830ec2a61c5957a7e1f3646a0d7904 Mon Sep 17 00:00:00 2001 From: sladro Date: Mon, 30 Mar 2026 10:32:32 +0800 Subject: [PATCH] Complete terminal session flow stabilization --- AGENTS.md | 29 + README.md | 7 + .../android/app/src/main/AndroidManifest.xml | 5 +- .../main/res/xml/network_security_config.xml | 7 + .../integration_test/reconnect_flow_test.dart | 29 + apps/mobile_app/lib/app/app.dart | 16 + .../lib/core/network/agent_api_client.dart | 70 +++ .../network/agent_connection_providers.dart | 21 + .../lib/core/network/agent_socket_client.dart | 32 + .../features/pairing/pairing_controller.dart | 19 + .../lib/features/presets/preset_command.dart | 11 + .../lib/features/presets/preset_panel.dart | 23 + .../features/presets/preset_repository.dart | 9 + .../lib/features/sessions/session.dart | 17 + .../features/sessions/session_list_page.dart | 296 ++++++++++ .../features/sessions/session_repository.dart | 18 + .../lib/features/terminal/history_window.dart | 9 + .../terminal/terminal_controller.dart | 7 + .../terminal_interaction_controller.dart | 89 +++ .../lib/features/terminal/terminal_page.dart | 468 +++++++++++++++ .../terminal_session_coordinator.dart | 243 ++++++++ .../terminal/terminal_socket_session.dart | 179 ++++++ apps/mobile_app/lib/main.dart | 55 +- apps/mobile_app/pubspec.lock | 479 ++++++++++++++- apps/mobile_app/pubspec.yaml | 20 +- .../core/network/agent_api_client_test.dart | 144 +++++ .../agent_connection_providers_test.dart | 57 ++ .../network/agent_socket_client_test.dart | 37 ++ .../pairing/pairing_controller_test.dart | 57 ++ .../sessions/session_repository_test.dart | 79 +++ .../terminal/terminal_controller_test.dart | 26 + .../terminal_interaction_controller_test.dart | 51 ++ .../terminal_session_coordinator_test.dart | 247 ++++++++ .../terminal_socket_session_test.dart | 148 +++++ apps/mobile_app/test/widget_test.dart | 552 +++++++++++++++++- apps/windows_agent/TermRemoteCtl.Agent.sln | 7 + .../Api/SessionEndpoints.cs | 14 + .../AgentEndpointConfiguration.cs | 28 +- .../Configuration/AgentOptions.cs | 6 + ...AgentOptionsServiceCollectionExtensions.cs | 1 + .../Configuration/AgentOptionsValidator.cs | 19 +- .../src/TermRemoteCtl.Agent/Program.cs | 5 + .../Properties/AssemblyInfo.cs | 6 + .../Realtime/TerminalWebSocketHandler.cs | 220 +++++++ .../Sessions/SessionRecord.cs | 5 + .../Sessions/SessionRegistry.cs | 62 ++ .../TermRemoteCtl.Agent.csproj | 19 + .../Terminal/ConPtyInterop.Native.cs | 344 +++++++++++ .../Terminal/ConPtyProcessSession.cs | 199 +++++++ .../Terminal/ConPtySafeHandles.cs | 20 + .../Terminal/ConPtySessionFactory.cs | 17 + .../Terminal/HelperBackedConPtySession.cs | 211 +++++++ .../Terminal/HelperPathResolver.cs | 33 ++ .../Terminal/IConPtySession.cs | 17 + .../Terminal/ISessionHost.cs | 26 + .../Terminal/PowerShellSessionHost.cs | 95 +++ .../Terminal/TerminalFrameBatcher.cs | 150 +++++ .../src/TermRemoteCtl.Agent/appsettings.json | 5 +- .../src/TermRemoteCtl.ConPtyHelper/Program.cs | 437 ++++++++++++++ .../TermRemoteCtl.ConPtyHelper.csproj | 8 + .../Properties/AssemblyInfo.cs | 3 + .../Realtime/TerminalSmokeCheckTests.cs | 282 +++++++++ .../Realtime/TerminalWebSocketHandlerTests.cs | 156 +++++ .../SessionFlowTests.cs | 173 ++++++ .../SessionHistoryApiTests.cs | 67 +++ .../AgentOptionsPipelineTests.cs | 57 +- .../AgentOptionsValidatorTests.cs | 89 ++- .../Properties/AssemblyInfo.cs | 3 + .../Sessions/SessionRegistryTests.cs | 76 ++- .../Terminal/ConPtyInteropTests.cs | 20 + .../Terminal/ConPtySessionFactoryTests.cs | 88 +++ .../Terminal/PowerShellSessionHostTests.cs | 126 ++++ .../Terminal/TerminalFrameBatcherTests.cs | 46 ++ .../tools/ConPtyProbe/probe-output.txt | 4 + docs/operations/windows-agent-setup.md | 11 + .../plans/2026-03-29-terminal-next-steps.md | 97 +++ docs/testing/manual-smoke-checklist.md | 11 + 77 files changed, 6734 insertions(+), 85 deletions(-) create mode 100644 AGENTS.md create mode 100644 apps/mobile_app/android/app/src/main/res/xml/network_security_config.xml create mode 100644 apps/mobile_app/integration_test/reconnect_flow_test.dart create mode 100644 apps/mobile_app/lib/app/app.dart create mode 100644 apps/mobile_app/lib/core/network/agent_api_client.dart create mode 100644 apps/mobile_app/lib/core/network/agent_connection_providers.dart create mode 100644 apps/mobile_app/lib/core/network/agent_socket_client.dart create mode 100644 apps/mobile_app/lib/features/pairing/pairing_controller.dart create mode 100644 apps/mobile_app/lib/features/presets/preset_command.dart create mode 100644 apps/mobile_app/lib/features/presets/preset_panel.dart create mode 100644 apps/mobile_app/lib/features/presets/preset_repository.dart create mode 100644 apps/mobile_app/lib/features/sessions/session.dart create mode 100644 apps/mobile_app/lib/features/sessions/session_list_page.dart create mode 100644 apps/mobile_app/lib/features/sessions/session_repository.dart create mode 100644 apps/mobile_app/lib/features/terminal/history_window.dart create mode 100644 apps/mobile_app/lib/features/terminal/terminal_controller.dart create mode 100644 apps/mobile_app/lib/features/terminal/terminal_interaction_controller.dart create mode 100644 apps/mobile_app/lib/features/terminal/terminal_page.dart create mode 100644 apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart create mode 100644 apps/mobile_app/lib/features/terminal/terminal_socket_session.dart create mode 100644 apps/mobile_app/test/core/network/agent_api_client_test.dart create mode 100644 apps/mobile_app/test/core/network/agent_connection_providers_test.dart create mode 100644 apps/mobile_app/test/core/network/agent_socket_client_test.dart create mode 100644 apps/mobile_app/test/features/pairing/pairing_controller_test.dart create mode 100644 apps/mobile_app/test/features/sessions/session_repository_test.dart create mode 100644 apps/mobile_app/test/features/terminal/terminal_controller_test.dart create mode 100644 apps/mobile_app/test/features/terminal/terminal_interaction_controller_test.dart create mode 100644 apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart create mode 100644 apps/mobile_app/test/features/terminal/terminal_socket_session_test.dart create mode 100644 apps/windows_agent/src/TermRemoteCtl.Agent/Properties/AssemblyInfo.cs create mode 100644 apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs create mode 100644 apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ConPtyInterop.Native.cs create mode 100644 apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ConPtyProcessSession.cs create mode 100644 apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ConPtySafeHandles.cs create mode 100644 apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ConPtySessionFactory.cs create mode 100644 apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/HelperBackedConPtySession.cs create mode 100644 apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/HelperPathResolver.cs create mode 100644 apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/IConPtySession.cs create mode 100644 apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ISessionHost.cs create mode 100644 apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/PowerShellSessionHost.cs create mode 100644 apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/TerminalFrameBatcher.cs create mode 100644 apps/windows_agent/src/TermRemoteCtl.ConPtyHelper/Program.cs create mode 100644 apps/windows_agent/src/TermRemoteCtl.ConPtyHelper/TermRemoteCtl.ConPtyHelper.csproj create mode 100644 apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Properties/AssemblyInfo.cs create mode 100644 apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalSmokeCheckTests.cs create mode 100644 apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs create mode 100644 apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/SessionFlowTests.cs create mode 100644 apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/SessionHistoryApiTests.cs create mode 100644 apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Properties/AssemblyInfo.cs create mode 100644 apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/ConPtyInteropTests.cs create mode 100644 apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/ConPtySessionFactoryTests.cs create mode 100644 apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/PowerShellSessionHostTests.cs create mode 100644 apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/TerminalFrameBatcherTests.cs create mode 100644 apps/windows_agent/tools/ConPtyProbe/probe-output.txt create mode 100644 docs/operations/windows-agent-setup.md create mode 100644 docs/superpowers/plans/2026-03-29-terminal-next-steps.md create mode 100644 docs/testing/manual-smoke-checklist.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d661577 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,29 @@ +# AGENTS.md + +## Tooling +- Flutter SDK path: `C:\tools\flutter\bin\flutter.bat` +- On Windows, use the absolute Flutter path instead of relying on `PATH`. +- Prefer running Flutter and Dart work from the repository root or the app directory with the absolute Flutter path. + +## Windows Shell Setup +- Before running commands, initialize the terminal as UTF-8: + - `chcp 65001` + - `[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)` + - `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)` + - `$OutputEncoding = [System.Text.UTF8Encoding]::new($false)` + - `$env:PYTHONUTF8 = '1'` + - `$env:PYTHONIOENCODING = 'utf-8'` + +## Common Commands +- Run all mobile app tests: + - `C:\tools\flutter\bin\flutter.bat test` +- Run a targeted Flutter test: + - `C:\tools\flutter\bin\flutter.bat test test/widget_test.dart` +- Run Windows agent tests: + - `dotnet test apps/windows_agent/TermRemoteCtl.Agent.sln` + +## Encoding Rules +- All source, config, Markdown, JSON, YAML, TOML, and script files must use UTF-8 without BOM. +- Do not rely on terminal rendering alone to diagnose encoding issues. +- Always read and write text files explicitly as UTF-8 when tooling requires an encoding choice. + diff --git a/README.md b/README.md index 94f89cf..13ed1d1 100644 --- a/README.md +++ b/README.md @@ -17,3 +17,10 @@ TermRemoteCtl is a personal remote coding controller for one Windows workstation 2. Run dotnet new sln -n TermRemoteCtl.Agent -o apps/windows_agent 3. Verify the generated workspace with `flutter --version` and `dotnet --version` 4. Begin implementation in `apps/mobile_app` and `apps/windows_agent` using the checked-in workspace files and protocol notes + +## Verification + +- `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/TermRemoteCtl.Agent.Tests.csproj` +- `flutter test apps/mobile_app/test` +- `flutter test apps/mobile_app/integration_test` +- The Flutter `integration_test` suite may still require a supported local runtime target or device on the machine running it. diff --git a/apps/mobile_app/android/app/src/main/AndroidManifest.xml b/apps/mobile_app/android/app/src/main/AndroidManifest.xml index f97ab1e..eebd175 100644 --- a/apps/mobile_app/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile_app/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,11 @@ + + + android:icon="@mipmap/ic_launcher" + android:networkSecurityConfig="@xml/network_security_config"> + + + + 10.0.2.2 + + diff --git a/apps/mobile_app/integration_test/reconnect_flow_test.dart b/apps/mobile_app/integration_test/reconnect_flow_test.dart new file mode 100644 index 0000000..618079c --- /dev/null +++ b/apps/mobile_app/integration_test/reconnect_flow_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xterm/xterm.dart'; +import 'package:term_remote_ctl/features/terminal/terminal_page.dart'; + +void main() { + testWidgets('reopens terminal and shows live mode affordance after being removed', (tester) async { + await tester.pumpWidget(const MaterialApp(home: TerminalPage())); + await tester.pumpAndSettle(); + + expect(find.textContaining('Live | 0 lines'), findsOneWidget); + expect(find.byType(TerminalView), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + expect(find.byType(TerminalPage), findsNothing); + + await tester.pumpWidget(const MaterialApp(home: TerminalPage())); + await tester.pumpAndSettle(); + + expect(find.text('Terminal'), findsOneWidget); + expect(find.byType(TerminalView), findsOneWidget); + expect( + find.text('Minimal terminal shell. Live session wiring is not attached yet.'), + findsOneWidget, + ); + expect(find.textContaining('Live | 0 lines'), findsOneWidget); + }); +} diff --git a/apps/mobile_app/lib/app/app.dart b/apps/mobile_app/lib/app/app.dart new file mode 100644 index 0000000..3c4b53f --- /dev/null +++ b/apps/mobile_app/lib/app/app.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +import '../features/sessions/session_list_page.dart'; + +class TermRemoteCtlApp extends StatelessWidget { + const TermRemoteCtlApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'TermRemoteCtl', + theme: ThemeData(colorSchemeSeed: Colors.blue), + home: const SessionListPage(), + ); + } +} diff --git a/apps/mobile_app/lib/core/network/agent_api_client.dart b/apps/mobile_app/lib/core/network/agent_api_client.dart new file mode 100644 index 0000000..5d5d4cd --- /dev/null +++ b/apps/mobile_app/lib/core/network/agent_api_client.dart @@ -0,0 +1,70 @@ +import 'package:dio/dio.dart'; + +class AgentApiClient { + AgentApiClient(this.baseUri, {Dio? dio}) : _dio = dio ?? Dio(); + + final Uri baseUri; + final Dio _dio; + + Uri get sessionsUri => baseUri.resolve('/api/sessions'); + Uri get pairingCodeUri => baseUri.resolve('/api/pairing/code'); + Uri get pairingRedeemUri => baseUri.resolve('/api/pairing/redeem'); + + Uri sessionHistoryUri(String sessionId, {int lineCount = 200}) { + return baseUri.resolve('/api/sessions/$sessionId/history').replace( + queryParameters: {'lineCount': '$lineCount'}, + ); + } + + Future>> listSessions() async { + final response = await _dio.getUri(sessionsUri); + return _readJsonList(response.data, 'sessions'); + } + + Future> createSession(String name) async { + final response = await _dio.postUri( + sessionsUri, + data: {'name': name}, + ); + return _readJsonMap(response.data, 'session'); + } + + Future> getSessionHistory( + String sessionId, { + int lineCount = 200, + }) async { + final response = await _dio.getUri( + sessionHistoryUri(sessionId, lineCount: lineCount), + ); + return _readJsonMap(response.data, 'session history'); + } + + Future redeemPairingCode({ + required String code, + required String deviceName, + }) { + return _dio.postUri( + pairingRedeemUri, + data: { + 'code': code, + 'deviceName': deviceName, + }, + ); + } + + List> _readJsonList(dynamic data, String label) { + if (data is! List) { + throw FormatException('Expected $label response to be a JSON list.'); + } + + return data.map((entry) => _readJsonMap(entry, label)).toList(); + } + + Map _readJsonMap(dynamic data, String label) { + if (data is! Map) { + throw FormatException('Expected $label response to be a JSON object.'); + } + + return Map.from(data); + } +} diff --git a/apps/mobile_app/lib/core/network/agent_connection_providers.dart b/apps/mobile_app/lib/core/network/agent_connection_providers.dart new file mode 100644 index 0000000..dcb62d0 --- /dev/null +++ b/apps/mobile_app/lib/core/network/agent_connection_providers.dart @@ -0,0 +1,21 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'agent_api_client.dart'; +import '../../features/sessions/session_repository.dart'; +import '../../features/sessions/session.dart'; + +final agentBaseUriProvider = StateProvider((ref) { + return Uri.parse('http://10.0.2.2:5067'); +}); + +final agentApiClientProvider = Provider((ref) { + return AgentApiClient(ref.watch(agentBaseUriProvider)); +}); + +final sessionRepositoryProvider = Provider((ref) { + return SessionRepository(ref.watch(agentApiClientProvider)); +}); + +final sessionsProvider = FutureProvider>((ref) { + return ref.watch(sessionRepositoryProvider).listSessions(); +}); diff --git a/apps/mobile_app/lib/core/network/agent_socket_client.dart b/apps/mobile_app/lib/core/network/agent_socket_client.dart new file mode 100644 index 0000000..efde3d9 --- /dev/null +++ b/apps/mobile_app/lib/core/network/agent_socket_client.dart @@ -0,0 +1,32 @@ +class AgentSocketClient { + AgentSocketClient(this.baseUri); + + final Uri baseUri; + + Uri buildTerminalSocketUri(String sessionId) { + return Uri( + scheme: baseUri.scheme == 'https' ? 'wss' : 'ws', + host: baseUri.host, + port: baseUri.port, + path: '/ws/terminal', + queryParameters: {'sessionId': sessionId}, + ); + } + + Map buildAttachMessage(String sessionId) => { + 'type': 'attach', + 'sessionId': sessionId, + }; + + Map buildInputMessage(String input) => { + 'type': 'input', + 'input': input, + }; + + Map buildResizeMessage(int columns, int rows) => + { + 'type': 'resize', + 'columns': columns, + 'rows': rows, + }; +} diff --git a/apps/mobile_app/lib/features/pairing/pairing_controller.dart b/apps/mobile_app/lib/features/pairing/pairing_controller.dart new file mode 100644 index 0000000..6b8e69b --- /dev/null +++ b/apps/mobile_app/lib/features/pairing/pairing_controller.dart @@ -0,0 +1,19 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/network/agent_api_client.dart'; + +class PairingController extends StateNotifier> { + PairingController(this._client) : super(const AsyncData(null)); + + final AgentApiClient _client; + + Future redeemCode(String code, String deviceName) async { + state = const AsyncLoading(); + try { + await _client.redeemPairingCode(code: code, deviceName: deviceName); + state = const AsyncData(null); + } catch (error, stackTrace) { + state = AsyncError(error, stackTrace); + } + } +} diff --git a/apps/mobile_app/lib/features/presets/preset_command.dart b/apps/mobile_app/lib/features/presets/preset_command.dart new file mode 100644 index 0000000..9b09daf --- /dev/null +++ b/apps/mobile_app/lib/features/presets/preset_command.dart @@ -0,0 +1,11 @@ +class PresetCommand { + PresetCommand({ + required this.id, + required this.label, + required this.commandText, + }); + + final String id; + final String label; + final String commandText; +} diff --git a/apps/mobile_app/lib/features/presets/preset_panel.dart b/apps/mobile_app/lib/features/presets/preset_panel.dart new file mode 100644 index 0000000..e921dda --- /dev/null +++ b/apps/mobile_app/lib/features/presets/preset_panel.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:term_remote_ctl/features/presets/preset_command.dart'; + +class PresetPanel extends StatelessWidget { + const PresetPanel({super.key, required this.presets}); + + final List presets; + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final preset in presets) + FilledButton( + onPressed: () {}, + child: Text(preset.label), + ), + ], + ); + } +} diff --git a/apps/mobile_app/lib/features/presets/preset_repository.dart b/apps/mobile_app/lib/features/presets/preset_repository.dart new file mode 100644 index 0000000..be97ca6 --- /dev/null +++ b/apps/mobile_app/lib/features/presets/preset_repository.dart @@ -0,0 +1,9 @@ +import 'package:term_remote_ctl/features/presets/preset_command.dart'; + +class PresetRepository { + const PresetRepository(); + + List listPresets() { + return const []; + } +} diff --git a/apps/mobile_app/lib/features/sessions/session.dart b/apps/mobile_app/lib/features/sessions/session.dart new file mode 100644 index 0000000..96cc898 --- /dev/null +++ b/apps/mobile_app/lib/features/sessions/session.dart @@ -0,0 +1,17 @@ +class Session { + Session({ + required this.sessionId, + required this.name, + required this.status, + }); + + final String sessionId; + final String name; + final String status; + + factory Session.fromJson(Map json) => Session( + sessionId: json['sessionId'] as String, + name: json['name'] as String, + status: json['status'] as String, + ); +} diff --git a/apps/mobile_app/lib/features/sessions/session_list_page.dart b/apps/mobile_app/lib/features/sessions/session_list_page.dart new file mode 100644 index 0000000..83abe24 --- /dev/null +++ b/apps/mobile_app/lib/features/sessions/session_list_page.dart @@ -0,0 +1,296 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/network/agent_connection_providers.dart'; +import 'session.dart'; +import '../terminal/terminal_page.dart'; + +class SessionListPage extends ConsumerStatefulWidget { + const SessionListPage({super.key}); + + @override + ConsumerState createState() => _SessionListPageState(); +} + +class _SessionListPageState extends ConsumerState { + late final TextEditingController _agentUrlController; + + @override + void initState() { + super.initState(); + _agentUrlController = TextEditingController( + text: ref.read(agentBaseUriProvider).toString(), + ); + } + + @override + void dispose() { + _agentUrlController.dispose(); + super.dispose(); + } + + void _showMessage(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + + Future _refreshSessions() async { + await _reloadSessions(); + } + + Future _createSession() async { + final repository = ref.read(sessionRepositoryProvider); + var sessionNameInput = ''; + final sessionName = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('New session'), + content: TextField( + autofocus: true, + textInputAction: TextInputAction.done, + decoration: const InputDecoration( + labelText: 'Session name', + hintText: 'codex-main', + ), + onChanged: (value) { + sessionNameInput = value; + }, + onSubmitted: (value) { + sessionNameInput = value; + Navigator.of(context).pop(value); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(sessionNameInput), + child: const Text('Create'), + ), + ], + ); + }, + ); + + final trimmedName = sessionName?.trim() ?? ''; + if (trimmedName.isEmpty) { + return; + } + + try { + await repository.createSession(trimmedName); + await _reloadSessions(); + } catch (error) { + if (!mounted) { + return; + } + + _showMessage('Failed to create session: $error'); + } + } + + void _openSession(Session session) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) { + return TerminalPage( + session: session, + agentBaseUri: ref.read(agentBaseUriProvider), + ); + }, + ), + ); + } + + Future _applyAgentUrl() async { + final parsedUri = Uri.tryParse(_agentUrlController.text.trim()); + if (parsedUri == null || parsedUri.scheme.isEmpty || parsedUri.host.isEmpty) { + _showMessage('Enter a valid agent base URL.'); + return; + } + + final current = ref.read(agentBaseUriProvider); + if (current == parsedUri) { + return; + } + + ref.read(agentBaseUriProvider.notifier).state = parsedUri; + await _reloadSessions(); + } + + Future _reloadSessions() async { + ref.invalidate(sessionsProvider); + try { + await ref.read(sessionsProvider.future); + } catch (error) { + if (!mounted) { + return; + } + + _showMessage('Failed to load sessions: $error'); + } + } + + Widget _buildAgentConfigCard(Uri baseUri) { + return Card( + margin: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Agent base URL', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: _agentUrlController, + decoration: const InputDecoration( + hintText: 'http://10.0.2.2:5067', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.url, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _applyAgentUrl(), + ), + ), + const SizedBox(width: 12), + FilledButton( + onPressed: _applyAgentUrl, + child: const Text('Use'), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Session requests use this base origin: ${baseUri.toString()}.', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ); + } + + Widget _buildSessionsBody(AsyncValue> sessionsAsync) { + return sessionsAsync.when( + loading: () { + return const Center(child: CircularProgressIndicator()); + }, + error: (error, stackTrace) { + return RefreshIndicator( + onRefresh: _refreshSessions, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(24), + children: [ + const SizedBox(height: 96), + const Icon(Icons.cloud_off_outlined, size: 48), + const SizedBox(height: 16), + Text( + 'Could not load sessions', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + '$error', + textAlign: TextAlign.center, + ), + ], + ), + ); + }, + data: (sessions) { + return RefreshIndicator( + onRefresh: _refreshSessions, + child: sessions.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(24), + children: [ + const SizedBox(height: 56), + Icon( + Icons.terminal_outlined, + size: 56, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + 'No sessions yet', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Create a session after you point the app at a reachable agent.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ) + : ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + itemCount: sessions.length, + separatorBuilder: (context, index) => + const SizedBox(height: 12), + itemBuilder: (context, index) { + final session = sessions[index]; + return Card( + child: ListTile( + onTap: () => _openSession(session), + leading: const Icon(Icons.terminal), + title: Text(session.name), + subtitle: Text('Status: ${session.status}'), + trailing: Text( + session.sessionId, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ); + }, + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final baseUri = ref.watch(agentBaseUriProvider); + final sessionsAsync = ref.watch(sessionsProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Sessions'), + actions: [ + IconButton( + onPressed: _refreshSessions, + tooltip: 'Refresh sessions', + icon: const Icon(Icons.refresh), + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: _createSession, + tooltip: 'Create session', + child: const Icon(Icons.add), + ), + body: Column( + children: [ + _buildAgentConfigCard(baseUri), + Expanded(child: _buildSessionsBody(sessionsAsync)), + ], + ), + ); + } +} diff --git a/apps/mobile_app/lib/features/sessions/session_repository.dart b/apps/mobile_app/lib/features/sessions/session_repository.dart new file mode 100644 index 0000000..c63c047 --- /dev/null +++ b/apps/mobile_app/lib/features/sessions/session_repository.dart @@ -0,0 +1,18 @@ +import 'package:term_remote_ctl/core/network/agent_api_client.dart'; +import 'package:term_remote_ctl/features/sessions/session.dart'; + +class SessionRepository { + SessionRepository(this._client); + + final AgentApiClient _client; + + Future> listSessions() async { + final sessions = await _client.listSessions(); + return sessions.map(Session.fromJson).toList(growable: false); + } + + Future createSession(String name) async { + final session = await _client.createSession(name); + return Session.fromJson(session); + } +} diff --git a/apps/mobile_app/lib/features/terminal/history_window.dart b/apps/mobile_app/lib/features/terminal/history_window.dart new file mode 100644 index 0000000..b886cf9 --- /dev/null +++ b/apps/mobile_app/lib/features/terminal/history_window.dart @@ -0,0 +1,9 @@ +class HistoryWindow { + const HistoryWindow({ + required this.lines, + required this.hasMoreAbove, + }); + + final List lines; + final bool hasMoreAbove; +} diff --git a/apps/mobile_app/lib/features/terminal/terminal_controller.dart b/apps/mobile_app/lib/features/terminal/terminal_controller.dart new file mode 100644 index 0000000..947e395 --- /dev/null +++ b/apps/mobile_app/lib/features/terminal/terminal_controller.dart @@ -0,0 +1,7 @@ +import 'history_window.dart'; +import 'terminal_interaction_controller.dart'; + +class TerminalController extends TerminalInteractionController { + TerminalController({HistoryWindow historyWindow = const HistoryWindow(lines: [], hasMoreAbove: false)}) + : super(historyWindow: historyWindow); +} diff --git a/apps/mobile_app/lib/features/terminal/terminal_interaction_controller.dart b/apps/mobile_app/lib/features/terminal/terminal_interaction_controller.dart new file mode 100644 index 0000000..7072603 --- /dev/null +++ b/apps/mobile_app/lib/features/terminal/terminal_interaction_controller.dart @@ -0,0 +1,89 @@ +import 'package:flutter/foundation.dart'; + +import 'history_window.dart'; + +enum TerminalConnectionState { + connecting, + connected, + reconnecting, + disconnected, +} + +class TerminalInteractionController extends ChangeNotifier { + TerminalInteractionController({ + HistoryWindow historyWindow = const HistoryWindow( + lines: [], + hasMoreAbove: false, + ), + }) : _historyWindow = historyWindow; + + TerminalConnectionState _connectionState = TerminalConnectionState.connecting; + bool _isFollowingLiveOutput = true; + bool _hasPendingLiveOutput = false; + HistoryWindow _historyWindow; + final List _liveLines = []; + + TerminalConnectionState get connectionState => _connectionState; + + bool get isFollowingLiveOutput => _isFollowingLiveOutput; + + bool get hasPendingLiveOutput => _hasPendingLiveOutput; + + bool get canSendInput => _connectionState == TerminalConnectionState.connected; + + HistoryWindow get historyWindow => _historyWindow; + + List get liveLines => List.unmodifiable(_liveLines); + + void markConnecting() { + _connectionState = TerminalConnectionState.connecting; + notifyListeners(); + } + + void markConnected() { + _connectionState = TerminalConnectionState.connected; + notifyListeners(); + } + + void markReconnecting() { + _connectionState = TerminalConnectionState.reconnecting; + notifyListeners(); + } + + void markDisconnected() { + _connectionState = TerminalConnectionState.disconnected; + notifyListeners(); + } + + void enterScrollback() { + _isFollowingLiveOutput = false; + notifyListeners(); + } + + void jumpToLive() { + _isFollowingLiveOutput = true; + _hasPendingLiveOutput = false; + notifyListeners(); + } + + void registerIncomingFrame() { + if (!_isFollowingLiveOutput) { + _hasPendingLiveOutput = true; + notifyListeners(); + } + } + + void applyFrame(String chunk) { + _liveLines.add(chunk); + notifyListeners(); + } + + void loadHistory(HistoryWindow historyWindow) { + _historyWindow = historyWindow; + _liveLines + ..clear() + ..addAll(historyWindow.lines); + _hasPendingLiveOutput = false; + notifyListeners(); + } +} diff --git a/apps/mobile_app/lib/features/terminal/terminal_page.dart b/apps/mobile_app/lib/features/terminal/terminal_page.dart new file mode 100644 index 0000000..11015a4 --- /dev/null +++ b/apps/mobile_app/lib/features/terminal/terminal_page.dart @@ -0,0 +1,468 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:xterm/xterm.dart'; + +import '../../core/network/agent_connection_providers.dart'; +import '../sessions/session.dart'; +import 'history_window.dart'; +import 'terminal_interaction_controller.dart'; +import 'terminal_session_coordinator.dart'; +import 'terminal_socket_session.dart'; + +class TerminalPage extends ConsumerStatefulWidget { + const TerminalPage({ + super.key, + required this.session, + required this.agentBaseUri, + }); + + final Session session; + final Uri agentBaseUri; + + @override + ConsumerState createState() => _TerminalPageState(); +} + +class _TerminalPageState extends ConsumerState { + final Terminal terminal = Terminal(maxLines: 1000); + final TerminalInteractionController controller = + TerminalInteractionController(); + final FocusNode _terminalFocusNode = FocusNode(); + final TextEditingController _inputController = TextEditingController(); + late final TerminalSessionCoordinator _coordinator; + late final Listenable _controllerAndCoordinator; + + @override + void initState() { + super.initState(); + _coordinator = TerminalSessionCoordinator( + controller: controller, + apiClient: ref.read(agentApiClientProvider), + session: widget.session, + sessionFactory: ref.read(terminalSocketSessionFactoryProvider).create, + baseUri: widget.agentBaseUri, + onFrame: terminal.write, + onHistoryLoaded: (history) { + if (history.lines.isNotEmpty) { + terminal.write('${history.lines.join('\r\n')}\r\n'); + } + }, + viewportProvider: () => TerminalViewport( + columns: terminal.viewWidth, + rows: terminal.viewHeight, + ), + ); + _controllerAndCoordinator = Listenable.merge([controller, _coordinator]); + terminal.onResize = (width, height, _, _) { + _coordinator.handleTerminalResize(width, height); + }; + terminal.onOutput = _coordinator.sendInput; + unawaited(_coordinator.start()); + } + + @override + void dispose() { + _terminalFocusNode.dispose(); + _inputController.dispose(); + unawaited(_coordinator.close()); + controller.dispose(); + super.dispose(); + } + + Future _sendLine() async { + final input = _inputController.text; + if (!_canSendInput || input.trim().isEmpty) { + return; + } + + _coordinator.sendInput('$input\r'); + _inputController.clear(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.session.name), + actions: [ + AnimatedBuilder( + animation: controller, + builder: (context, _) { + final mode = controller.isFollowingLiveOutput + ? 'Live' + : 'Scrollback'; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: InkWell( + borderRadius: BorderRadius.circular(999), + onTap: () { + if (controller.isFollowingLiveOutput) { + controller.enterScrollback(); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 6, + ), + child: Text('$mode | ${controller.liveLines.length} lines'), + ), + ), + ), + if (!controller.isFollowingLiveOutput) + Padding( + padding: const EdgeInsets.only(right: 12), + child: TextButton( + onPressed: controller.jumpToLive, + child: const Text('Back to live'), + ), + ), + ], + ); + }, + ), + ], + ), + body: Column( + children: [ + AnimatedBuilder( + animation: _controllerAndCoordinator, + builder: (context, _) { + if (controller.isFollowingLiveOutput) { + return const SizedBox.shrink(); + } + + return Container( + width: double.infinity, + margin: const EdgeInsets.fromLTRB(12, 6, 12, 0), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Recent scrollback', + style: Theme.of(context).textTheme.titleSmall, + ), + const Spacer(), + Text( + '${controller.historyWindow.lines.length} lines loaded', + style: Theme.of(context).textTheme.labelMedium, + ), + ], + ), + const SizedBox(height: 6), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 72), + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + ), + child: ListView.separated( + key: const Key('terminal_scrollback_list'), + shrinkWrap: true, + itemCount: controller.historyWindow.lines.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + child: Text( + controller.historyWindow.lines[index], + style: Theme.of(context).textTheme.bodySmall, + ), + ); + }, + separatorBuilder: (context, index) => Divider( + height: 1, + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + ), + ), + const SizedBox(height: 6), + Container( + key: const Key('terminal_scrollback_actions'), + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Expanded( + child: Text( + controller.historyWindow.hasMoreAbove + ? 'Recent history is loaded. Older lines are not loaded yet.' + : 'All loaded history is visible.', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + if (controller.historyWindow.hasMoreAbove) ...[ + const SizedBox(width: 8), + TextButton.icon( + onPressed: _coordinator.isLoadingOlderHistory + ? null + : _coordinator.loadOlderHistory, + icon: _coordinator.isLoadingOlderHistory + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.unfold_less_double), + label: Text( + _coordinator.isLoadingOlderHistory + ? 'Loading older lines...' + : 'Load older lines', + ), + ), + ], + ], + ), + ), + ], + ), + ); + }, + ), + AnimatedBuilder( + animation: controller, + builder: (context, _) { + if (controller.isFollowingLiveOutput) { + return const SizedBox.shrink(); + } + + return Container( + width: double.infinity, + margin: const EdgeInsets.fromLTRB(12, 4, 12, 0), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.pause_circle_outline, + size: 18, + color: Theme.of(context).colorScheme.onSecondaryContainer, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Browsing history. Live output is still arriving.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSecondaryContainer, + ), + ), + if (controller.hasPendingLiveOutput) + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: controller.jumpToLive, + child: const Text('New output available'), + ), + ), + ], + ), + ), + ], + ), + ); + }, + ), + Expanded( + child: TerminalView( + terminal, + focusNode: _terminalFocusNode, + autofocus: true, + ), + ), + AnimatedBuilder( + animation: _controllerAndCoordinator, + builder: (context, _) { + return Material( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Padding( + padding: const EdgeInsets.all(12), + child: Container( + key: const Key('terminal_controls_panel'), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Terminal controls', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 12), + Row( + children: [ + _StatusChip( + label: _statusLabel, + icon: _statusIcon, + color: _statusColor(context), + ), + const SizedBox(width: 8), + _StatusChip( + label: controller.historyWindow.lines.isEmpty + ? 'No history' + : 'History ready', + icon: controller.historyWindow.hasMoreAbove + ? Icons.history_toggle_off + : Icons.history, + color: Theme.of(context).colorScheme.secondary, + ), + const Spacer(), + OutlinedButton.icon( + onPressed: () => unawaited(_coordinator.reconnectNow()), + icon: const Icon(Icons.refresh), + label: const Text('Reconnect'), + ), + ], + ), + const SizedBox(height: 12), + TextField( + controller: _inputController, + enabled: _canSendInput, + decoration: const InputDecoration( + labelText: 'Send input', + hintText: 'dir', + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _sendLine(), + ), + const SizedBox(height: 8), + Row( + children: [ + FilledButton( + onPressed: _canSendInput ? _sendLine : null, + child: const Text('Send'), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + _coordinator.connectionStatus, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ], + ), + ), + ), + ); + }, + ), + ], + ), + ); + } + + String get _statusLabel => switch (_connectionState) { + TerminalConnectionState.connecting => 'Connecting', + TerminalConnectionState.connected => 'Connected', + TerminalConnectionState.reconnecting => 'Reconnecting', + TerminalConnectionState.disconnected => 'Offline', + }; + + bool get _canSendInput => controller.canSendInput; + + IconData get _statusIcon => switch (_connectionState) { + TerminalConnectionState.connecting => Icons.sync, + TerminalConnectionState.connected => Icons.check_circle, + TerminalConnectionState.reconnecting => Icons.refresh, + TerminalConnectionState.disconnected => Icons.portable_wifi_off, + }; + + Color _statusColor(BuildContext context) => switch (_connectionState) { + TerminalConnectionState.connecting => + Theme.of(context).colorScheme.tertiary, + TerminalConnectionState.connected => + Theme.of(context).colorScheme.primary, + TerminalConnectionState.reconnecting => + Theme.of(context).colorScheme.secondary, + TerminalConnectionState.disconnected => + Theme.of(context).colorScheme.error, + }; + + TerminalConnectionState get _connectionState => controller.connectionState; +} + +class _StatusChip extends StatelessWidget { + const _StatusChip({ + required this.label, + required this.icon, + required this.color, + }); + + final String label; + final IconData icon; + final Color color; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withValues(alpha: 0.24)), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 6), + Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart b/apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart new file mode 100644 index 0000000..c1998c4 --- /dev/null +++ b/apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart @@ -0,0 +1,243 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../../core/network/agent_api_client.dart'; +import '../sessions/session.dart'; +import 'history_window.dart'; +import 'terminal_interaction_controller.dart'; +import 'terminal_socket_session.dart'; + +typedef CancelReconnect = void Function(); +typedef ReconnectScheduler = CancelReconnect Function( + Duration delay, + Future Function() callback, +); +typedef TerminalSessionFactory = TerminalSocketSession Function({ + required Uri baseUri, + required Session session, +}); + +class TerminalViewport { + const TerminalViewport({ + required this.columns, + required this.rows, + }); + + final int columns; + final int rows; +} + +class TerminalSessionCoordinator extends ChangeNotifier { + TerminalSessionCoordinator({ + required this.controller, + required this.apiClient, + required this.session, + required this.sessionFactory, + required this.onFrame, + required this.viewportProvider, + Uri? baseUri, + this.onHistoryLoaded, + ReconnectScheduler? reconnectScheduler, + }) : baseUri = baseUri ?? _defaultBaseUri, + _reconnectScheduler = reconnectScheduler ?? _defaultReconnectScheduler; + + static final Uri _defaultBaseUri = Uri( + scheme: 'https', + host: 'host', + port: 9443, + ); + static const Duration reconnectDelay = Duration(seconds: 1); + static const int initialHistoryLineCount = 1000; + + final TerminalInteractionController controller; + final AgentApiClient apiClient; + final Session session; + final TerminalSessionFactory sessionFactory; + final void Function(String frame) onFrame; + final TerminalViewport Function() viewportProvider; + final Uri baseUri; + final void Function(HistoryWindow history)? onHistoryLoaded; + final ReconnectScheduler _reconnectScheduler; + + TerminalSocketSession? _socketSession; + CancelReconnect? _cancelReconnect; + int _historyLineCount = initialHistoryLineCount; + bool _isLoadingOlderHistory = false; + bool _isDisposed = false; + String _connectionStatus = 'Connecting...'; + + bool get isLoadingOlderHistory => _isLoadingOlderHistory; + + String get connectionStatus => _connectionStatus; + + Future start({bool isReconnect = false}) async { + _cancelPendingReconnect(); + + if (_isDisposed) { + return; + } + + if (isReconnect) { + controller.markReconnecting(); + _connectionStatus = 'Reconnecting to ${session.name}...'; + } else { + controller.markConnecting(); + _connectionStatus = 'Connecting...'; + } + notifyListeners(); + + _disposeActiveSessionInBackground(); + + if (!isReconnect && controller.liveLines.isEmpty) { + await _loadHistory(); + } + + if (_isDisposed) { + return; + } + + final socketSession = sessionFactory( + baseUri: baseUri, + session: session, + ); + _socketSession = socketSession; + + try { + await socketSession.connect( + onFrame: _handleFrame, + onDisconnected: () { + if (_isDisposed || !identical(_socketSession, socketSession)) { + return; + } + + _scheduleReconnect(); + }, + ); + + if (_isDisposed || !identical(_socketSession, socketSession)) { + return; + } + + final viewport = viewportProvider(); + socketSession.sendResize(viewport.columns, viewport.rows); + controller.markConnected(); + _connectionStatus = 'Attached to ${session.name}'; + notifyListeners(); + } catch (error) { + if (_isDisposed || !identical(_socketSession, socketSession)) { + return; + } + + controller.markDisconnected(); + _connectionStatus = 'Live connection unavailable: $error'; + notifyListeners(); + _scheduleReconnect(); + } + } + + void handleTerminalResize(int columns, int rows) { + _socketSession?.sendResize(columns, rows); + } + + void sendInput(String input) { + _socketSession?.sendInput(input); + } + + Future loadOlderHistory() async { + if (_isLoadingOlderHistory || !controller.historyWindow.hasMoreAbove) { + return; + } + + _isLoadingOlderHistory = true; + _historyLineCount += initialHistoryLineCount; + notifyListeners(); + + try { + await _loadHistory(); + } finally { + _isLoadingOlderHistory = false; + notifyListeners(); + } + } + + Future reconnectNow() async { + _cancelPendingReconnect(); + await start(isReconnect: true); + } + + Future close() async { + _isDisposed = true; + _cancelPendingReconnect(); + await _closeActiveSession(); + } + + void _handleFrame(String chunk) { + controller.registerIncomingFrame(); + controller.applyFrame(chunk); + onFrame(chunk); + } + + Future _loadHistory() async { + try { + final payload = await apiClient.getSessionHistory( + session.sessionId, + lineCount: _historyLineCount, + ); + final history = HistoryWindow( + lines: ((payload['lines'] as List?) ?? const []) + .map((line) => line.toString()) + .toList(growable: false), + hasMoreAbove: payload['hasMoreAbove'] == true, + ); + controller.loadHistory(history); + onHistoryLoaded?.call(history); + } catch (_) { + } + } + + void _scheduleReconnect() { + _cancelPendingReconnect(); + controller.markReconnecting(); + _connectionStatus = 'Connection lost. Reconnecting...'; + notifyListeners(); + _cancelReconnect = _reconnectScheduler(reconnectDelay, () async { + if (_isDisposed) { + return; + } + + await start(isReconnect: true); + }); + } + + void _cancelPendingReconnect() { + _cancelReconnect?.call(); + _cancelReconnect = null; + } + + void _disposeActiveSessionInBackground() { + final activeSession = _socketSession; + _socketSession = null; + if (activeSession != null) { + unawaited(activeSession.dispose()); + } + } + + Future _closeActiveSession() async { + final activeSession = _socketSession; + _socketSession = null; + if (activeSession != null) { + await activeSession.dispose(); + } + } + + static CancelReconnect _defaultReconnectScheduler( + Duration delay, + Future Function() callback, + ) { + final timer = Timer(delay, () { + unawaited(callback()); + }); + return timer.cancel; + } +} diff --git a/apps/mobile_app/lib/features/terminal/terminal_socket_session.dart b/apps/mobile_app/lib/features/terminal/terminal_socket_session.dart new file mode 100644 index 0000000..1131e87 --- /dev/null +++ b/apps/mobile_app/lib/features/terminal/terminal_socket_session.dart @@ -0,0 +1,179 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +import '../../core/network/agent_api_client.dart'; +import '../../core/network/agent_socket_client.dart'; +import '../sessions/session.dart'; + +typedef TerminalSocketTransportFactory = TerminalSocketTransport Function(Uri uri); + +final terminalSocketSessionFactoryProvider = + Provider((ref) { + return TerminalSocketSessionFactory( + transportFactory: WebSocketTerminalSocketTransport.connect, + ); +}); + +class TerminalSocketSessionFactory { + TerminalSocketSessionFactory({TerminalSocketTransportFactory? transportFactory}) + : _transportFactory = transportFactory ?? WebSocketTerminalSocketTransport.connect; + + final TerminalSocketTransportFactory _transportFactory; + + TerminalSocketSession create({ + required Uri baseUri, + required Session session, + }) { + return TerminalSocketSession( + sessionId: session.sessionId, + socketClient: AgentSocketClient(baseUri), + transportFactory: _transportFactory, + ); + } +} + +class TerminalSocketSession { + TerminalSocketSession({ + required this.sessionId, + required this.socketClient, + TerminalSocketTransportFactory? transportFactory, + }) : _transportFactory = transportFactory ?? WebSocketTerminalSocketTransport.connect; + + final String sessionId; + final AgentSocketClient socketClient; + final TerminalSocketTransportFactory _transportFactory; + + TerminalSocketTransport? _transport; + StreamSubscription? _subscription; + bool _isAttached = false; + + Future connect({ + required void Function(String frame) onFrame, + void Function()? onDisconnected, + }) async { + if (_transport != null || _subscription != null) { + await dispose(); + } + + final transport = _transportFactory(socketClient.buildTerminalSocketUri(sessionId)); + _transport = transport; + + final attachedCompleter = Completer(); + _subscription = transport.stream.listen( + (message) { + if (message is! String) { + return; + } + + if (!_isAttached && _handleAttachedAck(message)) { + _isAttached = true; + if (!attachedCompleter.isCompleted) { + attachedCompleter.complete(); + } + return; + } + + onFrame(message); + }, + onError: (error, stackTrace) { + if (!attachedCompleter.isCompleted) { + attachedCompleter.completeError(error, stackTrace); + } + }, + onDone: () { + if (!attachedCompleter.isCompleted) { + attachedCompleter.completeError( + StateError('Terminal socket closed before attach acknowledgement.'), + ); + return; + } + + onDisconnected?.call(); + }, + ); + + transport.send(jsonEncode(socketClient.buildAttachMessage(sessionId))); + + try { + await attachedCompleter.future; + } catch (_) { + await dispose(); + rethrow; + } + } + + void sendInput(String input) { + final transport = _transport; + if (transport == null || input.isEmpty) { + return; + } + + transport.send(jsonEncode(socketClient.buildInputMessage(input))); + } + + void sendResize(int columns, int rows) { + final transport = _transport; + if (transport == null || columns <= 0 || rows <= 0) { + return; + } + + transport.send(jsonEncode(socketClient.buildResizeMessage(columns, rows))); + } + + Future dispose() async { + final subscription = _subscription; + final transport = _transport; + _subscription = null; + _transport = null; + _isAttached = false; + await subscription?.cancel(); + try { + await transport?.close(); + } catch (_) { + } + } + + bool _handleAttachedAck(String frame) { + try { + final decoded = jsonDecode(frame); + if (decoded is Map && decoded['type'] == 'attached') { + return true; + } + } catch (_) { + } + + return false; + } +} + +abstract class TerminalSocketTransport { + Stream get stream; + void send(String message); + Future close(); +} + +class WebSocketTerminalSocketTransport implements TerminalSocketTransport { + WebSocketTerminalSocketTransport(this._channel); + + final WebSocketChannel _channel; + + static WebSocketTerminalSocketTransport connect(Uri uri) { + return WebSocketTerminalSocketTransport(WebSocketChannel.connect(uri)); + } + + @override + Stream get stream => _channel.stream; + + @override + void send(String message) { + _channel.sink.add(message); + } + + @override + Future close() { + return _channel.sink.close(); + } +} diff --git a/apps/mobile_app/lib/main.dart b/apps/mobile_app/lib/main.dart index 2772616..2438a1a 100644 --- a/apps/mobile_app/lib/main.dart +++ b/apps/mobile_app/lib/main.dart @@ -1,55 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'app/app.dart'; void main() { - runApp(const TermRemoteCtlApp()); -} - -class TermRemoteCtlApp extends StatelessWidget { - const TermRemoteCtlApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - debugShowCheckedModeBanner: false, - title: 'TermRemoteCtl', - theme: ThemeData( - useMaterial3: true, - colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal), - ), - home: const BootstrapShell(), - ); - } -} - -class BootstrapShell extends StatelessWidget { - const BootstrapShell({super.key}); - - @override - Widget build(BuildContext context) { - return const Scaffold( - body: SafeArea( - child: Center( - child: Padding( - padding: EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.computer, size: 48), - SizedBox(height: 16), - Text( - 'TermRemoteCtl', - textAlign: TextAlign.center, - ), - SizedBox(height: 8), - Text( - 'Bootstrap shell ready for the mobile client.', - textAlign: TextAlign.center, - ), - ], - ), - ), - ), - ), - ); - } + runApp(const ProviderScope(child: TermRemoteCtlApp())); } diff --git a/apps/mobile_app/pubspec.lock b/apps/mobile_app/pubspec.lock index 57eac55..ad38974 100644 --- a/apps/mobile_app/pubspec.lock +++ b/apps/mobile_app/pubspec.lock @@ -1,6 +1,30 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + url: "https://pub.flutter-io.cn" + source: hosted + version: "85.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.7.1" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -17,6 +41,70 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.4" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.1.1" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.4" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.4" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.1.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af" + url: "https://pub.flutter-io.cn" + source: hosted + version: "8.12.5" characters: dependency: transitive description: @@ -25,6 +113,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.4" clock: dependency: transitive description: @@ -33,6 +129,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.1.2" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.11.1" collection: dependency: transitive description: @@ -41,6 +145,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -49,6 +169,38 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.0.9" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.1" + dio: + dependency: "direct main" + description: + name: dio + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.9.2" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.8" fake_async: dependency: transitive description: @@ -57,6 +209,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.3.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -70,11 +238,128 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "5.0.0" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.6.1" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed: + dependency: "direct dev" + description: + name: freezed + sha256: "59a584c24b3acdc5250bb856d0d3e9c0b798ed14a4af1ddb7dc1c7b41df91c9c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.8" + freezed_annotation: + dependency: "direct main" + description: + name: freezed_annotation + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.4" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + url: "https://pub.flutter-io.cn" + source: hosted + version: "14.8.1" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.2" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.9.5" leak_tracker: dependency: transitive description: @@ -107,6 +392,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -131,6 +424,30 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.0" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.4" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" path: dependency: transitive description: @@ -139,11 +456,83 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.5.0" + quiver: + dependency: transitive + description: + name: quiver + sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.2" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.6.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.7" source_span: dependency: transitive description: @@ -160,6 +549,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" stream_channel: dependency: transitive description: @@ -168,6 +565,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -192,6 +597,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.7.6" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: @@ -208,6 +629,62 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: "direct main" + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.3" + xterm: + dependency: "direct main" + description: + name: xterm + sha256: "168dfedca77cba33fdb6f52e2cd001e9fde216e398e89335c19b524bb22da3a2" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.0.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.3" + zmodem: + dependency: transitive + description: + name: zmodem + sha256: "3b7e5b29f3a7d8aee472029b05165a68438eff2f3f7766edf13daba1e297adbf" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.0.6" sdks: dart: ">=3.9.2 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.22.0" diff --git a/apps/mobile_app/pubspec.yaml b/apps/mobile_app/pubspec.yaml index dada8a3..651f70d 100644 --- a/apps/mobile_app/pubspec.yaml +++ b/apps/mobile_app/pubspec.yaml @@ -30,20 +30,22 @@ environment: dependencies: flutter: sdk: flutter - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. + flutter_riverpod: ^2.6.1 + go_router: ^14.8.1 + dio: ^5.8.0+1 + web_socket_channel: ^3.0.3 + freezed_annotation: ^2.4.4 + json_annotation: ^4.9.0 + xterm: ^4.0.0 cupertino_icons: ^1.0.8 dev_dependencies: flutter_test: sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. + build_runner: ^2.4.15 + freezed: ^2.5.8 + json_serializable: ^6.9.4 + mocktail: ^1.0.4 flutter_lints: ^5.0.0 # For information on the generic Dart part of this file, see the diff --git a/apps/mobile_app/test/core/network/agent_api_client_test.dart b/apps/mobile_app/test/core/network/agent_api_client_test.dart new file mode 100644 index 0000000..5dbdca0 --- /dev/null +++ b/apps/mobile_app/test/core/network/agent_api_client_test.dart @@ -0,0 +1,144 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:term_remote_ctl/core/network/agent_api_client.dart'; + +void main() { + test('builds session list url from base uri', () { + final client = AgentApiClient(Uri.parse('https://host:9443')); + expect(client.sessionsUri.toString(), 'https://host:9443/api/sessions'); + }); + + test('builds pairing urls from base uri', () { + final client = AgentApiClient(Uri.parse('https://host:9443')); + + expect(client.pairingCodeUri.toString(), 'https://host:9443/api/pairing/code'); + expect( + client.pairingRedeemUri.toString(), + 'https://host:9443/api/pairing/redeem', + ); + }); + + test('lists sessions from the sessions endpoint', () async { + final adapter = _FakeHttpClientAdapter( + jsonEncode([ + { + 'sessionId': 'abc', + 'name': 'codex-main', + 'status': 'idle', + }, + { + 'sessionId': 'def', + 'name': 'cloud-code', + 'status': 'active', + }, + ]), + ); + final dio = Dio()..httpClientAdapter = adapter; + final client = AgentApiClient(Uri.parse('https://host:9443'), dio: dio); + + final sessions = await client.listSessions(); + + expect(adapter.lastOptions?.method, 'GET'); + expect( + adapter.lastOptions?.uri.toString(), + 'https://host:9443/api/sessions', + ); + expect(sessions, hasLength(2)); + expect(sessions.first['name'], 'codex-main'); + expect(sessions.last['status'], 'active'); + }); + + test('creates a session with the provided name', () async { + final adapter = _FakeHttpClientAdapter( + jsonEncode({ + 'sessionId': 'abc', + 'name': 'codex-main', + 'status': 'idle', + }), + ); + final dio = Dio()..httpClientAdapter = adapter; + final client = AgentApiClient(Uri.parse('https://host:9443'), dio: dio); + + final session = await client.createSession('codex-main'); + + expect(adapter.lastOptions?.method, 'POST'); + expect( + adapter.lastOptions?.uri.toString(), + 'https://host:9443/api/sessions', + ); + expect(adapter.lastOptions?.data, {'name': 'codex-main'}); + expect(session['sessionId'], 'abc'); + expect(session['name'], 'codex-main'); + }); + + test('posts pairing redeem payload to the redeem endpoint', () async { + final adapter = _FakeHttpClientAdapter(); + final dio = Dio()..httpClientAdapter = adapter; + final client = AgentApiClient(Uri.parse('https://host:9443'), dio: dio); + + await client.redeemPairingCode(code: '123456', deviceName: 'tablet'); + + expect(adapter.lastOptions?.method, 'POST'); + expect( + adapter.lastOptions?.uri.toString(), + 'https://host:9443/api/pairing/redeem', + ); + expect( + adapter.lastOptions?.data, + {'code': '123456', 'deviceName': 'tablet'}, + ); + }); + + test('fetches session history from the history endpoint', () async { + final adapter = _FakeHttpClientAdapter( + jsonEncode({ + 'sessionId': 'abc', + 'lines': ['one', 'two'], + 'hasMoreAbove': true, + }), + ); + final dio = Dio()..httpClientAdapter = adapter; + final client = AgentApiClient(Uri.parse('https://host:9443'), dio: dio); + + final history = await client.getSessionHistory('abc', lineCount: 2); + + expect(adapter.lastOptions?.method, 'GET'); + expect( + adapter.lastOptions?.uri.toString(), + 'https://host:9443/api/sessions/abc/history?lineCount=2', + ); + expect(history['sessionId'], 'abc'); + expect(history['hasMoreAbove'], isTrue); + }); +} + +class _FakeHttpClientAdapter implements HttpClientAdapter { + _FakeHttpClientAdapter([this.responseBody = '']); + + final String responseBody; + RequestOptions? lastOptions; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + lastOptions = options; + return ResponseBody.fromString( + responseBody, + 200, + headers: responseBody.isEmpty + ? const >{} + : >{ + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + } + + @override + void close({bool force = false}) {} +} diff --git a/apps/mobile_app/test/core/network/agent_connection_providers_test.dart b/apps/mobile_app/test/core/network/agent_connection_providers_test.dart new file mode 100644 index 0000000..4f4fbe2 --- /dev/null +++ b/apps/mobile_app/test/core/network/agent_connection_providers_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:term_remote_ctl/core/network/agent_api_client.dart'; +import 'package:term_remote_ctl/core/network/agent_connection_providers.dart'; + +void main() { + test('uses the emulator-local http agent base uri by default', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final baseUri = container.read(agentBaseUriProvider); + + expect(baseUri.toString(), 'http://10.0.2.2:5067'); + }); + + test('builds the agent client from the configured base uri', () { + final container = ProviderContainer( + overrides: [ + agentBaseUriProvider.overrideWith((ref) { + return Uri.parse('https://host.example:9443'); + }), + ], + ); + addTearDown(container.dispose); + + final client = container.read(agentApiClientProvider); + + expect(client.baseUri.toString(), 'https://host.example:9443'); + }); + + test('session repository provider depends on the configured agent client', () async { + final client = _FakeAgentApiClient(Uri.parse('https://host.example:9443')); + final container = ProviderContainer( + overrides: [ + agentApiClientProvider.overrideWith((ref) => client), + ], + ); + addTearDown(container.dispose); + + final repository = container.read(sessionRepositoryProvider); + await repository.listSessions(); + + expect(client.listCalls, 1); + }); +} + +class _FakeAgentApiClient extends AgentApiClient { + _FakeAgentApiClient(Uri baseUri) : super(baseUri); + + int listCalls = 0; + + @override + Future>> listSessions() async { + listCalls += 1; + return const []; + } +} diff --git a/apps/mobile_app/test/core/network/agent_socket_client_test.dart b/apps/mobile_app/test/core/network/agent_socket_client_test.dart new file mode 100644 index 0000000..c400fa7 --- /dev/null +++ b/apps/mobile_app/test/core/network/agent_socket_client_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:term_remote_ctl/core/network/agent_socket_client.dart'; + +void main() { + test('builds terminal socket url with wss and session id', () { + final client = AgentSocketClient(Uri.parse('https://host:9443')); + + expect( + client.buildTerminalSocketUri('session-123').toString(), + 'wss://host:9443/ws/terminal?sessionId=session-123', + ); + }); + + test('builds attach message for terminal sessions', () { + final client = AgentSocketClient(Uri.parse('https://host:9443')); + + expect( + client.buildAttachMessage('session-123'), + { + 'type': 'attach', + 'sessionId': 'session-123', + }, + ); + }); + + test('builds input message for terminal input', () { + final client = AgentSocketClient(Uri.parse('https://host:9443')); + + expect( + client.buildInputMessage('ls'), + { + 'type': 'input', + 'input': 'ls', + }, + ); + }); +} diff --git a/apps/mobile_app/test/features/pairing/pairing_controller_test.dart b/apps/mobile_app/test/features/pairing/pairing_controller_test.dart new file mode 100644 index 0000000..7531973 --- /dev/null +++ b/apps/mobile_app/test/features/pairing/pairing_controller_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:term_remote_ctl/core/network/agent_api_client.dart'; +import 'package:term_remote_ctl/features/pairing/pairing_controller.dart'; + +void main() { + test('redeemCode updates state on success', () async { + final client = _FakeAgentApiClient(); + final controller = PairingController(client); + + final future = controller.redeemCode('123456', 'tablet'); + + expect(controller.state, const AsyncLoading()); + await future; + + expect(client.redeemCalls, 1); + expect(client.lastCode, '123456'); + expect(client.lastDeviceName, 'tablet'); + expect(controller.state, const AsyncData(null)); + }); + + test('redeemCode exposes api failures as AsyncError', () async { + final client = _FakeAgentApiClient(shouldThrow: true); + final controller = PairingController(client); + + final future = controller.redeemCode('123456', 'tablet'); + + expect(controller.state, const AsyncLoading()); + await future; + + expect(controller.state, isA>()); + }); +} + +class _FakeAgentApiClient extends AgentApiClient { + _FakeAgentApiClient({this.shouldThrow = false}) : super(Uri.parse('https://host:9443')); + + final bool shouldThrow; + + int redeemCalls = 0; + String? lastCode; + String? lastDeviceName; + + @override + Future redeemPairingCode({ + required String code, + required String deviceName, + }) async { + redeemCalls += 1; + lastCode = code; + lastDeviceName = deviceName; + + if (shouldThrow) { + throw StateError('boom'); + } + } +} diff --git a/apps/mobile_app/test/features/sessions/session_repository_test.dart b/apps/mobile_app/test/features/sessions/session_repository_test.dart new file mode 100644 index 0000000..aa02d53 --- /dev/null +++ b/apps/mobile_app/test/features/sessions/session_repository_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:term_remote_ctl/core/network/agent_api_client.dart'; +import 'package:term_remote_ctl/features/sessions/session.dart'; +import 'package:term_remote_ctl/features/sessions/session_repository.dart'; + +void main() { + test('lists sessions from the agent and maps them to models', () async { + final client = _FakeAgentApiClient( + sessions: const [ + { + 'sessionId': 'abc', + 'name': 'codex-main', + 'status': 'idle', + }, + { + 'sessionId': 'def', + 'name': 'cloud-code', + 'status': 'active', + }, + ], + ); + final repository = SessionRepository(client); + + final sessions = await repository.listSessions(); + + expect(client.listCalls, 1); + expect(sessions, hasLength(2)); + expect(sessions.first.sessionId, 'abc'); + expect(sessions.last.name, 'cloud-code'); + }); + + test('creates a session through the agent and maps the response', () async { + final client = _FakeAgentApiClient( + createdSession: const { + 'sessionId': 'xyz', + 'name': 'new-session', + 'status': 'idle', + }, + ); + final repository = SessionRepository(client); + + final session = await repository.createSession('new-session'); + + expect(client.lastCreatedName, 'new-session'); + expect(session, isA()); + expect(session.sessionId, 'xyz'); + expect(session.name, 'new-session'); + }); +} + +class _FakeAgentApiClient extends AgentApiClient { + _FakeAgentApiClient({ + this.sessions = const [], + this.createdSession, + }) : super(Uri.parse('https://host:9443')); + + final List> sessions; + final Map? createdSession; + + int listCalls = 0; + String? lastCreatedName; + + @override + Future>> listSessions() async { + listCalls += 1; + return sessions; + } + + @override + Future> createSession(String name) async { + lastCreatedName = name; + return createdSession ?? + { + 'sessionId': 'generated', + 'name': name, + 'status': 'idle', + }; + } +} diff --git a/apps/mobile_app/test/features/terminal/terminal_controller_test.dart b/apps/mobile_app/test/features/terminal/terminal_controller_test.dart new file mode 100644 index 0000000..fbf291f --- /dev/null +++ b/apps/mobile_app/test/features/terminal/terminal_controller_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:term_remote_ctl/features/terminal/terminal_controller.dart'; +import 'package:term_remote_ctl/features/terminal/history_window.dart'; + +void main() { + test('entering scrollback keeps live follow disabled after new output', () { + final controller = TerminalController(); + + controller.enterScrollback(); + controller.applyFrame('new output'); + + expect(controller.isFollowingLiveOutput, isFalse); + }); + + test('loading history seeds the scrollback window and visible lines', () { + final controller = TerminalController(); + + controller.loadHistory( + const HistoryWindow(lines: ['one', 'two'], hasMoreAbove: true), + ); + + expect(controller.historyWindow.lines, ['one', 'two']); + expect(controller.historyWindow.hasMoreAbove, isTrue); + expect(controller.liveLines, ['one', 'two']); + }); +} diff --git a/apps/mobile_app/test/features/terminal/terminal_interaction_controller_test.dart b/apps/mobile_app/test/features/terminal/terminal_interaction_controller_test.dart new file mode 100644 index 0000000..840726b --- /dev/null +++ b/apps/mobile_app/test/features/terminal/terminal_interaction_controller_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:term_remote_ctl/features/terminal/history_window.dart'; +import 'package:term_remote_ctl/features/terminal/terminal_interaction_controller.dart'; + +void main() { + test('scrollback mode tracks pending live output until returning to live', () { + final controller = TerminalInteractionController(); + + controller.markConnected(); + controller.enterScrollback(); + controller.registerIncomingFrame(); + + expect(controller.isFollowingLiveOutput, isFalse); + expect(controller.hasPendingLiveOutput, isTrue); + + controller.jumpToLive(); + + expect(controller.isFollowingLiveOutput, isTrue); + expect(controller.hasPendingLiveOutput, isFalse); + }); + + test('loadHistory replaces visible history and clears pending live output', () { + final controller = TerminalInteractionController(); + + controller.enterScrollback(); + controller.registerIncomingFrame(); + controller.loadHistory( + const HistoryWindow(lines: ['one', 'two'], hasMoreAbove: true), + ); + + expect(controller.historyWindow.lines, ['one', 'two']); + expect(controller.historyWindow.hasMoreAbove, isTrue); + expect(controller.liveLines, ['one', 'two']); + expect(controller.hasPendingLiveOutput, isFalse); + }); + + test('connection state controls whether input can be sent', () { + final controller = TerminalInteractionController(); + + expect(controller.canSendInput, isFalse); + + controller.markConnecting(); + expect(controller.canSendInput, isFalse); + + controller.markConnected(); + expect(controller.canSendInput, isTrue); + + controller.markReconnecting(); + expect(controller.canSendInput, isFalse); + }); +} diff --git a/apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart b/apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart new file mode 100644 index 0000000..1e8d162 --- /dev/null +++ b/apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart @@ -0,0 +1,247 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:term_remote_ctl/core/network/agent_api_client.dart'; +import 'package:term_remote_ctl/core/network/agent_socket_client.dart'; +import 'package:term_remote_ctl/features/sessions/session.dart'; +import 'package:term_remote_ctl/features/terminal/terminal_interaction_controller.dart'; +import 'package:term_remote_ctl/features/terminal/terminal_session_coordinator.dart'; +import 'package:term_remote_ctl/features/terminal/terminal_socket_session.dart'; + +void main() { + test('start stays connecting until attach completes and then sends resize', () async { + final controller = TerminalInteractionController(); + final apiClient = _FakeAgentApiClient(); + final sessionFactory = _FakeTerminalSessionFactory(autoConnect: false); + final session = Session(sessionId: 'abc', name: 'codex-main', status: 'idle'); + final coordinator = TerminalSessionCoordinator( + controller: controller, + apiClient: apiClient, + session: session, + sessionFactory: sessionFactory.create, + onFrame: (_) {}, + viewportProvider: () => const TerminalViewport(columns: 132, rows: 40), + ); + + final startFuture = coordinator.start(); + await Future.delayed(Duration.zero); + + expect(controller.connectionState, TerminalConnectionState.connecting); + expect(sessionFactory.createdSessions.single.resizeCalls, isEmpty); + + sessionFactory.createdSessions.single.completeConnect(); + await startFuture; + + expect(controller.connectionState, TerminalConnectionState.connected); + expect(sessionFactory.createdSessions.single.resizeCalls, const [ + [132, 40], + ]); + }); + + test('disconnected session schedules reconnect and reconnects when triggered', () async { + final controller = TerminalInteractionController(); + final apiClient = _FakeAgentApiClient(); + final sessionFactory = _FakeTerminalSessionFactory(); + final reconnectScheduler = _FakeReconnectScheduler(); + final session = Session(sessionId: 'abc', name: 'codex-main', status: 'idle'); + final coordinator = TerminalSessionCoordinator( + controller: controller, + apiClient: apiClient, + session: session, + sessionFactory: sessionFactory.create, + onFrame: (_) {}, + viewportProvider: () => const TerminalViewport(columns: 80, rows: 24), + reconnectScheduler: reconnectScheduler.schedule, + ); + + await coordinator.start(); + expect(sessionFactory.createdSessions, hasLength(1)); + + sessionFactory.createdSessions.single.disconnect(); + + expect(controller.connectionState, TerminalConnectionState.reconnecting); + expect(sessionFactory.createdSessions, hasLength(1)); + expect(reconnectScheduler.pendingCallback, isNotNull); + + await reconnectScheduler.runPending(); + + expect(sessionFactory.createdSessions, hasLength(2)); + expect(controller.connectionState, TerminalConnectionState.connected); + }); + + test('loadOlderHistory increases the requested history window size', () async { + final controller = TerminalInteractionController(); + final apiClient = _FakeAgentApiClient( + responses: [ + { + 'sessionId': 'abc', + 'lines': ['one', 'two'], + 'hasMoreAbove': true, + }, + { + 'sessionId': 'abc', + 'lines': ['zero', 'one', 'two'], + 'hasMoreAbove': false, + }, + ], + ); + final sessionFactory = _FakeTerminalSessionFactory(); + final session = Session(sessionId: 'abc', name: 'codex-main', status: 'idle'); + final coordinator = TerminalSessionCoordinator( + controller: controller, + apiClient: apiClient, + session: session, + sessionFactory: sessionFactory.create, + onFrame: (_) {}, + viewportProvider: () => const TerminalViewport(columns: 80, rows: 24), + ); + + await coordinator.start(); + await coordinator.loadOlderHistory(); + + expect(apiClient.requestedLineCounts, [1000, 2000]); + expect(controller.historyWindow.lines, ['zero', 'one', 'two']); + expect(controller.historyWindow.hasMoreAbove, isFalse); + }); + + test('incoming frames while browsing history flag pending live output', () async { + final controller = TerminalInteractionController(); + final apiClient = _FakeAgentApiClient(); + final sessionFactory = _FakeTerminalSessionFactory(); + final session = Session(sessionId: 'abc', name: 'codex-main', status: 'idle'); + final receivedFrames = []; + final coordinator = TerminalSessionCoordinator( + controller: controller, + apiClient: apiClient, + session: session, + sessionFactory: sessionFactory.create, + onFrame: receivedFrames.add, + viewportProvider: () => const TerminalViewport(columns: 80, rows: 24), + ); + + await coordinator.start(); + controller.enterScrollback(); + + sessionFactory.createdSessions.single.emitFrame('next-line'); + + expect(receivedFrames, ['next-line']); + expect(controller.hasPendingLiveOutput, isTrue); + expect(controller.liveLines, contains('next-line')); + }); +} + +class _FakeAgentApiClient extends AgentApiClient { + _FakeAgentApiClient({List>? responses}) + : _responses = responses ?? + [ + { + 'sessionId': 'abc', + 'lines': ['one', 'two'], + 'hasMoreAbove': true, + }, + ], + super(Uri.parse('https://host:9443')); + + final List> _responses; + final requestedLineCounts = []; + var _index = 0; + + @override + Future> getSessionHistory( + String sessionId, { + int lineCount = 200, + }) async { + requestedLineCounts.add(lineCount); + final response = _responses[_index]; + if (_index < _responses.length - 1) { + _index += 1; + } + + return response; + } +} + +class _FakeTerminalSessionFactory { + _FakeTerminalSessionFactory({this.autoConnect = true}); + + final bool autoConnect; + final createdSessions = <_FakeTerminalSocketSession>[]; + + TerminalSocketSession create({ + required Uri baseUri, + required Session session, + }) { + final fake = _FakeTerminalSocketSession(autoConnect: autoConnect); + createdSessions.add(fake); + return fake; + } +} + +class _FakeTerminalSocketSession extends TerminalSocketSession { + _FakeTerminalSocketSession({required this.autoConnect}) + : super( + sessionId: 'abc', + socketClient: _FakeAgentSocketClient(), + ); + + final bool autoConnect; + final resizeCalls = >[]; + Completer _connectCompleter = Completer(); + void Function(String frame)? _onFrame; + void Function()? _onDisconnected; + + @override + Future connect({ + required void Function(String frame) onFrame, + void Function()? onDisconnected, + }) { + _onFrame = onFrame; + _onDisconnected = onDisconnected; + if (autoConnect && !_connectCompleter.isCompleted) { + _connectCompleter.complete(); + } + return _connectCompleter.future; + } + + @override + void sendResize(int columns, int rows) { + resizeCalls.add([columns, rows]); + } + + void completeConnect() { + if (!_connectCompleter.isCompleted) { + _connectCompleter.complete(); + } + } + + void emitFrame(String frame) { + _onFrame?.call(frame); + } + + void disconnect() { + _onDisconnected?.call(); + } +} + +class _FakeAgentSocketClient extends AgentSocketClient { + _FakeAgentSocketClient() : super(Uri.parse('https://host:9443')); +} + +class _FakeReconnectScheduler { + Future Function()? pendingCallback; + + CancelReconnect schedule(Duration _, Future Function() callback) { + pendingCallback = callback; + return () { + pendingCallback = null; + }; + } + + Future runPending() async { + final callback = pendingCallback; + pendingCallback = null; + if (callback != null) { + await callback(); + } + } +} diff --git a/apps/mobile_app/test/features/terminal/terminal_socket_session_test.dart b/apps/mobile_app/test/features/terminal/terminal_socket_session_test.dart new file mode 100644 index 0000000..247fa12 --- /dev/null +++ b/apps/mobile_app/test/features/terminal/terminal_socket_session_test.dart @@ -0,0 +1,148 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:term_remote_ctl/core/network/agent_socket_client.dart'; +import 'package:term_remote_ctl/features/terminal/terminal_socket_session.dart'; + +void main() { + test('connect waits for attach acknowledgement before completing', () async { + final transport = _FakeTerminalSocketTransport(); + final session = TerminalSocketSession( + sessionId: 'session-123', + socketClient: AgentSocketClient(Uri.parse('https://host:9443')), + transportFactory: (_) => transport, + ); + + final frames = []; + var completed = false; + final connectFuture = session.connect(onFrame: frames.add).then((_) { + completed = true; + }); + await Future.delayed(Duration.zero); + + expect( + transport.sentMessages.first, + '{"type":"attach","sessionId":"session-123"}', + ); + expect(completed, isFalse); + + transport.emit('{"type":"attached","sessionId":"session-123"}'); + await Future.delayed(Duration.zero); + + await connectFuture; + expect(completed, isTrue); + expect(frames, isEmpty); + + transport.emit('abc'); + await Future.delayed(Duration.zero); + expect(frames, ['abc']); + + await session.dispose(); + }); + + test('connect fails if the socket closes before attach acknowledgement', () async { + final transport = _FakeTerminalSocketTransport(); + final session = TerminalSocketSession( + sessionId: 'session-123', + socketClient: AgentSocketClient(Uri.parse('https://host:9443')), + transportFactory: (_) => transport, + ); + + final connectFuture = session.connect(onFrame: (_) {}); + await transport.close(); + + await expectLater(connectFuture, throwsStateError); + }); + + test('sendInput serializes the input message', () async { + final transport = _FakeTerminalSocketTransport(); + final session = TerminalSocketSession( + sessionId: 'session-123', + socketClient: AgentSocketClient(Uri.parse('https://host:9443')), + transportFactory: (_) => transport, + ); + + final connectFuture = session.connect(onFrame: (_) {}); + await Future.delayed(Duration.zero); + transport.emit('{"type":"attached","sessionId":"session-123"}'); + await connectFuture; + session.sendInput('dir\r'); + + expect( + transport.sentMessages, + contains('{"type":"input","input":"dir\\r"}'), + ); + + await session.dispose(); + }); + + test('sendResize serializes the resize message', () async { + final transport = _FakeTerminalSocketTransport(); + final session = TerminalSocketSession( + sessionId: 'session-123', + socketClient: AgentSocketClient(Uri.parse('https://host:9443')), + transportFactory: (_) => transport, + ); + + final connectFuture = session.connect(onFrame: (_) {}); + await Future.delayed(Duration.zero); + transport.emit('{"type":"attached","sessionId":"session-123"}'); + await connectFuture; + + session.sendResize(132, 40); + + expect( + transport.sentMessages, + contains('{"type":"resize","columns":132,"rows":40}'), + ); + + await session.dispose(); + }); + + test('connect notifies when an attached socket closes', () async { + final transport = _FakeTerminalSocketTransport(); + final session = TerminalSocketSession( + sessionId: 'session-123', + socketClient: AgentSocketClient(Uri.parse('https://host:9443')), + transportFactory: (_) => transport, + ); + + var disconnectCount = 0; + final connectFuture = session.connect( + onFrame: (_) {}, + onDisconnected: () { + disconnectCount += 1; + }, + ); + await Future.delayed(Duration.zero); + transport.emit('{"type":"attached","sessionId":"session-123"}'); + await connectFuture; + + await transport.close(); + await Future.delayed(Duration.zero); + + expect(disconnectCount, 1); + }); +} + +class _FakeTerminalSocketTransport implements TerminalSocketTransport { + final _incoming = StreamController.broadcast(); + final sentMessages = []; + + @override + Stream get stream => _incoming.stream; + + @override + void send(String message) { + sentMessages.add(message); + } + + @override + Future close() async { + await _incoming.close(); + } + + void emit(dynamic message) { + _incoming.add(message); + } +} diff --git a/apps/mobile_app/test/widget_test.dart b/apps/mobile_app/test/widget_test.dart index 994a3f2..47142b9 100644 --- a/apps/mobile_app/test/widget_test.dart +++ b/apps/mobile_app/test/widget_test.dart @@ -1,18 +1,550 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; +import 'dart:async'; -import 'package:term_remote_ctl/main.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:term_remote_ctl/app/app.dart'; +import 'package:term_remote_ctl/core/network/agent_api_client.dart'; +import 'package:term_remote_ctl/core/network/agent_connection_providers.dart'; +import 'package:term_remote_ctl/features/sessions/session.dart'; +import 'package:term_remote_ctl/features/sessions/session_repository.dart'; +import 'package:term_remote_ctl/features/terminal/terminal_socket_session.dart'; +import 'package:xterm/xterm.dart'; void main() { - testWidgets('shows the TermRemoteCtl bootstrap shell', (tester) async { - await tester.pumpWidget(const TermRemoteCtlApp()); + testWidgets('shows the sessions shell', (tester) async { + final repository = _FakeSessionRepository(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()), + sessionRepositoryProvider.overrideWithValue(repository), + ], + child: const TermRemoteCtlApp(), + ), + ); + await tester.pumpAndSettle(); + + final agentUrlField = tester.widget(find.byType(TextField).first); + + expect(find.text('Sessions'), findsOneWidget); + expect(find.text('Agent base URL'), findsOneWidget); + expect(agentUrlField.controller?.text, 'http://10.0.2.2:5067'); + expect(agentUrlField.decoration?.hintText, 'http://10.0.2.2:5067'); + expect(find.text('Session requests use this base origin: http://10.0.2.2:5067.'), findsOneWidget); + expect(find.text('codex-main'), findsOneWidget); + }); + + testWidgets('surfaces create-session errors in a snackbar', (tester) async { + final repository = _FakeSessionRepository(shouldThrowOnCreate: true); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + agentBaseUriProvider.overrideWith((ref) { + return Uri.parse('https://host.example:9443'); + }), + agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()), + sessionRepositoryProvider.overrideWithValue(repository), + ], + child: const TermRemoteCtlApp(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField).last, 'broken-session'); + await tester.tap(find.text('Create')); + await tester.pumpAndSettle(); + + expect(find.textContaining('Failed to create session'), findsOneWidget); + }); + + testWidgets('tapping a session opens the terminal page', (tester) async { + final repository = _FakeSessionRepository(); + final socketFactory = TerminalSocketSessionFactory( + transportFactory: (_) => _FakeTerminalSocketTransport(autoAttach: true), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + agentBaseUriProvider.overrideWith((ref) { + return Uri.parse('https://host.example:9443'); + }), + agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()), + sessionRepositoryProvider.overrideWithValue(repository), + terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory), + ], + child: const TermRemoteCtlApp(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('codex-main')); + await tester.pumpAndSettle(); + + expect(find.text('Send input'), findsOneWidget); + expect(find.text('Attached to codex-main'), findsOneWidget); + expect(find.text('Live | 2 lines'), findsOneWidget); + expect(find.text('Terminal controls'), findsOneWidget); + expect(find.byKey(const Key('terminal_controls_panel')), findsOneWidget); + expect(find.text('Connected'), findsOneWidget); + expect(find.text('History ready'), findsOneWidget); + expect(find.text('Reconnect'), findsOneWidget); + expect( + find.text('Live terminal attach is minimal for now. Resize and reconnect are not implemented yet.'), + findsNothing, + ); + }); + + testWidgets('terminal page reconnects after the socket closes', ( + tester, + ) async { + final repository = _FakeSessionRepository(); + final transportFactory = _QueuedTerminalSocketTransportFactory(); + final socketFactory = TerminalSocketSessionFactory( + transportFactory: transportFactory.create, + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + agentBaseUriProvider.overrideWith((ref) { + return Uri.parse('https://host.example:9443'); + }), + agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()), + sessionRepositoryProvider.overrideWithValue(repository), + terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory), + ], + child: const TermRemoteCtlApp(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('codex-main')); + await tester.pumpAndSettle(); + + expect(find.text('Attached to codex-main'), findsOneWidget); + expect(transportFactory.createCount, 1); + + final firstTransport = transportFactory.createdTransports.first; + await firstTransport.close(); await tester.pump(); - expect(find.text('TermRemoteCtl'), findsOneWidget); - expect( - find.text('Bootstrap shell ready for the mobile client.'), - findsOneWidget, + expect(find.text('Reconnecting'), findsOneWidget); + final textField = tester.widget(find.byType(TextField).last); + final sendButton = tester.widget(find.widgetWithText(FilledButton, 'Send')); + expect(textField.enabled, isFalse); + expect(sendButton.onPressed, isNull); + + await tester.pump(const Duration(seconds: 2)); + await tester.pumpAndSettle(); + + expect(transportFactory.createCount, 2); + expect(find.text('Attached to codex-main'), findsOneWidget); + final reconnectedField = tester.widget(find.byType(TextField).last); + final reconnectedButton = tester.widget(find.widgetWithText(FilledButton, 'Send')); + expect(reconnectedField.enabled, isTrue); + expect(reconnectedButton.onPressed, isNotNull); + }); + + testWidgets('terminal page can send input after leaving and reopening a session', ( + tester, + ) async { + final repository = _FakeSessionRepository(); + final transportFactory = _QueuedTerminalSocketTransportFactory(); + final socketFactory = TerminalSocketSessionFactory( + transportFactory: transportFactory.create, ); - expect(find.byIcon(Icons.computer), findsOneWidget); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + agentBaseUriProvider.overrideWith((ref) { + return Uri.parse('https://host.example:9443'); + }), + agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()), + sessionRepositoryProvider.overrideWithValue(repository), + terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory), + ], + child: const TermRemoteCtlApp(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('codex-main')); + await tester.pumpAndSettle(); + expect(transportFactory.createCount, 1); + + await tester.pageBack(); + await tester.pumpAndSettle(); + + await tester.tap(find.text('codex-main')); + await tester.pumpAndSettle(); + + expect(transportFactory.createCount, 2); + expect(find.text('Attached to codex-main'), findsOneWidget); + + await tester.enterText(find.byType(TextField).last, 'dir'); + await tester.tap(find.widgetWithText(FilledButton, 'Send')); + await tester.pumpAndSettle(); + + expect( + transportFactory.createdTransports.last.sentMessages, + contains('{"type":"input","input":"dir\\r"}'), + ); + }); + + testWidgets('terminal view accepts keyboard input after leaving and reopening a session', ( + tester, + ) async { + final repository = _FakeSessionRepository(); + final transportFactory = _QueuedTerminalSocketTransportFactory(); + final socketFactory = TerminalSocketSessionFactory( + transportFactory: transportFactory.create, + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + agentBaseUriProvider.overrideWith((ref) { + return Uri.parse('https://host.example:9443'); + }), + agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()), + sessionRepositoryProvider.overrideWithValue(repository), + terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory), + ], + child: const TermRemoteCtlApp(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('codex-main')); + await tester.pumpAndSettle(); + await tester.pageBack(); + await tester.pumpAndSettle(); + + await tester.tap(find.text('codex-main')); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(TerminalView)); + await tester.pump(const Duration(seconds: 1)); + tester.testTextInput.enterText('pwd'); + await tester.idle(); + + expect( + transportFactory.createdTransports.last.sentMessages, + contains('{"type":"input","input":"pwd"}'), + ); + }); + + testWidgets('terminal view regains keyboard input on reopen without an extra tap', ( + tester, + ) async { + final repository = _FakeSessionRepository(); + final transportFactory = _QueuedTerminalSocketTransportFactory(); + final socketFactory = TerminalSocketSessionFactory( + transportFactory: transportFactory.create, + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + agentBaseUriProvider.overrideWith((ref) { + return Uri.parse('https://host.example:9443'); + }), + agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()), + sessionRepositoryProvider.overrideWithValue(repository), + terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory), + ], + child: const TermRemoteCtlApp(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('codex-main')); + await tester.pumpAndSettle(); + await tester.pageBack(); + await tester.pumpAndSettle(); + + await tester.tap(find.text('codex-main')); + await tester.pumpAndSettle(); + + tester.testTextInput.enterText('whoami'); + await tester.idle(); + + expect( + transportFactory.createdTransports.last.sentMessages, + contains('{"type":"input","input":"whoami"}'), + ); + }); + + testWidgets('terminal page lets the user return to live mode', ( + tester, + ) async { + final repository = _FakeSessionRepository(); + final socketFactory = TerminalSocketSessionFactory( + transportFactory: (_) => _FakeTerminalSocketTransport(autoAttach: true), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + agentBaseUriProvider.overrideWith((ref) { + return Uri.parse('https://host.example:9443'); + }), + agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()), + sessionRepositoryProvider.overrideWithValue(repository), + terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory), + ], + child: const TermRemoteCtlApp(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('codex-main')); + await tester.pumpAndSettle(); + + expect(find.text('Back to live'), findsNothing); + + await tester.tap(find.text('Live | 2 lines')); + await tester.pumpAndSettle(); + + expect(find.text('Back to live'), findsOneWidget); + expect(find.text('Scrollback | 2 lines'), findsOneWidget); + expect(find.text('Recent scrollback'), findsOneWidget); + expect(find.text('Browsing history. Live output is still arriving.'), findsOneWidget); + expect(find.text('one'), findsOneWidget); + expect(find.text('two'), findsOneWidget); + expect(find.byKey(const Key('terminal_scrollback_list')), findsOneWidget); + expect(find.byKey(const Key('terminal_scrollback_actions')), findsOneWidget); + expect(find.text('Recent history is loaded. Older lines are not loaded yet.'), findsOneWidget); + expect(find.text('Load older lines'), findsOneWidget); + + await tester.tap(find.text('Back to live')); + await tester.pumpAndSettle(); + + expect(find.text('Back to live'), findsNothing); + expect(find.text('Live | 2 lines'), findsOneWidget); + expect(find.text('Recent scrollback'), findsNothing); + expect(find.text('Browsing history. Live output is still arriving.'), findsNothing); + }); + + testWidgets('terminal page loads older scrollback lines on demand', ( + tester, + ) async { + final repository = _FakeSessionRepository(); + final socketFactory = TerminalSocketSessionFactory( + transportFactory: (_) => _FakeTerminalSocketTransport(autoAttach: true), + ); + final apiClient = _SequencedHistoryAgentApiClient( + responses: [ + { + 'sessionId': 'abc', + 'lines': ['one', 'two'], + 'hasMoreAbove': true, + }, + { + 'sessionId': 'abc', + 'lines': ['zero', 'one', 'two'], + 'hasMoreAbove': false, + }, + ], + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + agentBaseUriProvider.overrideWith((ref) { + return Uri.parse('https://host.example:9443'); + }), + agentApiClientProvider.overrideWithValue(apiClient), + sessionRepositoryProvider.overrideWithValue(repository), + terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory), + ], + child: const TermRemoteCtlApp(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('codex-main')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Live | 2 lines')); + await tester.pumpAndSettle(); + + expect(find.text('Load older lines'), findsOneWidget); + + await tester.tap(find.text('Load older lines')); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(apiClient.requestedLineCounts, [1000, 2000]); + expect(find.text('Scrollback | 3 lines'), findsOneWidget); + expect(find.text('3 lines loaded'), findsOneWidget); + expect(find.text('zero'), findsOneWidget); + expect(find.text('Load older lines'), findsNothing); + expect( + find.text('Recent history is loaded. Older lines are not loaded yet.'), + findsNothing, + ); + }); + + testWidgets('terminal page surfaces new live output while browsing history', ( + tester, + ) async { + final repository = _FakeSessionRepository(); + final transport = _FakeTerminalSocketTransport(autoAttach: true); + final socketFactory = TerminalSocketSessionFactory( + transportFactory: (_) => transport, + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + agentBaseUriProvider.overrideWith((ref) { + return Uri.parse('https://host.example:9443'); + }), + agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()), + sessionRepositoryProvider.overrideWithValue(repository), + terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory), + ], + child: const TermRemoteCtlApp(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('codex-main')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Live | 2 lines')); + await tester.pumpAndSettle(); + + expect(find.text('New output available'), findsNothing); + + transport.emit('next-line'); + await tester.pumpAndSettle(); + + expect(find.text('New output available'), findsOneWidget); + + await tester.tap(find.text('New output available')); + await tester.pumpAndSettle(); + + expect(find.text('Back to live'), findsNothing); + expect(find.text('Live | 3 lines'), findsOneWidget); + expect(find.text('New output available'), findsNothing); }); } + +class _FakeSessionRepository extends SessionRepository { + _FakeSessionRepository({this.shouldThrowOnCreate = false}) + : _sessions = [ + Session( + sessionId: 'abc', + name: 'codex-main', + status: 'idle', + ), + ], + super(_FakeAgentApiClient()); + + final List _sessions; + final bool shouldThrowOnCreate; + + @override + Future> listSessions() async { + return List.of(_sessions); + } + + @override + Future createSession(String name) async { + if (shouldThrowOnCreate) { + throw StateError('boom'); + } + + return Session(sessionId: 'created', name: name, status: 'idle'); + } +} + +class _FakeAgentApiClient extends AgentApiClient { + _FakeAgentApiClient() : super(Uri.parse('https://host:9443')); + + @override + Future> getSessionHistory( + String sessionId, { + int lineCount = 200, + }) async { + return { + 'sessionId': sessionId, + 'lines': ['one', 'two'], + 'hasMoreAbove': true, + }; + } +} + +class _SequencedHistoryAgentApiClient extends _FakeAgentApiClient { + _SequencedHistoryAgentApiClient({required List> responses}) + : _responses = responses; + + final List> _responses; + final requestedLineCounts = []; + var _index = 0; + + @override + Future> getSessionHistory( + String sessionId, { + int lineCount = 200, + }) async { + requestedLineCounts.add(lineCount); + final response = _responses[_index]; + if (_index < _responses.length - 1) { + _index += 1; + } + + return response; + } +} + +class _FakeTerminalSocketTransport implements TerminalSocketTransport { + _FakeTerminalSocketTransport({this.autoAttach = false}) { + if (autoAttach) { + Future.microtask(() { + emit('{"type":"attached","sessionId":"abc"}'); + }); + } + } + + final bool autoAttach; + final _incoming = StreamController.broadcast(); + final sentMessages = []; + + @override + Stream get stream => _incoming.stream; + + @override + void send(String message) { + sentMessages.add(message); + } + + @override + Future close() async { + await _incoming.close(); + } + + void emit(String message) { + _incoming.add(message); + } +} + +class _QueuedTerminalSocketTransportFactory { + final createdTransports = <_FakeTerminalSocketTransport>[]; + var createCount = 0; + + TerminalSocketTransport create(Uri _) { + final transport = _FakeTerminalSocketTransport(autoAttach: true); + createdTransports.add(transport); + createCount += 1; + return transport; + } +} diff --git a/apps/windows_agent/TermRemoteCtl.Agent.sln b/apps/windows_agent/TermRemoteCtl.Agent.sln index 85d79f6..15f49ab 100644 --- a/apps/windows_agent/TermRemoteCtl.Agent.sln +++ b/apps/windows_agent/TermRemoteCtl.Agent.sln @@ -7,6 +7,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{762BA82A-FFA EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TermRemoteCtl.Agent", "src\TermRemoteCtl.Agent\TermRemoteCtl.Agent.csproj", "{51E4993A-E415-4425-B6B8-9761B2209A5B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TermRemoteCtl.ConPtyHelper", "src\TermRemoteCtl.ConPtyHelper\TermRemoteCtl.ConPtyHelper.csproj", "{5BA37FEB-AB49-4F19-93C5-E8B8B1254DBA}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{ED0BB4F4-E9D8-49A4-A344-773EA12C5970}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TermRemoteCtl.Agent.Tests", "tests\TermRemoteCtl.Agent.Tests\TermRemoteCtl.Agent.Tests.csproj", "{CD81CCA7-FD15-4F13-85D3-89FFE0F0E62F}" @@ -26,6 +28,10 @@ Global {51E4993A-E415-4425-B6B8-9761B2209A5B}.Debug|Any CPU.Build.0 = Debug|Any CPU {51E4993A-E415-4425-B6B8-9761B2209A5B}.Release|Any CPU.ActiveCfg = Release|Any CPU {51E4993A-E415-4425-B6B8-9761B2209A5B}.Release|Any CPU.Build.0 = Release|Any CPU + {5BA37FEB-AB49-4F19-93C5-E8B8B1254DBA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5BA37FEB-AB49-4F19-93C5-E8B8B1254DBA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5BA37FEB-AB49-4F19-93C5-E8B8B1254DBA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5BA37FEB-AB49-4F19-93C5-E8B8B1254DBA}.Release|Any CPU.Build.0 = Release|Any CPU {CD81CCA7-FD15-4F13-85D3-89FFE0F0E62F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CD81CCA7-FD15-4F13-85D3-89FFE0F0E62F}.Debug|Any CPU.Build.0 = Debug|Any CPU {CD81CCA7-FD15-4F13-85D3-89FFE0F0E62F}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -37,6 +43,7 @@ Global EndGlobalSection GlobalSection(NestedProjects) = preSolution {51E4993A-E415-4425-B6B8-9761B2209A5B} = {762BA82A-FFA7-43DE-A006-811AC3BCE95E} + {5BA37FEB-AB49-4F19-93C5-E8B8B1254DBA} = {762BA82A-FFA7-43DE-A006-811AC3BCE95E} {CD81CCA7-FD15-4F13-85D3-89FFE0F0E62F} = {ED0BB4F4-E9D8-49A4-A344-773EA12C5970} {2C37B937-C6AC-401D-9005-9C34A52F3A89} = {ED0BB4F4-E9D8-49A4-A344-773EA12C5970} EndGlobalSection diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Api/SessionEndpoints.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Api/SessionEndpoints.cs index 68dab4a..d2d91ce 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Api/SessionEndpoints.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Api/SessionEndpoints.cs @@ -12,6 +12,20 @@ public static class SessionEndpoints var group = endpoints.MapGroup("/api/sessions"); group.MapGet(string.Empty, (SessionRegistry registry) => Results.Ok(registry.List())); + group.MapGet("/{sessionId}/history", ( + string sessionId, + int? lineCount, + SessionRegistry registry) => + { + try + { + return Results.Ok(registry.GetHistory(sessionId, lineCount ?? 200)); + } + catch (KeyNotFoundException) + { + return Results.NotFound(); + } + }); group.MapPost(string.Empty, async ( HttpRequest httpRequest, diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentEndpointConfiguration.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentEndpointConfiguration.cs index 0c2956e..7dc8f16 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentEndpointConfiguration.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentEndpointConfiguration.cs @@ -7,18 +7,40 @@ public static class AgentEndpointConfiguration { public static Uri BuildListenUri(AgentOptions options) { - return new UriBuilder(Uri.UriSchemeHttps, options.BindAddress, options.HttpsPort).Uri; + if (options.HasHttpsEndpoint) + { + return new UriBuilder(Uri.UriSchemeHttps, options.BindAddress, options.HttpsPort).Uri; + } + + return new UriBuilder(Uri.UriSchemeHttp, options.BindAddress, options.HttpPort).Uri; } public static void ConfigureHttpsEndpoint(KestrelServerOptions kestrel, AgentOptions options) { if (string.Equals(options.BindAddress, "localhost", StringComparison.OrdinalIgnoreCase)) { - kestrel.ListenLocalhost(options.HttpsPort, listenOptions => listenOptions.UseHttps()); + if (options.HasHttpsEndpoint) + { + kestrel.ListenLocalhost(options.HttpsPort, listenOptions => listenOptions.UseHttps()); + } + + if (options.HasHttpEndpoint) + { + kestrel.ListenLocalhost(options.HttpPort); + } + return; } var address = IPAddress.Parse(options.BindAddress); - kestrel.Listen(address, options.HttpsPort, listenOptions => listenOptions.UseHttps()); + if (options.HasHttpsEndpoint) + { + kestrel.Listen(address, options.HttpsPort, listenOptions => listenOptions.UseHttps()); + } + + if (options.HasHttpEndpoint) + { + kestrel.Listen(address, options.HttpPort); + } } } diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptions.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptions.cs index 530892b..e9632bb 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptions.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptions.cs @@ -10,7 +10,13 @@ public sealed class AgentOptions public int HttpsPort { get; set; } + public int HttpPort { get; set; } + public int WebSocketFrameFlushMilliseconds { get; set; } public int RingBufferLineLimit { get; set; } + + public bool HasHttpsEndpoint => HttpsPort > 0; + + public bool HasHttpEndpoint => HttpPort > 0; } diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptionsServiceCollectionExtensions.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptionsServiceCollectionExtensions.cs index 912e681..2e773b1 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptionsServiceCollectionExtensions.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptionsServiceCollectionExtensions.cs @@ -18,6 +18,7 @@ public static class AgentOptionsServiceCollectionExtensions options.DataRoot = effectiveOptions.DataRoot; options.BindAddress = effectiveOptions.BindAddress; options.HttpsPort = effectiveOptions.HttpsPort; + options.HttpPort = effectiveOptions.HttpPort; options.WebSocketFrameFlushMilliseconds = effectiveOptions.WebSocketFrameFlushMilliseconds; options.RingBufferLineLimit = effectiveOptions.RingBufferLineLimit; }) diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptionsValidator.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptionsValidator.cs index a8da287..b2e5950 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptionsValidator.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptionsValidator.cs @@ -19,9 +19,24 @@ public sealed class AgentOptionsValidator : IValidateOptions failures.Add("Agent:BindAddress must be 'localhost' or an IP address literal."); } - if (options.HttpsPort is < 1 or > 65535) + if (options.HttpsPort is < 0 or > 65535) { - failures.Add("Agent:HttpsPort must be between 1 and 65535."); + failures.Add("Agent:HttpsPort must be 0 or between 1 and 65535."); + } + + if (options.HttpPort is < 0 or > 65535) + { + failures.Add("Agent:HttpPort must be 0 or between 1 and 65535."); + } + + if (!options.HasHttpsEndpoint && !options.HasHttpEndpoint) + { + failures.Add("Agent:HttpsPort or Agent:HttpPort must be configured."); + } + + if (options.HasHttpsEndpoint && options.HasHttpEndpoint && options.HttpsPort == options.HttpPort) + { + failures.Add("Agent:HttpsPort and Agent:HttpPort must not be the same when both endpoints are enabled."); } if (options.WebSocketFrameFlushMilliseconds <= 0) diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs index adfde2f..892dd32 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs @@ -2,8 +2,10 @@ using Microsoft.Extensions.Options; using TermRemoteCtl.Agent.Api; using TermRemoteCtl.Agent.Configuration; using TermRemoteCtl.Agent.History; +using TermRemoteCtl.Agent.Realtime; using TermRemoteCtl.Agent.Security; using TermRemoteCtl.Agent.Sessions; +using TermRemoteCtl.Agent.Terminal; var builder = WebApplication.CreateBuilder(args); @@ -13,6 +15,8 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(serviceProvider => { var options = serviceProvider.GetRequiredService>().Value; @@ -32,6 +36,7 @@ Directory.CreateDirectory(agentOptions.DataRoot); app.MapGet("/health", () => Results.Json(new { status = "ok" })); app.MapPairingEndpoints(); app.MapSessionEndpoints(); +app.MapTerminalSocket(); app.Run(); diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Properties/AssemblyInfo.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..e8a76d3 --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using System.Runtime.CompilerServices; +using System.Runtime.Versioning; + +[assembly: SupportedOSPlatform("windows")] +[assembly: InternalsVisibleTo("TermRemoteCtl.Agent.Tests")] +[assembly: InternalsVisibleTo("TermRemoteCtl.Agent.IntegrationTests")] diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs new file mode 100644 index 0000000..db2275e --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs @@ -0,0 +1,220 @@ +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using TermRemoteCtl.Agent.Configuration; +using TermRemoteCtl.Agent.Sessions; +using TermRemoteCtl.Agent.Terminal; + +namespace TermRemoteCtl.Agent.Realtime; + +public static class TerminalWebSocketHandler +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public static WebApplication MapTerminalSocket(this WebApplication app) + { + app.UseWebSockets(); + app.Map("/ws/terminal", HandleTerminalSocketAsync); + return app; + } + + private static async Task HandleTerminalSocketAsync(HttpContext context) + { + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + var sessionId = context.Request.Query["sessionId"].ToString(); + if (string.IsNullOrWhiteSpace(sessionId)) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + var registry = context.RequestServices.GetRequiredService(); + if (!registry.TryGet(sessionId, out _)) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + var host = context.RequestServices.GetRequiredService(); + var options = context.RequestServices.GetRequiredService>().Value; + using var socket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false); + try + { + await host.StartAsync(sessionId, context.RequestAborted).ConfigureAwait(false); + } + catch (Exception ex) + { + if (socket.State == WebSocketState.Open) + { + await socket.CloseAsync(WebSocketCloseStatus.InternalServerError, ex.Message, context.RequestAborted).ConfigureAwait(false); + } + + return; + } + + using var sendGate = new SemaphoreSlim(1, 1); + await using var batcher = new TerminalFrameBatcher( + TimeSpan.FromMilliseconds(options.WebSocketFrameFlushMilliseconds), + chunk => SendTextAsync(socket, chunk, sendGate, context.RequestAborted)); + + void HandleOutput(object? sender, TerminalOutputEventArgs args) + { + if (string.Equals(args.SessionId, sessionId, StringComparison.Ordinal)) + { + batcher.Append(args.Chunk); + } + } + + host.OutputReceived += HandleOutput; + + try + { + await SendJsonAsync(socket, new TerminalAttachResponse(sessionId), sendGate, context.RequestAborted).ConfigureAwait(false); + await ReceiveLoopAsync(context, socket, host, sessionId).ConfigureAwait(false); + } + finally + { + host.OutputReceived -= HandleOutput; + } + } + + private static async Task ReceiveLoopAsync( + HttpContext context, + WebSocket socket, + ISessionHost host, + string sessionId) + { + var buffer = new byte[4096]; + + while (socket.State == WebSocketState.Open && !context.RequestAborted.IsCancellationRequested) + { + using var message = new MemoryStream(); + WebSocketReceiveResult receiveResult; + + do + { + receiveResult = await socket.ReceiveAsync(buffer, context.RequestAborted).ConfigureAwait(false); + if (receiveResult.MessageType == WebSocketMessageType.Close) + { + return; + } + + message.Write(buffer, 0, receiveResult.Count); + } + while (!receiveResult.EndOfMessage); + + if (receiveResult.MessageType != WebSocketMessageType.Text) + { + continue; + } + + await HandleClientMessageAsync( + Encoding.UTF8.GetString(message.ToArray()), + host, + sessionId, + context.RequestAborted).ConfigureAwait(false); + } + } + + private static async Task HandleClientMessageAsync( + string payload, + ISessionHost host, + string sessionId, + CancellationToken cancellationToken) + { + TerminalClientMessage? message; + + try + { + message = JsonSerializer.Deserialize(payload, JsonOptions); + } + catch (JsonException) + { + return; + } + + if (message is null || !string.Equals(message.Type, "input", StringComparison.OrdinalIgnoreCase) && !string.Equals(message.Type, "resize", StringComparison.OrdinalIgnoreCase)) + { + if (message is not null && string.Equals(message.Type, "attach", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + return; + } + + if (string.Equals(message.Type, "input", StringComparison.OrdinalIgnoreCase)) + { + if (!string.IsNullOrEmpty(message.Input)) + { + await host.WriteInputAsync(sessionId, message.Input, cancellationToken).ConfigureAwait(false); + } + + return; + } + + if (message.Columns is > 0 && message.Rows is > 0) + { + await host.ResizeAsync(sessionId, message.Columns.Value, message.Rows.Value, cancellationToken).ConfigureAwait(false); + } + } + + private static async Task SendJsonAsync( + WebSocket socket, + TerminalAttachResponse response, + SemaphoreSlim sendGate, + CancellationToken cancellationToken) + { + var json = JsonSerializer.SerializeToUtf8Bytes(response, JsonOptions); + await SendAsync(socket, json, sendGate, cancellationToken).ConfigureAwait(false); + } + + private static async Task SendTextAsync( + WebSocket socket, + string chunk, + SemaphoreSlim sendGate, + CancellationToken cancellationToken) + { + var bytes = Encoding.UTF8.GetBytes(chunk); + await SendAsync(socket, bytes, sendGate, cancellationToken).ConfigureAwait(false); + } + + private static async Task SendAsync( + WebSocket socket, + byte[] payload, + SemaphoreSlim sendGate, + CancellationToken cancellationToken) + { + await sendGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (socket.State == WebSocketState.Open) + { + await socket.SendAsync(payload, WebSocketMessageType.Text, true, cancellationToken).ConfigureAwait(false); + } + } + catch (WebSocketException) + { + } + finally + { + sendGate.Release(); + } + } + + private sealed record TerminalAttachResponse(string SessionId, string Type = "attached"); + + private sealed record TerminalClientMessage( + string Type, + string? SessionId, + string? Input, + int? Columns, + int? Rows); +} diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRecord.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRecord.cs index 30e7113..6d88517 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRecord.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRecord.cs @@ -6,3 +6,8 @@ public sealed record SessionRecord( string Status, DateTimeOffset CreatedAtUtc, DateTimeOffset UpdatedAtUtc); + +public sealed record SessionHistorySnapshot( + string SessionId, + IReadOnlyList Lines, + bool HasMoreAbove); diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs index 04ffa25..d150841 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs @@ -1,10 +1,22 @@ using System.Collections.Concurrent; +using Microsoft.Extensions.Options; +using TermRemoteCtl.Agent.Configuration; +using TermRemoteCtl.Agent.History; namespace TermRemoteCtl.Agent.Sessions; public sealed class SessionRegistry { private readonly ConcurrentDictionary _records = new(); + private readonly ConcurrentDictionary _historyBySession = new(); + private readonly SessionHistoryStore _historyStore; + private readonly int _ringBufferLineLimit; + + public SessionRegistry(SessionHistoryStore historyStore, IOptions options) + { + _historyStore = historyStore; + _ringBufferLineLimit = options.Value.RingBufferLineLimit; + } public SessionRecord Create(string name, DateTimeOffset now) { @@ -18,6 +30,7 @@ public sealed class SessionRegistry now); _records[record.SessionId] = record; + _historyBySession[record.SessionId] = new TerminalRingBuffer(_ringBufferLineLimit); return record; } @@ -27,4 +40,53 @@ public sealed class SessionRegistry .OrderBy(record => record.Name, StringComparer.Ordinal) .ToArray(); } + + public bool TryGet(string sessionId, out SessionRecord? record) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + return _records.TryGetValue(sessionId, out record); + } + + public async Task AppendOutputAsync( + string sessionId, + string chunk, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentNullException.ThrowIfNull(chunk); + + if (!_records.TryGetValue(sessionId, out var record)) + { + throw new KeyNotFoundException($"Session '{sessionId}' was not found."); + } + + var history = _historyBySession.GetOrAdd( + sessionId, + _ => new TerminalRingBuffer(_ringBufferLineLimit)); + history.Append(chunk); + _records[sessionId] = record with { UpdatedAtUtc = DateTimeOffset.UtcNow }; + await _historyStore.AppendAsync(sessionId, chunk, cancellationToken).ConfigureAwait(false); + } + + public SessionHistorySnapshot GetHistory(string sessionId, int lineCount) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(lineCount); + + if (!_records.ContainsKey(sessionId)) + { + throw new KeyNotFoundException($"Session '{sessionId}' was not found."); + } + + var history = _historyBySession.GetOrAdd( + sessionId, + _ => new TerminalRingBuffer(_ringBufferLineLimit)); + var lines = history.GetSnapshotLines(); + var skipCount = Math.Max(0, lines.Count - lineCount); + + return new SessionHistorySnapshot( + sessionId, + lines.Skip(skipCount).ToArray(), + skipCount > 0); + } } diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/TermRemoteCtl.Agent.csproj b/apps/windows_agent/src/TermRemoteCtl.Agent/TermRemoteCtl.Agent.csproj index 1b28a01..dbe0156 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/TermRemoteCtl.Agent.csproj +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/TermRemoteCtl.Agent.csproj @@ -1,9 +1,28 @@ + WinExe net8.0 enable enable + + + + + + + ..\TermRemoteCtl.ConPtyHelper\bin\$(Configuration)\$(TargetFramework)\ + $(OutDir)ConPtyHelper\ + + + + + + + + + + diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ConPtyInterop.Native.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ConPtyInterop.Native.cs new file mode 100644 index 0000000..c2ed09b --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ConPtyInterop.Native.cs @@ -0,0 +1,344 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Security.Principal; +using System.Runtime.Versioning; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace TermRemoteCtl.Agent.Terminal; + +[SupportedOSPlatform("windows")] +internal static partial class ConPtyInterop +{ + public static void EnsureSupported() + { + if (!OperatingSystem.IsWindows()) + { + throw new PlatformNotSupportedException("ConPTY requires Windows."); + } + } + + [SupportedOSPlatform("windows")] + public static WindowsIdentity GetCurrentWindowsIdentity() + { + EnsureSupported(); + return WindowsIdentity.GetCurrent(); + } + + [SupportedOSPlatform("windows")] + public static string ResolveShellPath() + { + var powershell = Path.Combine(Environment.SystemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"); + if (File.Exists(powershell)) + { + return powershell; + } + + return "pwsh.exe"; + } + + [SupportedOSPlatform("windows")] + public static string ResolveShellArguments() + { + return "-NoLogo -NoProfile -NoExit"; + } + + [SupportedOSPlatform("windows")] + public static ConPtyProcessSession StartProcess(string sessionId, string shellPath, string shellArguments, int columns, int rows) + { + EnsureSupported(); + + if (columns <= 0) + { + throw new ArgumentOutOfRangeException(nameof(columns)); + } + + if (rows <= 0) + { + throw new ArgumentOutOfRangeException(nameof(rows)); + } + + var inputPipe = CreatePipe(); + var outputPipe = CreatePipe(); + var pseudoConsole = CreatePseudoConsole(columns, rows, inputPipe.ReadHandle, outputPipe.WriteHandle); + + var startupInfo = new StartupInfoEx(); + startupInfo.StartupInfo.Cb = (uint)Marshal.SizeOf(); + + nuint attributeListSize = 0; + _ = InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref attributeListSize); + startupInfo.AttributeList = Marshal.AllocHGlobal((int)attributeListSize); + + try + { + if (!InitializeProcThreadAttributeList(startupInfo.AttributeList, 1, 0, ref attributeListSize)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + if (!UpdateProcThreadAttribute( + startupInfo.AttributeList, + 0, + ProcThreadAttributePseudoConsole, + pseudoConsole.DangerousGetHandle(), + IntPtr.Size, + IntPtr.Zero, + IntPtr.Zero)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + startupInfo.StartupInfo.Cb = (uint)Marshal.SizeOf(); + var processInfo = new ProcessInformation(); + var commandLine = new StringBuilder(); + commandLine.Append('"'); + commandLine.Append(shellPath); + commandLine.Append('"'); + + if (!string.IsNullOrWhiteSpace(shellArguments)) + { + commandLine.Append(' '); + commandLine.Append(shellArguments); + } + + if (!CreateProcess( + null, + commandLine, + IntPtr.Zero, + IntPtr.Zero, + false, + CreationFlags.ExtendedStartupInfoPresent, + IntPtr.Zero, + null, + ref startupInfo, + out processInfo)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + var process = Process.GetProcessById((int)processInfo.ProcessId); + var processHandle = processInfo.ProcessHandle; + CloseHandle(processInfo.ThreadHandle); + inputPipe.ReadHandle.Dispose(); + outputPipe.WriteHandle.Dispose(); + + return new ConPtyProcessSession(sessionId, pseudoConsole, inputPipe.WriteHandle, outputPipe.ReadHandle, process, processHandle); + } + catch + { + pseudoConsole.Dispose(); + inputPipe.ReadHandle.Dispose(); + inputPipe.WriteHandle.Dispose(); + outputPipe.WriteHandle.Dispose(); + outputPipe.ReadHandle.Dispose(); + throw; + } + finally + { + startupInfo.Dispose(); + } + } + + [SupportedOSPlatform("windows")] + public static void ResizePseudoConsole(SafePseudoConsoleHandle pseudoConsole, int columns, int rows) + { + ResizePseudoConsole(pseudoConsole.DangerousGetHandle(), new Coord((short)columns, (short)rows)); + } + + [SupportedOSPlatform("windows")] + public static bool TryGetExitCode(IntPtr processHandle, out uint exitCode) + { + return GetExitCodeProcess(processHandle, out exitCode); + } + + [SupportedOSPlatform("windows")] + public static bool CloseNativeHandle(IntPtr handle) + { + return CloseHandle(handle); + } + + [SupportedOSPlatform("windows")] + public static bool ReadPipe(SafeFileHandle handle, byte[] buffer, out int bytesRead) + { + if (!ReadFile(handle, buffer, (uint)buffer.Length, out var nativeBytesRead, IntPtr.Zero)) + { + bytesRead = 0; + return false; + } + + bytesRead = checked((int)nativeBytesRead); + return true; + } + + [SupportedOSPlatform("windows")] + public static bool WritePipe(SafeFileHandle handle, byte[] buffer, out int bytesWritten) + { + if (!WriteFile(handle, buffer, (uint)buffer.Length, out var nativeBytesWritten, IntPtr.Zero)) + { + bytesWritten = 0; + return false; + } + + bytesWritten = checked((int)nativeBytesWritten); + return true; + } + + private static ConPtyPipePair CreatePipe() + { + if (!CreatePipe(out var read, out var write, IntPtr.Zero, 0)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + return new ConPtyPipePair(new SafeFileHandle(read, ownsHandle: true), new SafeFileHandle(write, ownsHandle: true)); + } + + private static SafePseudoConsoleHandle CreatePseudoConsole(int columns, int rows, SafeFileHandle inputRead, SafeFileHandle outputWrite) + { + var hr = CreatePseudoConsole(new Coord((short)columns, (short)rows), inputRead.DangerousGetHandle(), outputWrite.DangerousGetHandle(), 0, out var pseudoConsole); + if (hr != 0) + { + throw new Win32Exception(hr); + } + + return new SafePseudoConsoleHandle(pseudoConsole); + } + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool CreateProcess( + string? lpApplicationName, + StringBuilder lpCommandLine, + IntPtr lpProcessAttributes, + IntPtr lpThreadAttributes, + bool bInheritHandles, + CreationFlags dwCreationFlags, + IntPtr lpEnvironment, + string? lpCurrentDirectory, + ref StartupInfoEx lpStartupInfo, + out ProcessInformation lpProcessInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CreatePipe(out IntPtr hReadPipe, out IntPtr hWritePipe, IntPtr lpPipeAttributes, int nSize); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern int CreatePseudoConsole(Coord size, IntPtr hInput, IntPtr hOutput, uint dwFlags, out IntPtr phPC); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern void ResizePseudoConsole(IntPtr hPC, Coord size); + + [DllImport("kernel32.dll", SetLastError = true)] + internal static extern void ClosePseudoConsole(IntPtr hPC); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool InitializeProcThreadAttributeList(IntPtr lpAttributeList, int dwAttributeCount, int dwFlags, ref nuint lpSize); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool UpdateProcThreadAttribute( + IntPtr lpAttributeList, + uint dwFlags, + IntPtr attribute, + IntPtr lpValue, + IntPtr cbSize, + IntPtr lpPreviousValue, + IntPtr lpReturnSize); + + private const int ProcThreadAttributePseudoConsole = 0x00020016; + + [Flags] + private enum CreationFlags : uint + { + ExtendedStartupInfoPresent = 0x00080000 + } + + [StructLayout(LayoutKind.Sequential)] + private struct Coord + { + public Coord(short x, short y) + { + X = x; + Y = y; + } + + public short X; + + public short Y; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct StartupInfo + { + public uint Cb; + public IntPtr Reserved; + public IntPtr Desktop; + public IntPtr Title; + public uint X; + public uint Y; + public uint XSize; + public uint YSize; + public uint XCountChars; + public uint YCountChars; + public uint FillAttribute; + public uint Flags; + public ushort ShowWindow; + public ushort Reserved2; + public IntPtr Reserved3; + public IntPtr StdInput; + public IntPtr StdOutput; + public IntPtr StdError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct StartupInfoEx : IDisposable + { + public StartupInfo StartupInfo; + public IntPtr AttributeList; + + public void Dispose() + { + if (AttributeList != IntPtr.Zero) + { + DeleteProcThreadAttributeList(AttributeList); + Marshal.FreeHGlobal(AttributeList); + AttributeList = IntPtr.Zero; + } + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct ProcessInformation + { + public IntPtr ProcessHandle; + public IntPtr ThreadHandle; + public uint ProcessId; + public uint ThreadId; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern void DeleteProcThreadAttributeList(IntPtr lpAttributeList); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr hObject); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool ReadFile(SafeFileHandle hFile, byte[] lpBuffer, uint nNumberOfBytesToRead, out uint lpNumberOfBytesRead, IntPtr lpOverlapped); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool WriteFile(SafeFileHandle hFile, byte[] lpBuffer, uint nNumberOfBytesToWrite, out uint lpNumberOfBytesWritten, IntPtr lpOverlapped); + + private sealed class ConPtyPipePair + { + public ConPtyPipePair(SafeFileHandle readHandle, SafeFileHandle writeHandle) + { + ReadHandle = readHandle; + WriteHandle = writeHandle; + } + + public SafeFileHandle ReadHandle { get; } + + public SafeFileHandle WriteHandle { get; } + } +} diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ConPtyProcessSession.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ConPtyProcessSession.cs new file mode 100644 index 0000000..c065ca4 --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ConPtyProcessSession.cs @@ -0,0 +1,199 @@ +using System.Diagnostics; +using System.Text; +using System.Runtime.Versioning; +using Microsoft.Win32.SafeHandles; + +namespace TermRemoteCtl.Agent.Terminal; + +[SupportedOSPlatform("windows")] +internal sealed class ConPtyProcessSession : IConPtySession +{ + private readonly string _sessionId; + private readonly SafePseudoConsoleHandle _pseudoConsole; + private readonly SafeFileHandle _inputWriterHandle; + private readonly SafeFileHandle _outputReaderHandle; + private readonly Process _process; + private readonly IntPtr _processHandle; + private readonly CancellationTokenSource _shutdown = new(); + private readonly SemaphoreSlim _inputGate = new(1, 1); + private Task? _outputPumpTask; + private bool _started; + private bool _disposed; + + public ConPtyProcessSession( + string sessionId, + SafePseudoConsoleHandle pseudoConsole, + SafeFileHandle inputWriterHandle, + SafeFileHandle outputReaderHandle, + Process process, + IntPtr processHandle) + { + _sessionId = sessionId; + _pseudoConsole = pseudoConsole; + _inputWriterHandle = inputWriterHandle; + _outputReaderHandle = outputReaderHandle; + _process = process; + _processHandle = processHandle; + } + + public event EventHandler? OutputReceived; + + public int? TryGetExitCode() + { + if (_processHandle == IntPtr.Zero) + { + return null; + } + + return ConPtyInterop.TryGetExitCode(_processHandle, out var exitCode) ? unchecked((int)exitCode) : null; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + ThrowIfDisposed(); + if (_started) + { + return; + } + + _started = true; + + _outputPumpTask = Task.Run(() => PumpOutputAsync(_shutdown.Token)); + await Task.CompletedTask.ConfigureAwait(false); + } + + public async Task WriteInputAsync(string input, CancellationToken cancellationToken) + { + ThrowIfDisposed(); + await _inputGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var payload = Encoding.UTF8.GetBytes(input); + await Task.Run(() => + { + if (!ConPtyInterop.WritePipe(_inputWriterHandle, payload, out var written) || written != payload.Length) + { + throw new IOException("Failed to write terminal input to the ConPTY pipe."); + } + }, cancellationToken).ConfigureAwait(false); + } + finally + { + _inputGate.Release(); + } + } + + public Task ResizeAsync(int columns, int rows, CancellationToken cancellationToken) + { + ThrowIfDisposed(); + if (columns <= 0) + { + throw new ArgumentOutOfRangeException(nameof(columns)); + } + + if (rows <= 0) + { + throw new ArgumentOutOfRangeException(nameof(rows)); + } + + ConPtyInterop.ResizePseudoConsole(_pseudoConsole, columns, rows); + return Task.CompletedTask; + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + _shutdown.Cancel(); + + _inputWriterHandle.Dispose(); + _outputReaderHandle.Dispose(); + _pseudoConsole.Dispose(); + + try + { + if (!_process.HasExited) + { + _process.Kill(entireProcessTree: true); + } + } + catch (InvalidOperationException) + { + } + catch (NotSupportedException) + { + } + + try + { + if (_outputPumpTask is not null) + { + await _outputPumpTask.ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + } + + if (_process is not null) + { + try + { + await _process.WaitForExitAsync().ConfigureAwait(false); + } + catch (InvalidOperationException) + { + } + } + + if (_processHandle != IntPtr.Zero) + { + ConPtyInterop.CloseNativeHandle(_processHandle); + } + _process?.Dispose(); + _shutdown.Dispose(); + _inputGate.Dispose(); + } + + private async Task PumpOutputAsync(CancellationToken cancellationToken) + { + var buffer = new byte[4096]; + + try + { + while (!cancellationToken.IsCancellationRequested) + { + var read = await Task.Run(() => + { + return ConPtyInterop.ReadPipe(_outputReaderHandle, buffer, out var bytesRead) ? bytesRead : 0; + }, cancellationToken).ConfigureAwait(false); + + if (read <= 0) + { + return; + } + + var chunk = Encoding.UTF8.GetString(buffer, 0, read); + OutputReceived?.Invoke(this, new TerminalOutputEventArgs(_sessionId, chunk)); + } + } + catch (OperationCanceledException) + { + } + catch (IOException) + { + } + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(ConPtyProcessSession)); + } + } +} diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ConPtySafeHandles.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ConPtySafeHandles.cs new file mode 100644 index 0000000..a1d30c6 --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ConPtySafeHandles.cs @@ -0,0 +1,20 @@ +using System.Runtime.Versioning; +using Microsoft.Win32.SafeHandles; + +namespace TermRemoteCtl.Agent.Terminal; + +[SupportedOSPlatform("windows")] +internal sealed class SafePseudoConsoleHandle : SafeHandleZeroOrMinusOneIsInvalid +{ + public SafePseudoConsoleHandle(IntPtr handle) + : base(ownsHandle: true) + { + SetHandle(handle); + } + + protected override bool ReleaseHandle() + { + ConPtyInterop.ClosePseudoConsole(handle); + return true; + } +} diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ConPtySessionFactory.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ConPtySessionFactory.cs new file mode 100644 index 0000000..07fef32 --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ConPtySessionFactory.cs @@ -0,0 +1,17 @@ +using System.Runtime.Versioning; + +namespace TermRemoteCtl.Agent.Terminal; + +[SupportedOSPlatform("windows")] +internal sealed class ConPtySessionFactory : IConPtySessionFactory +{ + public IConPtySession Create(string sessionId) + { + ConPtyInterop.EnsureSupported(); + return new HelperBackedConPtySession( + sessionId, + ConPtyInterop.ResolveShellPath(), + ConPtyInterop.ResolveShellArguments(), + HelperPathResolver.ResolveHelperExePath()); + } +} diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/HelperBackedConPtySession.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/HelperBackedConPtySession.cs new file mode 100644 index 0000000..483fe9d --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/HelperBackedConPtySession.cs @@ -0,0 +1,211 @@ +using System.Diagnostics; +using System.IO.Pipes; +using System.Text; +using System.Text.Json; +using System.Runtime.Versioning; + +namespace TermRemoteCtl.Agent.Terminal; + +[SupportedOSPlatform("windows")] +internal sealed class HelperBackedConPtySession : IConPtySession +{ + private readonly string _sessionId; + private readonly string _shellPath; + private readonly string _shellArguments; + private readonly string _helperExePath; + private readonly string _commandPipeName = $"termremotectl-cmd-{Guid.NewGuid():N}"; + private readonly string _outputPipeName = $"termremotectl-out-{Guid.NewGuid():N}"; + private Process? _helperProcess; + private NamedPipeServerStream? _commandPipe; + private NamedPipeServerStream? _outputPipe; + private StreamWriter? _commandWriter; + private StreamReader? _outputReader; + private Task? _outputPumpTask; + private bool _started; + private bool _disposed; + + public HelperBackedConPtySession(string sessionId, string shellPath, string shellArguments, string helperExePath) + { + _sessionId = sessionId; + _shellPath = shellPath; + _shellArguments = shellArguments; + _helperExePath = helperExePath; + } + + public event EventHandler? OutputReceived; + + public async Task StartAsync(CancellationToken cancellationToken) + { + ThrowIfDisposed(); + if (_started) + { + return; + } + + _commandPipe = new NamedPipeServerStream(_commandPipeName, PipeDirection.Out, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous); + _outputPipe = new NamedPipeServerStream(_outputPipeName, PipeDirection.In, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous); + + var startInfo = new ProcessStartInfo(_helperExePath) + { + UseShellExecute = false, + CreateNoWindow = true + }; + startInfo.ArgumentList.Add("--command-pipe"); + startInfo.ArgumentList.Add(_commandPipeName); + startInfo.ArgumentList.Add("--output-pipe"); + startInfo.ArgumentList.Add(_outputPipeName); + startInfo.ArgumentList.Add("--shell-path"); + startInfo.ArgumentList.Add(_shellPath); + startInfo.ArgumentList.Add("--shell-args"); + startInfo.ArgumentList.Add(_shellArguments); + startInfo.ArgumentList.Add("--columns"); + startInfo.ArgumentList.Add("120"); + startInfo.ArgumentList.Add("--rows"); + startInfo.ArgumentList.Add("30"); + + _helperProcess = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start ConPTY helper process."); + + using var startupTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + startupTimeout.CancelAfter(TimeSpan.FromSeconds(10)); + + await Task.WhenAll( + _commandPipe.WaitForConnectionAsync(startupTimeout.Token), + _outputPipe.WaitForConnectionAsync(startupTimeout.Token)).ConfigureAwait(false); + + _commandWriter = new StreamWriter(_commandPipe, new UTF8Encoding(false), leaveOpen: true) { AutoFlush = true }; + _outputReader = new StreamReader(_outputPipe, new UTF8Encoding(false), detectEncodingFromByteOrderMarks: false, leaveOpen: true); + + var handshake = await _outputReader.ReadLineAsync(startupTimeout.Token).ConfigureAwait(false); + if (handshake is null) + { + throw new InvalidOperationException("ConPTY helper closed before sending a startup handshake."); + } + + var handshakeMessage = JsonSerializer.Deserialize(handshake); + if (handshakeMessage is null) + { + throw new InvalidOperationException("ConPTY helper returned an invalid startup handshake."); + } + + if (string.Equals(handshakeMessage.Type, "error", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"ConPTY helper startup failed: {handshakeMessage.Data}"); + } + + if (!string.Equals(handshakeMessage.Type, "ready", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"Unexpected ConPTY helper startup handshake: {handshake}"); + } + + _outputPumpTask = Task.Run(() => PumpOutputAsync(_outputReader, cancellationToken), cancellationToken); + _started = true; + } + + public async Task WriteInputAsync(string input, CancellationToken cancellationToken) + { + if (_commandWriter is null) + { + throw new InvalidOperationException("Session has not been started."); + } + + var payload = JsonSerializer.Serialize(new HelperCommandMessage("input", input, null, null)); + await _commandWriter.WriteLineAsync(payload.AsMemory(), cancellationToken).ConfigureAwait(false); + } + + public async Task ResizeAsync(int columns, int rows, CancellationToken cancellationToken) + { + if (_commandWriter is null) + { + throw new InvalidOperationException("Session has not been started."); + } + + var payload = JsonSerializer.Serialize(new HelperCommandMessage("resize", null, columns, rows)); + await _commandWriter.WriteLineAsync(payload.AsMemory(), cancellationToken).ConfigureAwait(false); + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + + if (_commandWriter is not null) + { + try + { + await _commandWriter.WriteLineAsync(JsonSerializer.Serialize(new HelperCommandMessage("shutdown", null, null, null))).ConfigureAwait(false); + } + catch + { + } + } + + if (_helperProcess is not null && !_helperProcess.HasExited) + { + try + { + _helperProcess.Kill(entireProcessTree: true); + } + catch + { + } + } + + if (_outputPumpTask is not null) + { + await _outputPumpTask.ConfigureAwait(false); + } + + _commandWriter?.Dispose(); + _outputReader?.Dispose(); + _commandPipe?.Dispose(); + _outputPipe?.Dispose(); + _helperProcess?.Dispose(); + } + + private async Task PumpOutputAsync(StreamReader reader, CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + string? line; + try + { + line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + + if (line is null) + { + return; + } + + var message = JsonSerializer.Deserialize(line); + if (message is null) + { + continue; + } + + if (string.Equals(message.Type, "output", StringComparison.OrdinalIgnoreCase) && message.Data is not null) + { + OutputReceived?.Invoke(this, new TerminalOutputEventArgs(_sessionId, message.Data)); + } + } + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(HelperBackedConPtySession)); + } + } + + private sealed record HelperCommandMessage(string Type, string? Data, int? Columns, int? Rows); + private sealed record HelperOutputMessage(string Type, string? Data); +} diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/HelperPathResolver.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/HelperPathResolver.cs new file mode 100644 index 0000000..c7c4827 --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/HelperPathResolver.cs @@ -0,0 +1,33 @@ +namespace TermRemoteCtl.Agent.Terminal; + +internal static class HelperPathResolver +{ + public static string ResolveHelperExePath() + { + var helperPath = Path.Combine(AppContext.BaseDirectory, "ConPtyHelper", "TermRemoteCtl.ConPtyHelper.exe"); + if (File.Exists(helperPath)) + { + return helperPath; + } + + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + var debugCandidate = Path.Combine(current.FullName, "src", "TermRemoteCtl.ConPtyHelper", "bin", "Debug", "net8.0", "TermRemoteCtl.ConPtyHelper.exe"); + if (File.Exists(debugCandidate)) + { + return debugCandidate; + } + + var releaseCandidate = Path.Combine(current.FullName, "src", "TermRemoteCtl.ConPtyHelper", "bin", "Release", "net8.0", "TermRemoteCtl.ConPtyHelper.exe"); + if (File.Exists(releaseCandidate)) + { + return releaseCandidate; + } + + current = current.Parent; + } + + throw new FileNotFoundException($"ConPTY helper executable could not be located at '{helperPath}' or any source build output."); + } +} diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/IConPtySession.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/IConPtySession.cs new file mode 100644 index 0000000..3f679a5 --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/IConPtySession.cs @@ -0,0 +1,17 @@ +namespace TermRemoteCtl.Agent.Terminal; + +internal interface IConPtySession : IAsyncDisposable +{ + event EventHandler? OutputReceived; + + Task StartAsync(CancellationToken cancellationToken); + + Task WriteInputAsync(string input, CancellationToken cancellationToken); + + Task ResizeAsync(int columns, int rows, CancellationToken cancellationToken); +} + +internal interface IConPtySessionFactory +{ + IConPtySession Create(string sessionId); +} diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ISessionHost.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ISessionHost.cs new file mode 100644 index 0000000..8bdc8cc --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ISessionHost.cs @@ -0,0 +1,26 @@ +namespace TermRemoteCtl.Agent.Terminal; + +public interface ISessionHost +{ + event EventHandler? OutputReceived; + + Task StartAsync(string sessionId, CancellationToken cancellationToken); + + Task WriteInputAsync(string sessionId, string input, CancellationToken cancellationToken); + + Task ResizeAsync(string sessionId, int columns, int rows, CancellationToken cancellationToken); +} + +public sealed class TerminalOutputEventArgs : EventArgs +{ + public TerminalOutputEventArgs(string sessionId, string chunk) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + Chunk = chunk ?? throw new ArgumentNullException(nameof(chunk)); + SessionId = sessionId; + } + + public string SessionId { get; } + + public string Chunk { get; } +} diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/PowerShellSessionHost.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/PowerShellSessionHost.cs new file mode 100644 index 0000000..f8167e4 --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/PowerShellSessionHost.cs @@ -0,0 +1,95 @@ +using System.Collections.Concurrent; +using System.Runtime.Versioning; +using TermRemoteCtl.Agent.Sessions; + +namespace TermRemoteCtl.Agent.Terminal; + +[SupportedOSPlatform("windows")] +internal sealed class PowerShellSessionHost : ISessionHost, IAsyncDisposable +{ + private readonly IConPtySessionFactory _sessionFactory; + private readonly SessionRegistry _sessionRegistry; + private readonly ConcurrentDictionary _sessions = new(StringComparer.Ordinal); + + public PowerShellSessionHost(IConPtySessionFactory sessionFactory, SessionRegistry sessionRegistry) + { + _sessionFactory = sessionFactory; + _sessionRegistry = sessionRegistry; + } + + public event EventHandler? OutputReceived; + + public async Task StartAsync(string sessionId, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ConPtyInterop.EnsureSupported(); + + if (_sessions.ContainsKey(sessionId)) + { + return; + } + + var session = _sessionFactory.Create(sessionId); + if (!_sessions.TryAdd(sessionId, session)) + { + await session.DisposeAsync().ConfigureAwait(false); + return; + } + + session.OutputReceived += HandleSessionOutput; + + try + { + await session.StartAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + session.OutputReceived -= HandleSessionOutput; + _sessions.TryRemove(sessionId, out _); + await session.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + public async Task WriteInputAsync(string sessionId, string input, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentNullException.ThrowIfNull(input); + + if (!_sessions.TryGetValue(sessionId, out var session)) + { + throw new KeyNotFoundException($"Session '{sessionId}' is not running."); + } + + await session.WriteInputAsync(input, cancellationToken).ConfigureAwait(false); + } + + public async Task ResizeAsync(string sessionId, int columns, int rows, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + + if (!_sessions.TryGetValue(sessionId, out var session)) + { + throw new KeyNotFoundException($"Session '{sessionId}' is not running."); + } + + await session.ResizeAsync(columns, rows, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask DisposeAsync() + { + foreach (var session in _sessions.Values) + { + session.OutputReceived -= HandleSessionOutput; + await session.DisposeAsync().ConfigureAwait(false); + } + + _sessions.Clear(); + } + + private void HandleSessionOutput(object? sender, TerminalOutputEventArgs args) + { + _ = _sessionRegistry.AppendOutputAsync(args.SessionId, args.Chunk, CancellationToken.None); + OutputReceived?.Invoke(this, args); + } +} diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/TerminalFrameBatcher.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/TerminalFrameBatcher.cs new file mode 100644 index 0000000..c3c3577 --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/TerminalFrameBatcher.cs @@ -0,0 +1,150 @@ +using System.Text; + +namespace TermRemoteCtl.Agent.Terminal; + +public sealed class TerminalFrameBatcher : IAsyncDisposable +{ + private readonly TimeSpan _interval; + private readonly Func _flushAction; + private readonly SemaphoreSlim _flushGate = new(1, 1); + private readonly object _gate = new(); + private readonly StringBuilder _buffer = new(); + private CancellationTokenSource? _scheduledFlushCts; + private bool _disposed; + + public TerminalFrameBatcher(TimeSpan interval, Func flushAction) + { + if (interval <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(interval)); + } + + _interval = interval; + _flushAction = flushAction ?? throw new ArgumentNullException(nameof(flushAction)); + } + + public void Append(string chunk) + { + ArgumentNullException.ThrowIfNull(chunk); + + lock (_gate) + { + ThrowIfDisposed(); + _buffer.Append(chunk); + + if (_scheduledFlushCts is null) + { + ScheduleFlushLocked(); + } + } + } + + public async Task FlushAsync() + { + CancellationTokenSource? scheduledFlushCts; + + lock (_gate) + { + ThrowIfDisposed(); + scheduledFlushCts = _scheduledFlushCts; + _scheduledFlushCts = null; + } + + scheduledFlushCts?.Cancel(); + scheduledFlushCts?.Dispose(); + await FlushBufferedContentAsync().ConfigureAwait(false); + } + + public async ValueTask DisposeAsync() + { + CancellationTokenSource? scheduledFlushCts; + + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + scheduledFlushCts = _scheduledFlushCts; + _scheduledFlushCts = null; + } + + scheduledFlushCts?.Cancel(); + scheduledFlushCts?.Dispose(); + await _flushGate.WaitAsync().ConfigureAwait(false); + _flushGate.Release(); + } + + private void ScheduleFlushLocked() + { + var scheduledFlushCts = new CancellationTokenSource(); + _scheduledFlushCts = scheduledFlushCts; + + _ = Task.Run(async () => + { + try + { + await Task.Delay(_interval, scheduledFlushCts.Token).ConfigureAwait(false); + } + catch (TaskCanceledException) + { + return; + } + + lock (_gate) + { + if (!ReferenceEquals(_scheduledFlushCts, scheduledFlushCts)) + { + return; + } + + _scheduledFlushCts = null; + } + + try + { + await FlushBufferedContentAsync().ConfigureAwait(false); + } + finally + { + scheduledFlushCts.Dispose(); + } + }); + } + + private async Task FlushBufferedContentAsync() + { + string payload; + + lock (_gate) + { + if (_buffer.Length == 0) + { + return; + } + + payload = _buffer.ToString(); + _buffer.Clear(); + } + + await _flushGate.WaitAsync().ConfigureAwait(false); + try + { + await _flushAction(payload).ConfigureAwait(false); + } + finally + { + _flushGate.Release(); + } + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(TerminalFrameBatcher)); + } + } +} diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/appsettings.json b/apps/windows_agent/src/TermRemoteCtl.Agent/appsettings.json index aa50ffe..26dfcb8 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/appsettings.json +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/appsettings.json @@ -1,8 +1,9 @@ { "Agent": { "DataRoot": "C:\\ProgramData\\TermRemoteCtl", - "BindAddress": "0.0.0.0", - "HttpsPort": 9443, + "BindAddress": "localhost", + "HttpsPort": 0, + "HttpPort": 5067, "WebSocketFrameFlushMilliseconds": 33, "RingBufferLineLimit": 4000 } diff --git a/apps/windows_agent/src/TermRemoteCtl.ConPtyHelper/Program.cs b/apps/windows_agent/src/TermRemoteCtl.ConPtyHelper/Program.cs new file mode 100644 index 0000000..379f700 --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.ConPtyHelper/Program.cs @@ -0,0 +1,437 @@ +using System.ComponentModel; +using System.IO.Pipes; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using Microsoft.Win32.SafeHandles; + +namespace TermRemoteCtl.ConPtyHelper; + +internal static class Program +{ + private const uint EXTENDED_STARTUPINFO_PRESENT = 0x00080000; + private const uint WAIT_OBJECT_0 = 0x00000000; + private const int ProcThreadAttributePseudoConsole = 0x00020016; + + private static async Task Main(string[] args) + { + if (!OperatingSystem.IsWindows()) + { + return 1; + } + + var options = HelperOptions.Parse(args); + using var outputPipe = new NamedPipeClientStream(".", options.OutputPipeName, PipeDirection.Out, PipeOptions.Asynchronous); + using var commandPipe = new NamedPipeClientStream(".", options.CommandPipeName, PipeDirection.In, PipeOptions.Asynchronous); + + await outputPipe.ConnectAsync(10_000).ConfigureAwait(false); + await commandPipe.ConnectAsync(10_000).ConfigureAwait(false); + + await using var writer = new StreamWriter(outputPipe, new UTF8Encoding(false), leaveOpen: true) { AutoFlush = true }; + using var reader = new StreamReader(commandPipe, new UTF8Encoding(false), detectEncodingFromByteOrderMarks: false, leaveOpen: true); + + var session = ConPtyRuntime.Start(options.ShellPath, options.ShellArguments, options.Columns, options.Rows); + + try + { + await writer.WriteLineAsync(JsonSerializer.Serialize(new HelperOutputMessage("ready", null))).ConfigureAwait(false); + + var outputTask = Task.Run(() => PumpOutputAsync(session, writer)); + var commandTask = Task.Run(() => PumpCommandsAsync(session, reader)); + + await Task.WhenAny(outputTask, commandTask).ConfigureAwait(false); + } + catch (Exception ex) + { + await writer.WriteLineAsync(JsonSerializer.Serialize(new HelperOutputMessage("error", ex.ToString()))).ConfigureAwait(false); + return 1; + } + finally + { + session.Dispose(); + } + + return 0; + } + + private static async Task PumpOutputAsync(ConPtyRuntime session, StreamWriter writer) + { + var buffer = new byte[4096]; + while (true) + { + var read = session.TryReadOutput(buffer, TimeSpan.FromMilliseconds(50)); + if (read > 0) + { + var chunk = Encoding.UTF8.GetString(buffer, 0, read); + await writer.WriteLineAsync(JsonSerializer.Serialize(new HelperOutputMessage("output", chunk))).ConfigureAwait(false); + continue; + } + + if (session.HasExited) + { + await writer.WriteLineAsync(JsonSerializer.Serialize(new HelperOutputMessage("exit", $"0x{session.ExitCode:X8}"))).ConfigureAwait(false); + return; + } + } + } + + private static async Task PumpCommandsAsync(ConPtyRuntime session, StreamReader reader) + { + while (true) + { + var line = await reader.ReadLineAsync().ConfigureAwait(false); + if (line is null) + { + return; + } + + var message = JsonSerializer.Deserialize(line); + if (message is null) + { + continue; + } + + if (string.Equals(message.Type, "input", StringComparison.OrdinalIgnoreCase) && message.Data is not null) + { + session.WriteInput(message.Data); + continue; + } + + if (string.Equals(message.Type, "resize", StringComparison.OrdinalIgnoreCase) && message.Columns is > 0 && message.Rows is > 0) + { + session.Resize(message.Columns.Value, message.Rows.Value); + continue; + } + + if (string.Equals(message.Type, "shutdown", StringComparison.OrdinalIgnoreCase)) + { + return; + } + } + } + + private sealed record HelperCommandMessage(string Type, string? Data, int? Columns, int? Rows); + private sealed record HelperOutputMessage(string Type, string? Data); + + private sealed class HelperOptions + { + public required string CommandPipeName { get; init; } + public required string OutputPipeName { get; init; } + public required string ShellPath { get; init; } + public required string ShellArguments { get; init; } + public int Columns { get; init; } = 120; + public int Rows { get; init; } = 30; + + public static HelperOptions Parse(string[] args) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < args.Length - 1; i += 2) + { + values[args[i]] = args[i + 1]; + } + + return new HelperOptions + { + CommandPipeName = values["--command-pipe"], + OutputPipeName = values["--output-pipe"], + ShellPath = values["--shell-path"], + ShellArguments = values.TryGetValue("--shell-args", out var shellArgs) ? shellArgs : "", + Columns = values.TryGetValue("--columns", out var columns) ? int.Parse(columns) : 120, + Rows = values.TryGetValue("--rows", out var rows) ? int.Parse(rows) : 30 + }; + } + } + + private sealed class ConPtyRuntime : IDisposable + { + private readonly SafePseudoConsoleHandle _pseudoConsole; + private readonly SafeFileHandle _inputWriteHandle; + private readonly SafeFileHandle _outputReadHandle; + private readonly IntPtr _processHandle; + private readonly IntPtr _threadHandle; + + private ConPtyRuntime( + SafePseudoConsoleHandle pseudoConsole, + SafeFileHandle inputWriteHandle, + SafeFileHandle outputReadHandle, + IntPtr processHandle, + IntPtr threadHandle) + { + _pseudoConsole = pseudoConsole; + _inputWriteHandle = inputWriteHandle; + _outputReadHandle = outputReadHandle; + _processHandle = processHandle; + _threadHandle = threadHandle; + } + + public bool HasExited => WaitForSingleObject(_processHandle, 0) == WAIT_OBJECT_0; + + public uint ExitCode + { + get + { + _ = GetExitCodeProcess(_processHandle, out var exitCode); + return exitCode; + } + } + + public static ConPtyRuntime Start(string shellPath, string shellArguments, int columns, int rows) + { + var inputPipe = CreatePipePair(); + var outputPipe = CreatePipePair(); + var pseudoConsole = CreatePseudoConsole(new Coord((short)columns, (short)rows), inputPipe.ReadHandle, outputPipe.WriteHandle); + + STARTUPINFOEX startupInfo = default; + startupInfo.StartupInfo.cb = (uint)Marshal.SizeOf(); + + nuint attributeListSize = 0; + _ = InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref attributeListSize); + startupInfo.lpAttributeList = Marshal.AllocHGlobal((int)attributeListSize); + + try + { + if (!InitializeProcThreadAttributeList(startupInfo.lpAttributeList, 1, 0, ref attributeListSize)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + if (!UpdateProcThreadAttribute( + startupInfo.lpAttributeList, + 0, + (IntPtr)ProcThreadAttributePseudoConsole, + pseudoConsole.DangerousGetHandle(), + (IntPtr)IntPtr.Size, + IntPtr.Zero, + IntPtr.Zero)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + var commandLine = new StringBuilder($"\"{shellPath}\" {shellArguments}".Trim()); + if (!CreateProcess( + null, + commandLine, + IntPtr.Zero, + IntPtr.Zero, + false, + EXTENDED_STARTUPINFO_PRESENT, + IntPtr.Zero, + null, + ref startupInfo, + out var processInfo)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + inputPipe.ReadHandle.Dispose(); + outputPipe.WriteHandle.Dispose(); + return new ConPtyRuntime(pseudoConsole, inputPipe.WriteHandle, outputPipe.ReadHandle, processInfo.hProcess, processInfo.hThread); + } + finally + { + if (startupInfo.lpAttributeList != IntPtr.Zero) + { + DeleteProcThreadAttributeList(startupInfo.lpAttributeList); + Marshal.FreeHGlobal(startupInfo.lpAttributeList); + } + } + } + + public void WriteInput(string data) + { + var bytes = Encoding.UTF8.GetBytes(data); + if (!WriteFile(_inputWriteHandle, bytes, (uint)bytes.Length, out _, IntPtr.Zero)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } + + public int TryReadOutput(byte[] buffer, TimeSpan timeout) + { + var deadline = DateTime.UtcNow.Add(timeout); + while (DateTime.UtcNow < deadline) + { + if (!PeekNamedPipe(_outputReadHandle, IntPtr.Zero, 0, IntPtr.Zero, out var availableBytes, IntPtr.Zero)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + if (availableBytes > 0) + { + if (!ReadFile(_outputReadHandle, buffer, (uint)buffer.Length, out var bytesRead, IntPtr.Zero)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + return checked((int)bytesRead); + } + + Thread.Sleep(25); + } + + return 0; + } + + public void Resize(int columns, int rows) + { + ResizePseudoConsole(_pseudoConsole.DangerousGetHandle(), new Coord((short)columns, (short)rows)); + } + + public void Dispose() + { + _inputWriteHandle.Dispose(); + _outputReadHandle.Dispose(); + _pseudoConsole.Dispose(); + if (_threadHandle != IntPtr.Zero) + { + CloseHandle(_threadHandle); + } + + if (_processHandle != IntPtr.Zero) + { + CloseHandle(_processHandle); + } + } + } + + private sealed class SafePseudoConsoleHandle : SafeHandleZeroOrMinusOneIsInvalid + { + public SafePseudoConsoleHandle(IntPtr handle) : base(true) + { + SetHandle(handle); + } + + protected override bool ReleaseHandle() + { + ClosePseudoConsole(handle); + return true; + } + } + + private sealed record PipePair(SafeFileHandle ReadHandle, SafeFileHandle WriteHandle); + + private static PipePair CreatePipePair() + { + if (!CreatePipe(out var readPipe, out var writePipe, IntPtr.Zero, 0)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + return new PipePair(new SafeFileHandle(readPipe, true), new SafeFileHandle(writePipe, true)); + } + + private static SafePseudoConsoleHandle CreatePseudoConsole(Coord size, SafeFileHandle inputReadHandle, SafeFileHandle outputWriteHandle) + { + var result = CreatePseudoConsole(size, inputReadHandle.DangerousGetHandle(), outputWriteHandle.DangerousGetHandle(), 0, out var pseudoConsole); + if (result != 0) + { + throw new Win32Exception(result); + } + + return new SafePseudoConsoleHandle(pseudoConsole); + } + + [StructLayout(LayoutKind.Sequential)] + private struct Coord + { + public Coord(short x, short y) + { + X = x; + Y = y; + } + + public short X; + public short Y; + } + + [StructLayout(LayoutKind.Sequential)] + private struct STARTUPINFO + { + public uint cb; + public IntPtr lpReserved; + public IntPtr lpDesktop; + public IntPtr lpTitle; + public uint dwX; + public uint dwY; + public uint dwXSize; + public uint dwYSize; + public uint dwXCountChars; + public uint dwYCountChars; + public uint dwFillAttribute; + public uint dwFlags; + public ushort wShowWindow; + public ushort cbReserved2; + public IntPtr lpReserved2; + public IntPtr hStdInput; + public IntPtr hStdOutput; + public IntPtr hStdError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct STARTUPINFOEX + { + public STARTUPINFO StartupInfo; + public IntPtr lpAttributeList; + } + + [StructLayout(LayoutKind.Sequential)] + private struct PROCESS_INFORMATION + { + public IntPtr hProcess; + public IntPtr hThread; + public uint dwProcessId; + public uint dwThreadId; + } + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool CreateProcess( + string? lpApplicationName, + StringBuilder lpCommandLine, + IntPtr lpProcessAttributes, + IntPtr lpThreadAttributes, + bool bInheritHandles, + uint dwCreationFlags, + IntPtr lpEnvironment, + string? lpCurrentDirectory, + ref STARTUPINFOEX lpStartupInfo, + out PROCESS_INFORMATION lpProcessInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CreatePipe(out IntPtr hReadPipe, out IntPtr hWritePipe, IntPtr lpPipeAttributes, int nSize); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern int CreatePseudoConsole(Coord size, IntPtr hInput, IntPtr hOutput, uint dwFlags, out IntPtr phPC); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern void ResizePseudoConsole(IntPtr hPC, Coord size); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool InitializeProcThreadAttributeList(IntPtr lpAttributeList, int dwAttributeCount, int dwFlags, ref nuint lpSize); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool UpdateProcThreadAttribute(IntPtr lpAttributeList, uint dwFlags, IntPtr attribute, IntPtr lpValue, IntPtr cbSize, IntPtr lpPreviousValue, IntPtr lpReturnSize); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern void DeleteProcThreadAttributeList(IntPtr lpAttributeList); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool ReadFile(SafeFileHandle hFile, byte[] lpBuffer, uint nNumberOfBytesToRead, out uint lpNumberOfBytesRead, IntPtr lpOverlapped); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool WriteFile(SafeFileHandle hFile, byte[] lpBuffer, uint nNumberOfBytesToWrite, out uint lpNumberOfBytesWritten, IntPtr lpOverlapped); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool PeekNamedPipe(SafeFileHandle hNamedPipe, IntPtr lpBuffer, uint nBufferSize, IntPtr lpBytesRead, out uint lpTotalBytesAvail, IntPtr lpBytesLeftThisMessage); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr hObject); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern void ClosePseudoConsole(IntPtr hPC); +} diff --git a/apps/windows_agent/src/TermRemoteCtl.ConPtyHelper/TermRemoteCtl.ConPtyHelper.csproj b/apps/windows_agent/src/TermRemoteCtl.ConPtyHelper/TermRemoteCtl.ConPtyHelper.csproj new file mode 100644 index 0000000..5707368 --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.ConPtyHelper/TermRemoteCtl.ConPtyHelper.csproj @@ -0,0 +1,8 @@ + + + WinExe + net8.0 + enable + enable + + diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Properties/AssemblyInfo.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..37fefc2 --- /dev/null +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.Versioning; + +[assembly: SupportedOSPlatform("windows")] diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalSmokeCheckTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalSmokeCheckTests.cs new file mode 100644 index 0000000..8b7aa8c --- /dev/null +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalSmokeCheckTests.cs @@ -0,0 +1,282 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Json; +using System.Net.WebSockets; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; + +namespace TermRemoteCtl.Agent.IntegrationTests.Realtime; + +public sealed class TerminalSmokeCheckTests +{ + [Fact] + public async Task BuiltAgentExe_Starts_And_Attaches_To_Terminal_WebSocket() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + await using var fixture = new BuiltAgentFixture(); + await fixture.StartAsync(); + + try + { + var session = await fixture.CreateSessionAsync("smoke-shell"); + using var socket = await fixture.ConnectTerminalAsync(session.SessionId); + + var attached = await fixture.ReceiveTextAsync(socket, TimeSpan.FromSeconds(20)); + var attachedPayload = JsonSerializer.Deserialize(attached, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + Assert.NotNull(attachedPayload); + Assert.Equal("attached", attachedPayload!.Type); + Assert.Equal(session.SessionId, attachedPayload.SessionId); + + await fixture.SendTextAsync(socket, JsonSerializer.Serialize(new { type = "input", input = "Write-Output smoke\r" })); + var output = await fixture.ReceiveTextContainingAsync(socket, "smoke", TimeSpan.FromSeconds(20)); + + Assert.Contains("smoke", output, StringComparison.OrdinalIgnoreCase); + } + catch (Exception ex) + { + throw new InvalidOperationException($"{ex.Message}{Environment.NewLine}{fixture.GetDiagnostics()}", ex); + } + } + + private sealed class BuiltAgentFixture : IAsyncDisposable + { + private readonly string _projectRoot; + private readonly string _projectFile; + private readonly string _exePath; + private readonly string _dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Smoke", Guid.NewGuid().ToString("N")); + private readonly int _httpsPort = GetFreePort(); + private Process? _process; + + public BuiltAgentFixture() + { + var sourceRoot = Path.GetFullPath(Path.Combine( + AppContext.BaseDirectory, + "..", + "..", + "..", + "..", + "..", + "src", + "TermRemoteCtl.Agent")); + + _projectRoot = sourceRoot; + _projectFile = Path.Combine(_projectRoot, "TermRemoteCtl.Agent.csproj"); + _exePath = Path.Combine(_projectRoot, "bin", "Debug", "net8.0", "TermRemoteCtl.Agent.exe"); + } + + public async Task StartAsync() + { + await BuildAsync().ConfigureAwait(false); + + var startInfo = new ProcessStartInfo(_exePath) + { + WorkingDirectory = _projectRoot, + UseShellExecute = false + }; + + startInfo.Environment["ASPNETCORE_ENVIRONMENT"] = "Development"; + startInfo.Environment["Agent__DataRoot"] = _dataRoot; + startInfo.Environment["Agent__BindAddress"] = "127.0.0.1"; + startInfo.Environment["Agent__HttpsPort"] = _httpsPort.ToString(); + startInfo.Environment["Agent__WebSocketFrameFlushMilliseconds"] = "33"; + startInfo.Environment["Agent__RingBufferLineLimit"] = "4000"; + + _process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start agent process."); + + await WaitForHealthAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false); + } + + public async Task<(string SessionId, string Name)> CreateSessionAsync(string name) + { + using var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }; + using var client = new HttpClient(handler) + { + BaseAddress = new Uri($"https://127.0.0.1:{_httpsPort}") + }; + + using var response = await client.PostAsJsonAsync("/api/sessions", new { name }).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var payload = await response.Content.ReadFromJsonAsync(new JsonSerializerOptions(JsonSerializerDefaults.Web)).ConfigureAwait(false); + if (payload is null) + { + throw new InvalidOperationException("Missing session payload."); + } + + return (payload.SessionId, payload.Name); + } + + public async Task ConnectTerminalAsync(string sessionId) + { + var socket = new ClientWebSocket(); + socket.Options.RemoteCertificateValidationCallback = (_, _, _, _) => true; + await socket.ConnectAsync(new Uri($"wss://127.0.0.1:{_httpsPort}/ws/terminal?sessionId={sessionId}"), CancellationToken.None).ConfigureAwait(false); + return socket; + } + + public async Task SendTextAsync(WebSocket socket, string payload) + { + var bytes = Encoding.UTF8.GetBytes(payload); + await socket.SendAsync(bytes, WebSocketMessageType.Text, true, CancellationToken.None).ConfigureAwait(false); + } + + public async Task ReceiveTextAsync(WebSocket socket, TimeSpan timeout) + { + return await ReceiveUntilAsync(socket, static _ => true, timeout).ConfigureAwait(false); + } + + public async Task ReceiveTextContainingAsync(WebSocket socket, string expected, TimeSpan timeout) + { + return await ReceiveUntilAsync(socket, value => value.Contains(expected, StringComparison.OrdinalIgnoreCase), timeout).ConfigureAwait(false); + } + + public async ValueTask DisposeAsync() + { + if (_process is not null && !_process.HasExited) + { + try + { + _process.Kill(entireProcessTree: true); + } + catch + { + } + + await _process.WaitForExitAsync().ConfigureAwait(false); + } + + if (Directory.Exists(_dataRoot)) + { + Directory.Delete(_dataRoot, true); + } + } + + private async Task WaitForHealthAsync(TimeSpan timeout) + { + using var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }; + using var client = new HttpClient(handler) + { + BaseAddress = new Uri($"https://127.0.0.1:{_httpsPort}") + }; + + var deadline = DateTimeOffset.UtcNow.Add(timeout); + while (DateTimeOffset.UtcNow < deadline) + { + try + { + using var response = await client.GetAsync("/health").ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.OK) + { + return; + } + } + catch + { + } + + if (_process is not null && _process.HasExited) + { + throw new InvalidOperationException($"Agent exited early with code {_process.ExitCode}."); + } + + await Task.Delay(250).ConfigureAwait(false); + } + + throw new TimeoutException("Agent did not become healthy in time."); + } + + public string GetDiagnostics() + { + return _process is null + ? "Agent process was not started." + : $"Agent process state: HasExited={_process.HasExited}, ExitCode={(_process.HasExited ? _process.ExitCode : 0)}"; + } + + private async Task ReceiveUntilAsync(WebSocket socket, Func predicate, TimeSpan timeout) + { + var buffer = new byte[4096]; + using var aggregate = new MemoryStream(); + var deadline = DateTimeOffset.UtcNow.Add(timeout); + + while (DateTimeOffset.UtcNow < deadline) + { + var remaining = deadline - DateTimeOffset.UtcNow; + var receiveTask = socket.ReceiveAsync(buffer, CancellationToken.None); + var completed = await Task.WhenAny(receiveTask, Task.Delay(remaining)).ConfigureAwait(false); + if (completed != receiveTask) + { + break; + } + + var result = await receiveTask.ConfigureAwait(false); + if (result.MessageType == WebSocketMessageType.Close) + { + break; + } + + aggregate.Write(buffer, 0, result.Count); + if (result.EndOfMessage) + { + var text = Encoding.UTF8.GetString(aggregate.ToArray()); + if (predicate(text)) + { + return text; + } + + aggregate.SetLength(0); + } + } + + throw new TimeoutException("Expected terminal frame was not received."); + } + + private static int GetFreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + private async Task BuildAsync() + { + var startInfo = new ProcessStartInfo("dotnet") + { + WorkingDirectory = _projectRoot, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + startInfo.ArgumentList.Add("build"); + startInfo.ArgumentList.Add(_projectFile); + + using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start agent build."); + var stdout = await process.StandardOutput.ReadToEndAsync().ConfigureAwait(false); + var stderr = await process.StandardError.ReadToEndAsync().ConfigureAwait(false); + await process.WaitForExitAsync().ConfigureAwait(false); + + if (process.ExitCode != 0) + { + throw new InvalidOperationException($"Agent build failed.{Environment.NewLine}STDOUT:{Environment.NewLine}{stdout}{Environment.NewLine}STDERR:{Environment.NewLine}{stderr}"); + } + } + } + + private sealed record SessionResponse(string SessionId, string Name); + + private sealed record TerminalAttachResponse(string SessionId, string Type); +} diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs new file mode 100644 index 0000000..edf95b1 --- /dev/null +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs @@ -0,0 +1,156 @@ +using System.Net; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using TermRemoteCtl.Agent.Sessions; +using TermRemoteCtl.Agent.Terminal; + +namespace TermRemoteCtl.Agent.IntegrationTests.Realtime; + +public sealed class TerminalWebSocketHandlerTests +{ + [Fact] + public async Task Attach_Streams_Output_And_Forwards_Input() + { + await using var fixture = new TerminalApiFixture(); + var registry = fixture.Services.GetRequiredService(); + var session = registry.Create("Shell", DateTimeOffset.UtcNow); + + using WebSocket socket = await fixture.Server.CreateWebSocketClient().ConnectAsync( + new Uri($"ws://localhost/ws/terminal?sessionId={session.SessionId}"), + CancellationToken.None); + + var attachedFrame = await ReceiveTextAsync(socket, CancellationToken.None); + var attachedPayload = JsonSerializer.Deserialize( + attachedFrame, + new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + Assert.NotNull(attachedPayload); + Assert.Equal("attached", attachedPayload!.Type); + Assert.Equal(session.SessionId, attachedPayload.SessionId); + + fixture.TerminalHost.EmitOutput(session.SessionId, "abc"); + fixture.TerminalHost.EmitOutput(session.SessionId, "def"); + + var outputFrame = await ReceiveTextAsync(socket, CancellationToken.None); + Assert.Equal("abcdef", outputFrame); + + var inputMessage = JsonSerializer.Serialize(new { type = "input", input = "dir" }); + await socket.SendAsync(Encoding.UTF8.GetBytes(inputMessage), WebSocketMessageType.Text, true, CancellationToken.None); + + await WaitForConditionAsync(() => fixture.TerminalHost.Inputs.Contains(("input", session.SessionId, "dir")), TimeSpan.FromSeconds(2)); + } + + private static async Task ReceiveTextAsync(WebSocket socket, CancellationToken cancellationToken) + { + var buffer = new byte[4096]; + using var stream = new MemoryStream(); + + while (true) + { + var result = await socket.ReceiveAsync(buffer, cancellationToken); + if (result.MessageType == WebSocketMessageType.Close) + { + throw new InvalidOperationException("Socket closed before a text message was received."); + } + + stream.Write(buffer, 0, result.Count); + if (result.EndOfMessage) + { + return Encoding.UTF8.GetString(stream.ToArray()); + } + } + } + + private static async Task WaitForConditionAsync(Func condition, TimeSpan timeout) + { + var deadline = DateTimeOffset.UtcNow.Add(timeout); + while (DateTimeOffset.UtcNow < deadline) + { + if (condition()) + { + return; + } + + await Task.Delay(25); + } + + throw new TimeoutException("Condition was not met."); + } + + private sealed class TerminalApiFixture : WebApplicationFactory + { + private readonly string _dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Tests", Guid.NewGuid().ToString("N")); + private readonly TestTerminalSessionHost _terminalHost = new(); + + public TestTerminalSessionHost TerminalHost => _terminalHost; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + builder.ConfigureAppConfiguration((_, configBuilder) => + { + configBuilder.AddInMemoryCollection(new Dictionary + { + ["Agent:DataRoot"] = _dataRoot, + ["Agent:BindAddress"] = "127.0.0.1", + ["Agent:HttpsPort"] = "9443", + ["Agent:WebSocketFrameFlushMilliseconds"] = "33", + ["Agent:RingBufferLineLimit"] = "4000" + }); + }); + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(_terminalHost); + }); + } + + public new async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + if (Directory.Exists(_dataRoot)) + { + Directory.Delete(_dataRoot, true); + } + } + } + + private sealed class TestTerminalSessionHost : ISessionHost + { + private readonly List<(string Kind, string SessionId, string Value)> _inputs = new(); + + public event EventHandler? OutputReceived; + + public IReadOnlyList<(string Kind, string SessionId, string Value)> Inputs => _inputs; + + public Task StartAsync(string sessionId, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task WriteInputAsync(string sessionId, string input, CancellationToken cancellationToken) + { + _inputs.Add(("input", sessionId, input)); + return Task.CompletedTask; + } + + public Task ResizeAsync(string sessionId, int columns, int rows, CancellationToken cancellationToken) + { + _inputs.Add(("resize", sessionId, $"{columns}x{rows}")); + return Task.CompletedTask; + } + + public void EmitOutput(string sessionId, string chunk) + { + OutputReceived?.Invoke(this, new TerminalOutputEventArgs(sessionId, chunk)); + } + } + + private sealed record TerminalAttachResponse(string SessionId, string Type); +} diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/SessionFlowTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/SessionFlowTests.cs new file mode 100644 index 0000000..9c5d2d0 --- /dev/null +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/SessionFlowTests.cs @@ -0,0 +1,173 @@ +using System.Net.WebSockets; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using TermRemoteCtl.Agent.Terminal; + +namespace TermRemoteCtl.Agent.IntegrationTests; + +public sealed class SessionFlowTests +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + [Fact] + public async Task Create_Attach_Reconnect_Sequence_Returns_Consistent_Session_Metadata() + { + await using var fixture = new AgentFixture(); + using var client = fixture.CreateClient(); + + var createResponse = await client.PostAsJsonAsync("/api/sessions", new { name = "codex-main" }); + createResponse.EnsureSuccessStatusCode(); + + var created = await createResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(created); + + var listedSessions = await client.GetFromJsonAsync>("/api/sessions", JsonOptions); + Assert.NotNull(listedSessions); + Assert.Contains(listedSessions!, session => session.SessionId == created!.SessionId && session.Name == created.Name && session.Status == created.Status && session.CreatedAtUtc == created.CreatedAtUtc && session.UpdatedAtUtc == created.UpdatedAtUtc); + + var initialSnapshot = created; + + using (var firstSocket = await fixture.ConnectTerminalAsync(created.SessionId)) + { + var attached = await ReceiveTextAsync(firstSocket); + var attachedPayload = JsonSerializer.Deserialize(attached, JsonOptions); + + Assert.NotNull(attachedPayload); + Assert.Equal(created.SessionId, attachedPayload!.SessionId); + Assert.Equal("attached", attachedPayload.Type); + } + + using (var secondSocket = await fixture.ConnectTerminalAsync(created.SessionId)) + { + var reattached = await ReceiveTextAsync(secondSocket); + var reattachedPayload = JsonSerializer.Deserialize(reattached, JsonOptions); + + Assert.NotNull(reattachedPayload); + Assert.Equal(initialSnapshot.SessionId, reattachedPayload!.SessionId); + Assert.Equal("attached", reattachedPayload.Type); + } + + var replayedSessions = await client.GetFromJsonAsync>("/api/sessions", JsonOptions); + Assert.NotNull(replayedSessions); + Assert.Contains(replayedSessions!, session => session.SessionId == initialSnapshot.SessionId && session.Name == initialSnapshot.Name && session.Status == initialSnapshot.Status && session.CreatedAtUtc == initialSnapshot.CreatedAtUtc && session.UpdatedAtUtc == initialSnapshot.UpdatedAtUtc); + Assert.Equal(1, fixture.SessionHost.StartCountFor(initialSnapshot.SessionId)); + } + + private static async Task ReceiveTextAsync(WebSocket socket) + { + var buffer = new byte[4096]; + using var stream = new MemoryStream(); + + while (true) + { + var result = await socket.ReceiveAsync(buffer, CancellationToken.None); + if (result.MessageType == WebSocketMessageType.Close) + { + throw new InvalidOperationException("Socket closed before a text frame was received."); + } + + stream.Write(buffer, 0, result.Count); + if (result.EndOfMessage) + { + return Encoding.UTF8.GetString(stream.ToArray()); + } + } + } + + private sealed class AgentFixture : WebApplicationFactory + { + private readonly string _dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Tests", Guid.NewGuid().ToString("N")); + private readonly RecordingSessionHost _sessionHost = new(); + + public RecordingSessionHost SessionHost => _sessionHost; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + builder.ConfigureAppConfiguration((_, configBuilder) => + { + configBuilder.AddInMemoryCollection(new Dictionary + { + ["Agent:DataRoot"] = _dataRoot, + ["Agent:BindAddress"] = "127.0.0.1", + ["Agent:HttpsPort"] = "9443", + ["Agent:WebSocketFrameFlushMilliseconds"] = "33", + ["Agent:RingBufferLineLimit"] = "4000" + }); + }); + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(_sessionHost); + }); + } + + public async Task ConnectTerminalAsync(string sessionId) + { + return await Server.CreateWebSocketClient() + .ConnectAsync(new Uri($"ws://localhost/ws/terminal?sessionId={sessionId}"), CancellationToken.None); + } + + public new async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + if (Directory.Exists(_dataRoot)) + { + Directory.Delete(_dataRoot, true); + } + } + } + + private sealed class RecordingSessionHost : ISessionHost + { + private readonly Dictionary _startCounts = new(StringComparer.Ordinal); + private readonly HashSet _startedSessions = new(StringComparer.Ordinal); + + public event EventHandler? OutputReceived + { + add { } + remove { } + } + + public Task StartAsync(string sessionId, CancellationToken cancellationToken) + { + if (_startedSessions.Add(sessionId)) + { + _startCounts[sessionId] = 1; + } + + return Task.CompletedTask; + } + + public Task WriteInputAsync(string sessionId, string input, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task ResizeAsync(string sessionId, int columns, int rows, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public int StartCountFor(string sessionId) + { + return _startCounts.TryGetValue(sessionId, out var count) ? count : 0; + } + } + + private sealed record SessionResponse( + string SessionId, + string Name, + string Status, + DateTimeOffset CreatedAtUtc, + DateTimeOffset UpdatedAtUtc); + + private sealed record TerminalAttachResponse(string SessionId, string Type); +} diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/SessionHistoryApiTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/SessionHistoryApiTests.cs new file mode 100644 index 0000000..3d6d891 --- /dev/null +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/SessionHistoryApiTests.cs @@ -0,0 +1,67 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TermRemoteCtl.Agent.History; +using TermRemoteCtl.Agent.Sessions; + +namespace TermRemoteCtl.Agent.IntegrationTests; + +public sealed class SessionHistoryApiTests +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + [Fact] + public async Task GetHistory_Returns_Recent_Lines_And_HasMoreAbove() + { + await using var fixture = new AgentFixture(); + using var client = fixture.CreateClient(); + + var registry = fixture.Services.GetRequiredService(); + var session = registry.Create("codex-main", DateTimeOffset.UtcNow); + await registry.AppendOutputAsync(session.SessionId, "one\ntwo\nthree\n", CancellationToken.None); + + var response = await client.GetFromJsonAsync( + $"/api/sessions/{session.SessionId}/history?lineCount=2", + JsonOptions); + + Assert.NotNull(response); + Assert.Equal(session.SessionId, response!.SessionId); + Assert.Equal(["two", "three"], response.Lines); + Assert.True(response.HasMoreAbove); + } + + private sealed class AgentFixture : WebApplicationFactory + { + private readonly string _dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Tests", Guid.NewGuid().ToString("N")); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + builder.ConfigureAppConfiguration((_, configBuilder) => + { + configBuilder.AddInMemoryCollection(new Dictionary + { + ["Agent:DataRoot"] = _dataRoot, + ["Agent:BindAddress"] = "127.0.0.1", + ["Agent:HttpsPort"] = "9443", + ["Agent:WebSocketFrameFlushMilliseconds"] = "33", + ["Agent:RingBufferLineLimit"] = "4000" + }); + }); + } + + public new async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + if (Directory.Exists(_dataRoot)) + { + Directory.Delete(_dataRoot, true); + } + } + } + + private sealed record SessionHistoryResponse(string SessionId, IReadOnlyList Lines, bool HasMoreAbove); +} diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Configuration/AgentOptionsPipelineTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Configuration/AgentOptionsPipelineTests.cs index 33fc567..f2c633a 100644 --- a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Configuration/AgentOptionsPipelineTests.cs +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Configuration/AgentOptionsPipelineTests.cs @@ -16,6 +16,7 @@ public class AgentOptionsPipelineTests DataRoot = "C:\\ProgramData\\TermRemoteCtl", BindAddress = "127.0.0.1", HttpsPort = 9443, + HttpPort = 5067, WebSocketFrameFlushMilliseconds = 33, RingBufferLineLimit = 4000 }; @@ -27,6 +28,26 @@ public class AgentOptionsPipelineTests Assert.Equal(9443, uri.Port); } + [Fact] + public void BuildListenUri_Uses_Http_Scheme_When_HttpsPort_Is_Disabled() + { + var options = new AgentOptions + { + DataRoot = "C:\\ProgramData\\TermRemoteCtl", + BindAddress = "10.0.2.2", + HttpsPort = 0, + HttpPort = 5067, + WebSocketFrameFlushMilliseconds = 33, + RingBufferLineLimit = 4000 + }; + + var uri = AgentEndpointConfiguration.BuildListenUri(options); + + Assert.Equal(Uri.UriSchemeHttp, uri.Scheme); + Assert.Equal("10.0.2.2", uri.Host); + Assert.Equal(5067, uri.Port); + } + [Fact] public void ResolveOptions_Uses_DataRoot_Fallback_When_Config_Omits_DataRoot() { @@ -34,6 +55,7 @@ public class AgentOptionsPipelineTests { ["Agent:BindAddress"] = "127.0.0.1", ["Agent:HttpsPort"] = "9443", + ["Agent:HttpPort"] = "5067", ["Agent:WebSocketFrameFlushMilliseconds"] = "33", ["Agent:RingBufferLineLimit"] = "4000" }); @@ -49,14 +71,42 @@ public class AgentOptionsPipelineTests "TermRemoteCtl"); Assert.Equal(expectedDataRoot, options.DataRoot); + Assert.Equal(5067, options.HttpPort); + } + + [Fact] + public void ResolveOptions_Allows_Loopback_Scoped_Http_Only_Local_Test_Mode() + { + var configuration = BuildConfiguration(new Dictionary + { + ["Agent:BindAddress"] = "localhost", + ["Agent:HttpsPort"] = "0", + ["Agent:HttpPort"] = "5067", + ["Agent:WebSocketFrameFlushMilliseconds"] = "33", + ["Agent:RingBufferLineLimit"] = "4000" + }); + + var services = new ServiceCollection(); + services.AddAgentOptions(configuration); + + using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + Assert.Equal(0, options.HttpsPort); + Assert.Equal(5067, options.HttpPort); + Assert.Equal("localhost", options.BindAddress); + Assert.Equal(Uri.UriSchemeHttp, AgentEndpointConfiguration.BuildListenUri(options).Scheme); } [Theory] - [InlineData("0", "33", "HttpsPort")] - [InlineData("9443", "0", "WebSocketFrameFlushMilliseconds")] - [InlineData("9443", "33", "RingBufferLineLimit")] + [InlineData("0", "0", "33", "HttpsPort")] + [InlineData("5067", "5067", "33", "must not be the same")] + [InlineData("9443", "-1", "33", "HttpPort")] + [InlineData("9443", "5067", "0", "WebSocketFrameFlushMilliseconds")] + [InlineData("9443", "5067", "33", "RingBufferLineLimit")] public void ResolveOptions_Fails_When_Config_Is_Invalid( string httpsPort, + string httpPort, string flushMilliseconds, string expectedFailure) { @@ -64,6 +114,7 @@ public class AgentOptionsPipelineTests { ["Agent:BindAddress"] = "127.0.0.1", ["Agent:HttpsPort"] = httpsPort, + ["Agent:HttpPort"] = httpPort, ["Agent:WebSocketFrameFlushMilliseconds"] = flushMilliseconds, ["Agent:RingBufferLineLimit"] = expectedFailure == "RingBufferLineLimit" ? "0" : "4000" }); diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Configuration/AgentOptionsValidatorTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Configuration/AgentOptionsValidatorTests.cs index bc6672b..a0df12c 100644 --- a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Configuration/AgentOptionsValidatorTests.cs +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Configuration/AgentOptionsValidatorTests.cs @@ -11,7 +11,8 @@ public class AgentOptionsValidatorTests { DataRoot = "", BindAddress = "0.0.0.0", - HttpsPort = 9443 + HttpsPort = 9443, + HttpPort = 5067 }; var validator = new AgentOptionsValidator(); @@ -32,6 +33,7 @@ public class AgentOptionsValidatorTests DataRoot = "C:\\ProgramData\\TermRemoteCtl", BindAddress = bindAddress, HttpsPort = 9443, + HttpPort = 5067, WebSocketFrameFlushMilliseconds = 33, RingBufferLineLimit = 4000 }; @@ -43,4 +45,89 @@ public class AgentOptionsValidatorTests Assert.True(result.Failed); Assert.Contains(result.Failures, failure => failure.Contains("BindAddress")); } + + [Theory] + [InlineData(-1)] + [InlineData(65536)] + public void Validate_Fails_When_HttpPort_Is_Invalid(int httpPort) + { + var options = new AgentOptions + { + DataRoot = "C:\\ProgramData\\TermRemoteCtl", + BindAddress = "0.0.0.0", + HttpsPort = 9443, + HttpPort = httpPort, + WebSocketFrameFlushMilliseconds = 33, + RingBufferLineLimit = 4000 + }; + + var validator = new AgentOptionsValidator(); + + var result = validator.Validate("Agent", options); + + Assert.True(result.Failed); + Assert.Contains(result.Failures, failure => failure.Contains("HttpPort")); + } + + [Fact] + public void Validate_Succeeds_When_HttpsPort_Is_Disabled_And_HttpPort_Is_Configured() + { + var options = new AgentOptions + { + DataRoot = "C:\\ProgramData\\TermRemoteCtl", + BindAddress = "localhost", + HttpsPort = 0, + HttpPort = 5067, + WebSocketFrameFlushMilliseconds = 33, + RingBufferLineLimit = 4000 + }; + + var validator = new AgentOptionsValidator(); + + var result = validator.Validate("Agent", options); + + Assert.False(result.Failed); + } + + [Fact] + public void Validate_Fails_When_Http_And_Https_Are_Both_Disabled() + { + var options = new AgentOptions + { + DataRoot = "C:\\ProgramData\\TermRemoteCtl", + BindAddress = "0.0.0.0", + HttpsPort = 0, + HttpPort = 0, + WebSocketFrameFlushMilliseconds = 33, + RingBufferLineLimit = 4000 + }; + + var validator = new AgentOptionsValidator(); + + var result = validator.Validate("Agent", options); + + Assert.True(result.Failed); + Assert.Contains(result.Failures, failure => failure.Contains("HttpsPort") || failure.Contains("HttpPort")); + } + + [Fact] + public void Validate_Fails_When_Http_And_Https_Share_The_Same_Port() + { + var options = new AgentOptions + { + DataRoot = "C:\\ProgramData\\TermRemoteCtl", + BindAddress = "localhost", + HttpsPort = 5067, + HttpPort = 5067, + WebSocketFrameFlushMilliseconds = 33, + RingBufferLineLimit = 4000 + }; + + var validator = new AgentOptionsValidator(); + + var result = validator.Validate("Agent", options); + + Assert.True(result.Failed); + Assert.Contains(result.Failures, failure => failure.Contains("must not be the same")); + } } diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Properties/AssemblyInfo.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..37fefc2 --- /dev/null +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.Versioning; + +[assembly: SupportedOSPlatform("windows")] diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs index d26b9c3..b744c58 100644 --- a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs @@ -1,4 +1,7 @@ using TermRemoteCtl.Agent.Sessions; +using Microsoft.Extensions.Options; +using TermRemoteCtl.Agent.Configuration; +using TermRemoteCtl.Agent.History; namespace TermRemoteCtl.Agent.Tests.Sessions; @@ -7,7 +10,8 @@ public class SessionRegistryTests [Fact] public void Create_Returns_Record_And_List_Is_Ordered_By_Name() { - var registry = new SessionRegistry(); + using var harness = SessionRegistryHarness.Create(); + var registry = harness.Registry; var now = new DateTimeOffset(2026, 03, 27, 03, 00, 00, TimeSpan.Zero); var zebra = registry.Create("Zebra", now); @@ -21,7 +25,8 @@ public class SessionRegistryTests [Fact] public void Create_Sets_Record_Metadata() { - var registry = new SessionRegistry(); + using var harness = SessionRegistryHarness.Create(); + var registry = harness.Registry; var now = new DateTimeOffset(2026, 03, 27, 03, 15, 00, TimeSpan.Zero); var record = registry.Create("Shell", now); @@ -32,4 +37,71 @@ public class SessionRegistryTests Assert.Equal(now, record.CreatedAtUtc); Assert.Equal(now, record.UpdatedAtUtc); } + + [Fact] + public async Task AppendOutputAsync_Stores_Recent_Scrollback_Lines() + { + using var harness = SessionRegistryHarness.Create(lineLimit: 3); + var registry = harness.Registry; + var session = registry.Create("Shell", DateTimeOffset.UtcNow); + + await registry.AppendOutputAsync(session.SessionId, "one\ntwo\nthree\n", CancellationToken.None); + + var history = registry.GetHistory(session.SessionId, 2); + + Assert.Equal(["two", "three"], history.Lines); + Assert.True(history.HasMoreAbove); + } + + [Fact] + public async Task AppendOutputAsync_Persists_History_To_Log_File() + { + using var harness = SessionRegistryHarness.Create(); + var registry = harness.Registry; + var session = registry.Create("Shell", DateTimeOffset.UtcNow); + + await registry.AppendOutputAsync(session.SessionId, "dir\r\n", CancellationToken.None); + + var logPath = Path.Combine(harness.DataRoot, "sessions", $"{session.SessionId}.log"); + var content = await File.ReadAllTextAsync(logPath); + + Assert.Equal("dir\r\n", content); + } + + private sealed class SessionRegistryHarness : IDisposable + { + private SessionRegistryHarness(string dataRoot, SessionRegistry registry) + { + DataRoot = dataRoot; + Registry = registry; + } + + public string DataRoot { get; } + + public SessionRegistry Registry { get; } + + public static SessionRegistryHarness Create(int lineLimit = 4000) + { + var dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dataRoot); + + var options = Options.Create(new AgentOptions + { + DataRoot = dataRoot, + RingBufferLineLimit = lineLimit, + }); + var historyStore = new SessionHistoryStore(dataRoot); + var registry = new SessionRegistry(historyStore, options); + + return new SessionRegistryHarness(dataRoot, registry); + } + + public void Dispose() + { + if (Directory.Exists(DataRoot)) + { + Directory.Delete(DataRoot, true); + } + } + } } diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/ConPtyInteropTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/ConPtyInteropTests.cs new file mode 100644 index 0000000..37340ef --- /dev/null +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/ConPtyInteropTests.cs @@ -0,0 +1,20 @@ +using System.Security.Principal; +using TermRemoteCtl.Agent.Terminal; + +namespace TermRemoteCtl.Agent.Tests.Terminal; + +public class ConPtyInteropTests +{ + [Fact] + public void GetCurrentWindowsIdentity_Returns_Identity_On_Windows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + WindowsIdentity identity = ConPtyInterop.GetCurrentWindowsIdentity(); + + Assert.False(string.IsNullOrWhiteSpace(identity.Name)); + } +} diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/ConPtySessionFactoryTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/ConPtySessionFactoryTests.cs new file mode 100644 index 0000000..690e953 --- /dev/null +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/ConPtySessionFactoryTests.cs @@ -0,0 +1,88 @@ +using Microsoft.Extensions.Options; +using TermRemoteCtl.Agent.Configuration; +using TermRemoteCtl.Agent.History; +using TermRemoteCtl.Agent.Sessions; +using TermRemoteCtl.Agent.Terminal; + +namespace TermRemoteCtl.Agent.Tests.Terminal; + +public class ConPtySessionFactoryTests +{ + [Fact] + public async Task FactoryBackedHost_Starts_Shell_On_Windows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var harness = HostHarness.Create(); + await using var host = harness.Host; + await host.StartAsync("smoke", CancellationToken.None); + } + + [Fact] + public async Task FactoryBackedHost_WriteInput_Emits_Output() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var output = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var harness = HostHarness.Create(); + await using var host = harness.Host; + host.OutputReceived += (_, args) => + { + if (args.Chunk.Contains("smoke", StringComparison.OrdinalIgnoreCase)) + { + output.TrySetResult(args.Chunk); + } + }; + + await host.StartAsync("smoke", CancellationToken.None); + await Task.Delay(1000); + await host.WriteInputAsync("smoke", "Write-Output smoke\r\n", CancellationToken.None); + + var completed = await Task.WhenAny(output.Task, Task.Delay(TimeSpan.FromSeconds(20))); + Assert.True(ReferenceEquals(output.Task, completed), "Timed out waiting for shell output."); + Assert.Contains("smoke", await output.Task, StringComparison.OrdinalIgnoreCase); + } + + private sealed class HostHarness : IDisposable + { + private HostHarness(string dataRoot, PowerShellSessionHost host) + { + DataRoot = dataRoot; + Host = host; + } + + public string DataRoot { get; } + + public PowerShellSessionHost Host { get; } + + public static HostHarness Create() + { + var dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dataRoot); + + var options = Options.Create(new AgentOptions + { + DataRoot = dataRoot, + RingBufferLineLimit = 4000, + }); + var registry = new SessionRegistry(new SessionHistoryStore(dataRoot), options); + var host = new PowerShellSessionHost(new ConPtySessionFactory(), registry); + + return new HostHarness(dataRoot, host); + } + + public void Dispose() + { + if (Directory.Exists(DataRoot)) + { + Directory.Delete(DataRoot, true); + } + } + } +} diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/PowerShellSessionHostTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/PowerShellSessionHostTests.cs new file mode 100644 index 0000000..58bda49 --- /dev/null +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/PowerShellSessionHostTests.cs @@ -0,0 +1,126 @@ +using Microsoft.Extensions.Options; +using TermRemoteCtl.Agent.Configuration; +using TermRemoteCtl.Agent.History; +using TermRemoteCtl.Agent.Sessions; +using TermRemoteCtl.Agent.Terminal; + +namespace TermRemoteCtl.Agent.Tests.Terminal; + +public class PowerShellSessionHostTests +{ + [Fact] + public async Task ResizeAsync_Forwards_To_ConPty_Session() + { + var factory = new FakeConPtySessionFactory(); + using var harness = HostHarness.Create(factory); + await using var host = harness.Host; + + await host.StartAsync("alpha", CancellationToken.None); + await host.ResizeAsync("alpha", 120, 40, CancellationToken.None); + + Assert.Equal((120, 40), factory.Session.ResizeCalls.Single()); + } + + [Fact] + public async Task Session_Output_Is_Captured_In_Registry_History() + { + var factory = new FakeConPtySessionFactory(); + using var harness = HostHarness.Create(factory, lineLimit: 3); + var session = harness.Registry.Create("Shell", DateTimeOffset.UtcNow); + await using var host = harness.Host; + + await host.StartAsync(session.SessionId, CancellationToken.None); + factory.Session.EmitOutput(session.SessionId, "one\ntwo\nthree\n"); + + var history = harness.Registry.GetHistory(session.SessionId, 2); + + Assert.Equal(["two", "three"], history.Lines); + Assert.True(history.HasMoreAbove); + } + + private sealed class HostHarness : IDisposable + { + private HostHarness(string dataRoot, SessionRegistry registry, PowerShellSessionHost host) + { + DataRoot = dataRoot; + Registry = registry; + Host = host; + } + + public string DataRoot { get; } + + public SessionRegistry Registry { get; } + + public PowerShellSessionHost Host { get; } + + public static HostHarness Create(FakeConPtySessionFactory factory, int lineLimit = 4000) + { + var dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dataRoot); + + var options = Options.Create(new AgentOptions + { + DataRoot = dataRoot, + RingBufferLineLimit = lineLimit, + }); + var registry = new SessionRegistry(new SessionHistoryStore(dataRoot), options); + var host = new PowerShellSessionHost(factory, registry); + + return new HostHarness(dataRoot, registry, host); + } + + public void Dispose() + { + if (Directory.Exists(DataRoot)) + { + Directory.Delete(DataRoot, true); + } + } + } + + private sealed class FakeConPtySessionFactory : IConPtySessionFactory + { + public FakeConPtySession Session { get; } = new(); + + public IConPtySession Create(string sessionId) + { + Session.SessionId = sessionId; + return Session; + } + } + + private sealed class FakeConPtySession : IConPtySession + { + public string SessionId { get; set; } = string.Empty; + + public event EventHandler? OutputReceived; + + public List<(int Columns, int Rows)> ResizeCalls { get; } = new(); + + public Task StartAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task WriteInputAsync(string input, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task ResizeAsync(int columns, int rows, CancellationToken cancellationToken) + { + ResizeCalls.Add((columns, rows)); + return Task.CompletedTask; + } + + public void EmitOutput(string sessionId, string chunk) + { + OutputReceived?.Invoke(this, new TerminalOutputEventArgs(sessionId, chunk)); + } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + } +} diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/TerminalFrameBatcherTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/TerminalFrameBatcherTests.cs new file mode 100644 index 0000000..d1bb298 --- /dev/null +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/TerminalFrameBatcherTests.cs @@ -0,0 +1,46 @@ +using TermRemoteCtl.Agent.Terminal; + +namespace TermRemoteCtl.Agent.Tests.Terminal; + +public class TerminalFrameBatcherTests +{ + [Fact] + public async Task FlushAsync_Combines_Multiple_Writes_Into_One_Frame() + { + var frames = new List(); + await using var batcher = new TerminalFrameBatcher( + TimeSpan.FromMilliseconds(33), + frame => + { + frames.Add(frame); + return Task.CompletedTask; + }); + + batcher.Append("abc"); + batcher.Append("def"); + + await batcher.FlushAsync(); + + Assert.Equal(["abcdef"], frames); + } + + [Fact] + public async Task Append_Emits_Batched_Frame_After_Interval() + { + var frames = new List(); + await using var batcher = new TerminalFrameBatcher( + TimeSpan.FromMilliseconds(10), + frame => + { + frames.Add(frame); + return Task.CompletedTask; + }); + + batcher.Append("hello"); + batcher.Append(" world"); + + await Task.Delay(50); + + Assert.Equal(["hello world"], frames); + } +} diff --git a/apps/windows_agent/tools/ConPtyProbe/probe-output.txt b/apps/windows_agent/tools/ConPtyProbe/probe-output.txt new file mode 100644 index 0000000..b4187bb --- /dev/null +++ b/apps/windows_agent/tools/ConPtyProbe/probe-output.txt @@ -0,0 +1,4 @@ +bytesRead=88 +[?9001h[?1004h[?25lsmoke-from-probe +]0;C:\WINDOWS\system32\cmd.exe[?25h +exitCode=0x00000000 diff --git a/docs/operations/windows-agent-setup.md b/docs/operations/windows-agent-setup.md new file mode 100644 index 0000000..9a5ea21 --- /dev/null +++ b/docs/operations/windows-agent-setup.md @@ -0,0 +1,11 @@ +# Windows Agent Setup + +## Local Development + +1. Install .NET 8 SDK. +2. Run `dotnet run --project apps/windows_agent/src/TermRemoteCtl.Agent/TermRemoteCtl.Agent.csproj`. +3. Confirm the data root exists under `C:\ProgramData\TermRemoteCtl`. + +## First Runtime Goal + +Run the agent under Windows 11 while an interactive desktop user is signed in. diff --git a/docs/superpowers/plans/2026-03-29-terminal-next-steps.md b/docs/superpowers/plans/2026-03-29-terminal-next-steps.md new file mode 100644 index 0000000..7a41134 --- /dev/null +++ b/docs/superpowers/plans/2026-03-29-terminal-next-steps.md @@ -0,0 +1,97 @@ +# Terminal Next Steps Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Finish the terminal feature from "developer-ready" to "stable for manual product testing" by closing emulator-local transport gaps, shrinking `TerminalPage`, and fixing issues found during smoke testing. + +**Architecture:** Keep the helper-backed ConPTY backend and the current HTTP/HTTPS + WebSocket protocol. Continue moving terminal UI state out of `TerminalPage` into focused controllers/coordinators, while preserving the current live attach, reconnect, resize, and history behavior. + +**Tech Stack:** Flutter, Riverpod, Dio, xterm, ASP.NET Core, WebSocket, helper-backed ConPTY. + +--- + +### Task 1: Stabilize Local Emulator Test Mode + +**Files:** +- Modify: `apps/mobile_app/android/app/src/main/AndroidManifest.xml` +- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/appsettings.json` +- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptions.cs` +- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptionsValidator.cs` +- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptionsServiceCollectionExtensions.cs` +- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentEndpointConfiguration.cs` +- Test: `apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Configuration/AgentOptionsValidatorTests.cs` +- Test: `apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Configuration/AgentOptionsPipelineTests.cs` + +- [ ] Verify the current local-test path still works with `http://10.0.2.2:5067`. +Run: `Invoke-WebRequest -UseBasicParsing 'http://127.0.0.1:5067/health'` +Expected: `{"status":"ok"}` + +- [ ] Run the configuration-focused .NET tests. +Run: `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/TermRemoteCtl.Agent.Tests.csproj --filter "AgentOptionsValidatorTests|AgentOptionsPipelineTests"` +Expected: PASS + +- [ ] Rebuild the Android release APK used for local emulator testing. +Run: `C:\tools\flutter\bin\flutter.bat build apk --release` +Expected: `Built build\app\outputs\flutter-apk\app-release.apk` + +### Task 2: Finish Extracting TerminalPage Orchestration + +**Files:** +- Modify: `apps/mobile_app/lib/features/terminal/terminal_page.dart` +- Modify: `apps/mobile_app/lib/features/terminal/terminal_interaction_controller.dart` +- Modify: `apps/mobile_app/lib/features/terminal/terminal_controller.dart` +- Create or modify as needed: `apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart` +- Test: `apps/mobile_app/test/features/terminal/terminal_interaction_controller_test.dart` +- Test: `apps/mobile_app/test/widget_test.dart` + +- [ ] Move reconnect scheduling, history loading window size, and socket attach lifecycle out of `TerminalPage` and into a focused coordinator/controller. +- [ ] Keep `TerminalPage` primarily responsible for widget composition and command callbacks. +- [ ] Preserve existing behaviors: + - attach waits for ack + - resize sends immediately after attach + - scrollback mode can load older lines + - new live output while browsing history shows a return-to-live affordance + +- [ ] Run Flutter tests after each extraction step. +Run: `C:\tools\flutter\bin\flutter.bat test test/features/terminal/terminal_interaction_controller_test.dart test/widget_test.dart` +Expected: PASS + +### Task 3: Improve User-Facing Error Guidance + +**Files:** +- Modify: `apps/mobile_app/lib/features/sessions/session_list_page.dart` +- Modify: `apps/mobile_app/lib/features/terminal/terminal_page.dart` +- Modify: `apps/mobile_app/lib/core/network/agent_api_client.dart` +- Test: `apps/mobile_app/test/widget_test.dart` + +- [ ] Replace raw Dio/socket exception dumps in the UI with actionable messages for the main local-test cases: + - agent unreachable + - HTTPS certificate rejected + - session history failed but live attach may still recover + +- [ ] Add copy that explicitly tells emulator users to try `http://10.0.2.2:5067` when relevant. + +- [ ] Verify widget coverage still passes. +Run: `C:\tools\flutter\bin\flutter.bat test test/widget_test.dart` +Expected: PASS + +### Task 4: Run Manual Smoke and Fix What Breaks + +**Files:** +- Reference: `docs/testing/manual-smoke-checklist.md` +- Modify: terminal/mobile/agent files only as required by issues discovered in smoke testing + +- [ ] Run the current manual smoke checklist against the emulator-local HTTP setup. +- [ ] Record which steps fail and fix them one by one. +- [ ] After each fix, re-run the smallest relevant automated test set before re-testing manually. + +- [ ] Final verification set for this phase: +Run: `C:\tools\flutter\bin\flutter.bat test test/widget_test.dart test/features/terminal/terminal_interaction_controller_test.dart test/features/terminal/terminal_controller_test.dart test/features/terminal/terminal_socket_session_test.dart test/core/network/agent_api_client_test.dart` +Expected: PASS + +- [ ] Final backend verification set for this phase: +Run: `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/TermRemoteCtl.Agent.Tests.csproj` +Expected: PASS + +Run: `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/TermRemoteCtl.Agent.IntegrationTests.csproj --filter "SessionHistoryApiTests|SessionFlowTests|TerminalWebSocketHandlerTests"` +Expected: PASS diff --git a/docs/testing/manual-smoke-checklist.md b/docs/testing/manual-smoke-checklist.md new file mode 100644 index 0000000..c1bb1ce --- /dev/null +++ b/docs/testing/manual-smoke-checklist.md @@ -0,0 +1,11 @@ +# Manual Smoke Checklist + +1. Start the Windows agent on the primary Windows machine. +2. Pair the iPhone app with a fresh one-time pairing code. +3. Create `codex-main` and `cloud-code` sessions. +4. Start a noisy command in `codex-main`. +5. Background the app for one minute. +6. Reopen the app and confirm the same session is still alive. +7. Scroll upward and confirm older history loads. +8. Trigger one preset command and confirm it appears in the terminal. +9. Terminate one session and confirm only that session exits.