Complete terminal session flow stabilization
This commit is contained in:
parent
50a2b0b48b
commit
fb86ea2012
29
AGENTS.md
Normal file
29
AGENTS.md
Normal file
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
<application
|
||||
android:label="term_remote_ctl"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">10.0.2.2</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
29
apps/mobile_app/integration_test/reconnect_flow_test.dart
Normal file
29
apps/mobile_app/integration_test/reconnect_flow_test.dart
Normal file
@ -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);
|
||||
});
|
||||
}
|
||||
16
apps/mobile_app/lib/app/app.dart
Normal file
16
apps/mobile_app/lib/app/app.dart
Normal file
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
70
apps/mobile_app/lib/core/network/agent_api_client.dart
Normal file
70
apps/mobile_app/lib/core/network/agent_api_client.dart
Normal file
@ -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: <String, String>{'lineCount': '$lineCount'},
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> listSessions() async {
|
||||
final response = await _dio.getUri(sessionsUri);
|
||||
return _readJsonList(response.data, 'sessions');
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> createSession(String name) async {
|
||||
final response = await _dio.postUri(
|
||||
sessionsUri,
|
||||
data: <String, String>{'name': name},
|
||||
);
|
||||
return _readJsonMap(response.data, 'session');
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getSessionHistory(
|
||||
String sessionId, {
|
||||
int lineCount = 200,
|
||||
}) async {
|
||||
final response = await _dio.getUri(
|
||||
sessionHistoryUri(sessionId, lineCount: lineCount),
|
||||
);
|
||||
return _readJsonMap(response.data, 'session history');
|
||||
}
|
||||
|
||||
Future<void> redeemPairingCode({
|
||||
required String code,
|
||||
required String deviceName,
|
||||
}) {
|
||||
return _dio.postUri(
|
||||
pairingRedeemUri,
|
||||
data: <String, String>{
|
||||
'code': code,
|
||||
'deviceName': deviceName,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _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<String, dynamic> _readJsonMap(dynamic data, String label) {
|
||||
if (data is! Map) {
|
||||
throw FormatException('Expected $label response to be a JSON object.');
|
||||
}
|
||||
|
||||
return Map<String, dynamic>.from(data);
|
||||
}
|
||||
}
|
||||
@ -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<Uri>((ref) {
|
||||
return Uri.parse('http://10.0.2.2:5067');
|
||||
});
|
||||
|
||||
final agentApiClientProvider = Provider<AgentApiClient>((ref) {
|
||||
return AgentApiClient(ref.watch(agentBaseUriProvider));
|
||||
});
|
||||
|
||||
final sessionRepositoryProvider = Provider<SessionRepository>((ref) {
|
||||
return SessionRepository(ref.watch(agentApiClientProvider));
|
||||
});
|
||||
|
||||
final sessionsProvider = FutureProvider<List<Session>>((ref) {
|
||||
return ref.watch(sessionRepositoryProvider).listSessions();
|
||||
});
|
||||
32
apps/mobile_app/lib/core/network/agent_socket_client.dart
Normal file
32
apps/mobile_app/lib/core/network/agent_socket_client.dart
Normal file
@ -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: <String, String>{'sessionId': sessionId},
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> buildAttachMessage(String sessionId) => <String, dynamic>{
|
||||
'type': 'attach',
|
||||
'sessionId': sessionId,
|
||||
};
|
||||
|
||||
Map<String, dynamic> buildInputMessage(String input) => <String, dynamic>{
|
||||
'type': 'input',
|
||||
'input': input,
|
||||
};
|
||||
|
||||
Map<String, dynamic> buildResizeMessage(int columns, int rows) =>
|
||||
<String, dynamic>{
|
||||
'type': 'resize',
|
||||
'columns': columns,
|
||||
'rows': rows,
|
||||
};
|
||||
}
|
||||
19
apps/mobile_app/lib/features/pairing/pairing_controller.dart
Normal file
19
apps/mobile_app/lib/features/pairing/pairing_controller.dart
Normal file
@ -0,0 +1,19 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/network/agent_api_client.dart';
|
||||
|
||||
class PairingController extends StateNotifier<AsyncValue<void>> {
|
||||
PairingController(this._client) : super(const AsyncData(null));
|
||||
|
||||
final AgentApiClient _client;
|
||||
|
||||
Future<void> 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<void>(error, stackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
apps/mobile_app/lib/features/presets/preset_command.dart
Normal file
11
apps/mobile_app/lib/features/presets/preset_command.dart
Normal file
@ -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;
|
||||
}
|
||||
23
apps/mobile_app/lib/features/presets/preset_panel.dart
Normal file
23
apps/mobile_app/lib/features/presets/preset_panel.dart
Normal file
@ -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<PresetCommand> presets;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final preset in presets)
|
||||
FilledButton(
|
||||
onPressed: () {},
|
||||
child: Text(preset.label),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import 'package:term_remote_ctl/features/presets/preset_command.dart';
|
||||
|
||||
class PresetRepository {
|
||||
const PresetRepository();
|
||||
|
||||
List<PresetCommand> listPresets() {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
17
apps/mobile_app/lib/features/sessions/session.dart
Normal file
17
apps/mobile_app/lib/features/sessions/session.dart
Normal file
@ -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<String, dynamic> json) => Session(
|
||||
sessionId: json['sessionId'] as String,
|
||||
name: json['name'] as String,
|
||||
status: json['status'] as String,
|
||||
);
|
||||
}
|
||||
296
apps/mobile_app/lib/features/sessions/session_list_page.dart
Normal file
296
apps/mobile_app/lib/features/sessions/session_list_page.dart
Normal file
@ -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<SessionListPage> createState() => _SessionListPageState();
|
||||
}
|
||||
|
||||
class _SessionListPageState extends ConsumerState<SessionListPage> {
|
||||
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<void> _refreshSessions() async {
|
||||
await _reloadSessions();
|
||||
}
|
||||
|
||||
Future<void> _createSession() async {
|
||||
final repository = ref.read(sessionRepositoryProvider);
|
||||
var sessionNameInput = '';
|
||||
final sessionName = await showDialog<String>(
|
||||
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<void> _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<void> _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<List<Session>> 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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<List<Session>> listSessions() async {
|
||||
final sessions = await _client.listSessions();
|
||||
return sessions.map(Session.fromJson).toList(growable: false);
|
||||
}
|
||||
|
||||
Future<Session> createSession(String name) async {
|
||||
final session = await _client.createSession(name);
|
||||
return Session.fromJson(session);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
class HistoryWindow {
|
||||
const HistoryWindow({
|
||||
required this.lines,
|
||||
required this.hasMoreAbove,
|
||||
});
|
||||
|
||||
final List<String> lines;
|
||||
final bool hasMoreAbove;
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
import 'history_window.dart';
|
||||
import 'terminal_interaction_controller.dart';
|
||||
|
||||
class TerminalController extends TerminalInteractionController {
|
||||
TerminalController({HistoryWindow historyWindow = const HistoryWindow(lines: <String>[], hasMoreAbove: false)})
|
||||
: super(historyWindow: historyWindow);
|
||||
}
|
||||
@ -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: <String>[],
|
||||
hasMoreAbove: false,
|
||||
),
|
||||
}) : _historyWindow = historyWindow;
|
||||
|
||||
TerminalConnectionState _connectionState = TerminalConnectionState.connecting;
|
||||
bool _isFollowingLiveOutput = true;
|
||||
bool _hasPendingLiveOutput = false;
|
||||
HistoryWindow _historyWindow;
|
||||
final List<String> _liveLines = <String>[];
|
||||
|
||||
TerminalConnectionState get connectionState => _connectionState;
|
||||
|
||||
bool get isFollowingLiveOutput => _isFollowingLiveOutput;
|
||||
|
||||
bool get hasPendingLiveOutput => _hasPendingLiveOutput;
|
||||
|
||||
bool get canSendInput => _connectionState == TerminalConnectionState.connected;
|
||||
|
||||
HistoryWindow get historyWindow => _historyWindow;
|
||||
|
||||
List<String> 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();
|
||||
}
|
||||
}
|
||||
468
apps/mobile_app/lib/features/terminal/terminal_page.dart
Normal file
468
apps/mobile_app/lib/features/terminal/terminal_page.dart
Normal file
@ -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<TerminalPage> createState() => _TerminalPageState();
|
||||
}
|
||||
|
||||
class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||||
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<void> _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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<void> 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<void> 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<void> loadOlderHistory() async {
|
||||
if (_isLoadingOlderHistory || !controller.historyWindow.hasMoreAbove) {
|
||||
return;
|
||||
}
|
||||
|
||||
_isLoadingOlderHistory = true;
|
||||
_historyLineCount += initialHistoryLineCount;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await _loadHistory();
|
||||
} finally {
|
||||
_isLoadingOlderHistory = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reconnectNow() async {
|
||||
_cancelPendingReconnect();
|
||||
await start(isReconnect: true);
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
_isDisposed = true;
|
||||
_cancelPendingReconnect();
|
||||
await _closeActiveSession();
|
||||
}
|
||||
|
||||
void _handleFrame(String chunk) {
|
||||
controller.registerIncomingFrame();
|
||||
controller.applyFrame(chunk);
|
||||
onFrame(chunk);
|
||||
}
|
||||
|
||||
Future<void> _loadHistory() async {
|
||||
try {
|
||||
final payload = await apiClient.getSessionHistory(
|
||||
session.sessionId,
|
||||
lineCount: _historyLineCount,
|
||||
);
|
||||
final history = HistoryWindow(
|
||||
lines: ((payload['lines'] as List?) ?? const <dynamic>[])
|
||||
.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<void> _closeActiveSession() async {
|
||||
final activeSession = _socketSession;
|
||||
_socketSession = null;
|
||||
if (activeSession != null) {
|
||||
await activeSession.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
static CancelReconnect _defaultReconnectScheduler(
|
||||
Duration delay,
|
||||
Future<void> Function() callback,
|
||||
) {
|
||||
final timer = Timer(delay, () {
|
||||
unawaited(callback());
|
||||
});
|
||||
return timer.cancel;
|
||||
}
|
||||
}
|
||||
@ -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<TerminalSocketSessionFactory>((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<dynamic>? _subscription;
|
||||
bool _isAttached = false;
|
||||
|
||||
Future<void> 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<void>();
|
||||
_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<void> 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<dynamic> get stream;
|
||||
void send(String message);
|
||||
Future<void> close();
|
||||
}
|
||||
|
||||
class WebSocketTerminalSocketTransport implements TerminalSocketTransport {
|
||||
WebSocketTerminalSocketTransport(this._channel);
|
||||
|
||||
final WebSocketChannel _channel;
|
||||
|
||||
static WebSocketTerminalSocketTransport connect(Uri uri) {
|
||||
return WebSocketTerminalSocketTransport(WebSocketChannel.connect(uri));
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<dynamic> get stream => _channel.stream;
|
||||
|
||||
@override
|
||||
void send(String message) {
|
||||
_channel.sink.add(message);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
return _channel.sink.close();
|
||||
}
|
||||
}
|
||||
@ -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: <Widget>[
|
||||
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()));
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
144
apps/mobile_app/test/core/network/agent_api_client_test.dart
Normal file
144
apps/mobile_app/test/core/network/agent_api_client_test.dart
Normal file
@ -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, <String, String>{'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,
|
||||
<String, String>{'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<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<Uint8List>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) async {
|
||||
lastOptions = options;
|
||||
return ResponseBody.fromString(
|
||||
responseBody,
|
||||
200,
|
||||
headers: responseBody.isEmpty
|
||||
? const <String, List<String>>{}
|
||||
: <String, List<String>>{
|
||||
Headers.contentTypeHeader: <String>[Headers.jsonContentType],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
@ -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<List<Map<String, dynamic>>> listSessions() async {
|
||||
listCalls += 1;
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
@ -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'),
|
||||
<String, dynamic>{
|
||||
'type': 'attach',
|
||||
'sessionId': 'session-123',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('builds input message for terminal input', () {
|
||||
final client = AgentSocketClient(Uri.parse('https://host:9443'));
|
||||
|
||||
expect(
|
||||
client.buildInputMessage('ls'),
|
||||
<String, dynamic>{
|
||||
'type': 'input',
|
||||
'input': 'ls',
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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<void>());
|
||||
await future;
|
||||
|
||||
expect(client.redeemCalls, 1);
|
||||
expect(client.lastCode, '123456');
|
||||
expect(client.lastDeviceName, 'tablet');
|
||||
expect(controller.state, const AsyncData<void>(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<void>());
|
||||
await future;
|
||||
|
||||
expect(controller.state, isA<AsyncError<void>>());
|
||||
});
|
||||
}
|
||||
|
||||
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<void> redeemPairingCode({
|
||||
required String code,
|
||||
required String deviceName,
|
||||
}) async {
|
||||
redeemCalls += 1;
|
||||
lastCode = code;
|
||||
lastDeviceName = deviceName;
|
||||
|
||||
if (shouldThrow) {
|
||||
throw StateError('boom');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 [
|
||||
<String, dynamic>{
|
||||
'sessionId': 'abc',
|
||||
'name': 'codex-main',
|
||||
'status': 'idle',
|
||||
},
|
||||
<String, dynamic>{
|
||||
'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 <String, dynamic>{
|
||||
'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<Session>());
|
||||
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<Map<String, dynamic>> sessions;
|
||||
final Map<String, dynamic>? createdSession;
|
||||
|
||||
int listCalls = 0;
|
||||
String? lastCreatedName;
|
||||
|
||||
@override
|
||||
Future<List<Map<String, dynamic>>> listSessions() async {
|
||||
listCalls += 1;
|
||||
return sessions;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> createSession(String name) async {
|
||||
lastCreatedName = name;
|
||||
return createdSession ??
|
||||
<String, dynamic>{
|
||||
'sessionId': 'generated',
|
||||
'name': name,
|
||||
'status': 'idle',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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']);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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<void>.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: [
|
||||
<String, dynamic>{
|
||||
'sessionId': 'abc',
|
||||
'lines': <String>['one', 'two'],
|
||||
'hasMoreAbove': true,
|
||||
},
|
||||
<String, dynamic>{
|
||||
'sessionId': 'abc',
|
||||
'lines': <String>['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 = <String>[];
|
||||
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<Map<String, dynamic>>? responses})
|
||||
: _responses = responses ??
|
||||
[
|
||||
<String, dynamic>{
|
||||
'sessionId': 'abc',
|
||||
'lines': <String>['one', 'two'],
|
||||
'hasMoreAbove': true,
|
||||
},
|
||||
],
|
||||
super(Uri.parse('https://host:9443'));
|
||||
|
||||
final List<Map<String, dynamic>> _responses;
|
||||
final requestedLineCounts = <int>[];
|
||||
var _index = 0;
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> 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 = <List<int>>[];
|
||||
Completer<void> _connectCompleter = Completer<void>();
|
||||
void Function(String frame)? _onFrame;
|
||||
void Function()? _onDisconnected;
|
||||
|
||||
@override
|
||||
Future<void> 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<void> Function()? pendingCallback;
|
||||
|
||||
CancelReconnect schedule(Duration _, Future<void> Function() callback) {
|
||||
pendingCallback = callback;
|
||||
return () {
|
||||
pendingCallback = null;
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> runPending() async {
|
||||
final callback = pendingCallback;
|
||||
pendingCallback = null;
|
||||
if (callback != null) {
|
||||
await callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 = <String>[];
|
||||
var completed = false;
|
||||
final connectFuture = session.connect(onFrame: frames.add).then((_) {
|
||||
completed = true;
|
||||
});
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(
|
||||
transport.sentMessages.first,
|
||||
'{"type":"attach","sessionId":"session-123"}',
|
||||
);
|
||||
expect(completed, isFalse);
|
||||
|
||||
transport.emit('{"type":"attached","sessionId":"session-123"}');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
await connectFuture;
|
||||
expect(completed, isTrue);
|
||||
expect(frames, isEmpty);
|
||||
|
||||
transport.emit('abc');
|
||||
await Future<void>.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<void>.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<void>.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<void>.delayed(Duration.zero);
|
||||
transport.emit('{"type":"attached","sessionId":"session-123"}');
|
||||
await connectFuture;
|
||||
|
||||
await transport.close();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(disconnectCount, 1);
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeTerminalSocketTransport implements TerminalSocketTransport {
|
||||
final _incoming = StreamController<dynamic>.broadcast();
|
||||
final sentMessages = <String>[];
|
||||
|
||||
@override
|
||||
Stream<dynamic> get stream => _incoming.stream;
|
||||
|
||||
@override
|
||||
void send(String message) {
|
||||
sentMessages.add(message);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _incoming.close();
|
||||
}
|
||||
|
||||
void emit(dynamic message) {
|
||||
_incoming.add(message);
|
||||
}
|
||||
}
|
||||
@ -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<TextField>(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<TextField>(find.byType(TextField).last);
|
||||
final sendButton = tester.widget<FilledButton>(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<TextField>(find.byType(TextField).last);
|
||||
final reconnectedButton = tester.widget<FilledButton>(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: [
|
||||
<String, dynamic>{
|
||||
'sessionId': 'abc',
|
||||
'lines': <String>['one', 'two'],
|
||||
'hasMoreAbove': true,
|
||||
},
|
||||
<String, dynamic>{
|
||||
'sessionId': 'abc',
|
||||
'lines': <String>['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<Session> _sessions;
|
||||
final bool shouldThrowOnCreate;
|
||||
|
||||
@override
|
||||
Future<List<Session>> listSessions() async {
|
||||
return List<Session>.of(_sessions);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Session> 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<Map<String, dynamic>> getSessionHistory(
|
||||
String sessionId, {
|
||||
int lineCount = 200,
|
||||
}) async {
|
||||
return <String, dynamic>{
|
||||
'sessionId': sessionId,
|
||||
'lines': <String>['one', 'two'],
|
||||
'hasMoreAbove': true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _SequencedHistoryAgentApiClient extends _FakeAgentApiClient {
|
||||
_SequencedHistoryAgentApiClient({required List<Map<String, dynamic>> responses})
|
||||
: _responses = responses;
|
||||
|
||||
final List<Map<String, dynamic>> _responses;
|
||||
final requestedLineCounts = <int>[];
|
||||
var _index = 0;
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> 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<void>.microtask(() {
|
||||
emit('{"type":"attached","sessionId":"abc"}');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final bool autoAttach;
|
||||
final _incoming = StreamController<dynamic>.broadcast();
|
||||
final sentMessages = <String>[];
|
||||
|
||||
@override
|
||||
Stream<dynamic> get stream => _incoming.stream;
|
||||
|
||||
@override
|
||||
void send(String message) {
|
||||
sentMessages.add(message);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
})
|
||||
|
||||
@ -19,9 +19,24 @@ public sealed class AgentOptionsValidator : IValidateOptions<AgentOptions>
|
||||
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)
|
||||
|
||||
@ -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<PairingService>();
|
||||
builder.Services.AddSingleton<TrustedDeviceStore>();
|
||||
builder.Services.AddSingleton<AuditLog>();
|
||||
builder.Services.AddSingleton<SessionRegistry>();
|
||||
builder.Services.AddSingleton<IConPtySessionFactory, ConPtySessionFactory>();
|
||||
builder.Services.AddSingleton<ISessionHost, PowerShellSessionHost>();
|
||||
builder.Services.AddSingleton(serviceProvider =>
|
||||
{
|
||||
var options = serviceProvider.GetRequiredService<IOptions<AgentOptions>>().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();
|
||||
|
||||
|
||||
@ -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")]
|
||||
@ -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<SessionRegistry>();
|
||||
if (!registry.TryGet(sessionId, out _))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
var host = context.RequestServices.GetRequiredService<ISessionHost>();
|
||||
var options = context.RequestServices.GetRequiredService<IOptions<AgentOptions>>().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<TerminalClientMessage>(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);
|
||||
}
|
||||
@ -6,3 +6,8 @@ public sealed record SessionRecord(
|
||||
string Status,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
public sealed record SessionHistorySnapshot(
|
||||
string SessionId,
|
||||
IReadOnlyList<string> Lines,
|
||||
bool HasMoreAbove);
|
||||
|
||||
@ -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<string, SessionRecord> _records = new();
|
||||
private readonly ConcurrentDictionary<string, TerminalRingBuffer> _historyBySession = new();
|
||||
private readonly SessionHistoryStore _historyStore;
|
||||
private readonly int _ringBufferLineLimit;
|
||||
|
||||
public SessionRegistry(SessionHistoryStore historyStore, IOptions<AgentOptions> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TermRemoteCtl.ConPtyHelper\TermRemoteCtl.ConPtyHelper.csproj" ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyConPtyHelper" AfterTargets="Build">
|
||||
<PropertyGroup>
|
||||
<ConPtyHelperSourceDir>..\TermRemoteCtl.ConPtyHelper\bin\$(Configuration)\$(TargetFramework)\</ConPtyHelperSourceDir>
|
||||
<ConPtyHelperTargetDir>$(OutDir)ConPtyHelper\</ConPtyHelperTargetDir>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ConPtyHelperFiles Include="$(ConPtyHelperSourceDir)TermRemoteCtl.ConPtyHelper.*" />
|
||||
</ItemGroup>
|
||||
|
||||
<MakeDir Directories="$(ConPtyHelperTargetDir)" />
|
||||
<Copy SourceFiles="@(ConPtyHelperFiles)" DestinationFolder="$(ConPtyHelperTargetDir)" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
||||
@ -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<StartupInfoEx>();
|
||||
|
||||
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<StartupInfoEx>();
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@ -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<TerminalOutputEventArgs>? 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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<TerminalOutputEventArgs>? 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<HelperOutputMessage>(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<HelperOutputMessage>(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);
|
||||
}
|
||||
@ -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.");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
namespace TermRemoteCtl.Agent.Terminal;
|
||||
|
||||
internal interface IConPtySession : IAsyncDisposable
|
||||
{
|
||||
event EventHandler<TerminalOutputEventArgs>? 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);
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
namespace TermRemoteCtl.Agent.Terminal;
|
||||
|
||||
public interface ISessionHost
|
||||
{
|
||||
event EventHandler<TerminalOutputEventArgs>? 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; }
|
||||
}
|
||||
@ -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<string, IConPtySession> _sessions = new(StringComparer.Ordinal);
|
||||
|
||||
public PowerShellSessionHost(IConPtySessionFactory sessionFactory, SessionRegistry sessionRegistry)
|
||||
{
|
||||
_sessionFactory = sessionFactory;
|
||||
_sessionRegistry = sessionRegistry;
|
||||
}
|
||||
|
||||
public event EventHandler<TerminalOutputEventArgs>? 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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,150 @@
|
||||
using System.Text;
|
||||
|
||||
namespace TermRemoteCtl.Agent.Terminal;
|
||||
|
||||
public sealed class TerminalFrameBatcher : IAsyncDisposable
|
||||
{
|
||||
private readonly TimeSpan _interval;
|
||||
private readonly Func<string, Task> _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<string, Task> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
437
apps/windows_agent/src/TermRemoteCtl.ConPtyHelper/Program.cs
Normal file
437
apps/windows_agent/src/TermRemoteCtl.ConPtyHelper/Program.cs
Normal file
@ -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<int> 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<HelperCommandMessage>(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<string, string>(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<STARTUPINFOEX>();
|
||||
|
||||
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);
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@ -0,0 +1,3 @@
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
[assembly: SupportedOSPlatform("windows")]
|
||||
@ -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<TerminalAttachResponse>(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<SessionResponse>(new JsonSerializerOptions(JsonSerializerDefaults.Web)).ConfigureAwait(false);
|
||||
if (payload is null)
|
||||
{
|
||||
throw new InvalidOperationException("Missing session payload.");
|
||||
}
|
||||
|
||||
return (payload.SessionId, payload.Name);
|
||||
}
|
||||
|
||||
public async Task<ClientWebSocket> 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<string> ReceiveTextAsync(WebSocket socket, TimeSpan timeout)
|
||||
{
|
||||
return await ReceiveUntilAsync(socket, static _ => true, timeout).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<string> 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<string> ReceiveUntilAsync(WebSocket socket, Func<string, bool> 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);
|
||||
}
|
||||
@ -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<SessionRegistry>();
|
||||
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<TerminalAttachResponse>(
|
||||
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<string> 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<bool> 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<Program>
|
||||
{
|
||||
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<string, string?>
|
||||
{
|
||||
["Agent:DataRoot"] = _dataRoot,
|
||||
["Agent:BindAddress"] = "127.0.0.1",
|
||||
["Agent:HttpsPort"] = "9443",
|
||||
["Agent:WebSocketFrameFlushMilliseconds"] = "33",
|
||||
["Agent:RingBufferLineLimit"] = "4000"
|
||||
});
|
||||
});
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<ISessionHost>();
|
||||
services.AddSingleton<ISessionHost>(_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<TerminalOutputEventArgs>? 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);
|
||||
}
|
||||
@ -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<SessionResponse>(JsonOptions);
|
||||
Assert.NotNull(created);
|
||||
|
||||
var listedSessions = await client.GetFromJsonAsync<List<SessionResponse>>("/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<TerminalAttachResponse>(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<TerminalAttachResponse>(reattached, JsonOptions);
|
||||
|
||||
Assert.NotNull(reattachedPayload);
|
||||
Assert.Equal(initialSnapshot.SessionId, reattachedPayload!.SessionId);
|
||||
Assert.Equal("attached", reattachedPayload.Type);
|
||||
}
|
||||
|
||||
var replayedSessions = await client.GetFromJsonAsync<List<SessionResponse>>("/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<string> 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<Program>
|
||||
{
|
||||
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<string, string?>
|
||||
{
|
||||
["Agent:DataRoot"] = _dataRoot,
|
||||
["Agent:BindAddress"] = "127.0.0.1",
|
||||
["Agent:HttpsPort"] = "9443",
|
||||
["Agent:WebSocketFrameFlushMilliseconds"] = "33",
|
||||
["Agent:RingBufferLineLimit"] = "4000"
|
||||
});
|
||||
});
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<ISessionHost>();
|
||||
services.AddSingleton<ISessionHost>(_sessionHost);
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<WebSocket> 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<string, int> _startCounts = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> _startedSessions = new(StringComparer.Ordinal);
|
||||
|
||||
public event EventHandler<TerminalOutputEventArgs>? 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);
|
||||
}
|
||||
@ -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<SessionRegistry>();
|
||||
var session = registry.Create("codex-main", DateTimeOffset.UtcNow);
|
||||
await registry.AppendOutputAsync(session.SessionId, "one\ntwo\nthree\n", CancellationToken.None);
|
||||
|
||||
var response = await client.GetFromJsonAsync<SessionHistoryResponse>(
|
||||
$"/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<Program>
|
||||
{
|
||||
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<string, string?>
|
||||
{
|
||||
["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<string> Lines, bool HasMoreAbove);
|
||||
}
|
||||
@ -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<string, string?>
|
||||
{
|
||||
["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<IOptions<AgentOptions>>().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"
|
||||
});
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
[assembly: SupportedOSPlatform("windows")]
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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<string>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<TerminalOutputEventArgs>? 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<string>();
|
||||
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<string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
4
apps/windows_agent/tools/ConPtyProbe/probe-output.txt
Normal file
4
apps/windows_agent/tools/ConPtyProbe/probe-output.txt
Normal file
@ -0,0 +1,4 @@
|
||||
bytesRead=88
|
||||
[?9001h[?1004h[?25l[2J[m[Hsmoke-from-probe
|
||||
]0;C:\WINDOWS\system32\cmd.exe[?25h
|
||||
exitCode=0x00000000
|
||||
11
docs/operations/windows-agent-setup.md
Normal file
11
docs/operations/windows-agent-setup.md
Normal file
@ -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.
|
||||
97
docs/superpowers/plans/2026-03-29-terminal-next-steps.md
Normal file
97
docs/superpowers/plans/2026-03-29-terminal-next-steps.md
Normal file
@ -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
|
||||
11
docs/testing/manual-smoke-checklist.md
Normal file
11
docs/testing/manual-smoke-checklist.md
Normal file
@ -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.
|
||||
Loading…
Reference in New Issue
Block a user