Files

448 lines
13 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
enum ControlServiceStatus { checking, available, unavailable }
class ControlServiceResult {
const ControlServiceResult({required this.status, required this.message});
final ControlServiceStatus status;
final String message;
}
class ApiResult<T> {
const ApiResult.success(this.data, this.message) : ok = true;
const ApiResult.failure(this.message) : ok = false, data = null;
final bool ok;
final String message;
final T? data;
}
class SavedHost {
const SavedHost({
required this.id,
required this.name,
required this.address,
required this.user,
required this.certificateSha256,
});
final String id;
final String name;
final String address;
final String user;
final String certificateSha256;
factory SavedHost.fromJson(Map<String, dynamic> json) => SavedHost(
id: json['id'] as String? ?? '',
name: json['name'] as String? ?? '',
address: json['address'] as String? ?? '',
user: json['user'] as String? ?? '',
certificateSha256: json['certificate_sha256'] as String? ?? '',
);
Map<String, Object> toJson() => {
'id': id,
'name': name,
'address': address,
'user': user,
'certificate_sha256': certificateSha256,
};
}
class AgentProbe {
const AgentProbe({
required this.latencyMs,
required this.protocolMajor,
required this.protocolMinor,
required this.desktop,
required this.terminal,
required this.files,
});
final int latencyMs;
final int protocolMajor;
final int protocolMinor;
final bool desktop;
final bool terminal;
final bool files;
factory AgentProbe.fromJson(Map<String, dynamic> json) => AgentProbe(
latencyMs: (json['latency_ms'] as num?)?.toInt() ?? 0,
protocolMajor: (json['protocol_major'] as num?)?.toInt() ?? 0,
protocolMinor: (json['protocol_minor'] as num?)?.toInt() ?? 0,
desktop: json['desktop'] == true,
terminal: json['terminal'] == true,
files: json['files'] == true,
);
}
class DesktopLaunchResult {
const DesktopLaunchResult({
required this.ok,
required this.message,
this.sessionId,
});
final bool ok;
final String message;
final String? sessionId;
}
class DesktopSessionDiagnostics {
const DesktopSessionDiagnostics({
required this.sessionId,
required this.state,
required this.frameCount,
required this.width,
required this.height,
this.framesPerSecond,
this.networkLatencyMs,
this.decodeLatencyMs,
this.presentationLatencyMs,
this.errorCode,
});
final String sessionId;
final String state;
final int frameCount;
final int width;
final int height;
final double? framesPerSecond;
final double? networkLatencyMs;
final double? decodeLatencyMs;
final double? presentationLatencyMs;
final String? errorCode;
factory DesktopSessionDiagnostics.fromJson(Map<String, dynamic> json) =>
DesktopSessionDiagnostics(
sessionId: json['session_id'] as String? ?? '',
state: json['state'] as String? ?? 'unknown',
frameCount: (json['frame_count'] as num?)?.toInt() ?? 0,
width: (json['desktop_width'] as num?)?.toInt() ?? 0,
height: (json['desktop_height'] as num?)?.toInt() ?? 0,
framesPerSecond: (json['frames_per_second'] as num?)?.toDouble(),
networkLatencyMs: (json['network_latency_ms'] as num?)?.toDouble(),
decodeLatencyMs: (json['decode_latency_ms'] as num?)?.toDouble(),
presentationLatencyMs: (json['presentation_latency_ms'] as num?)
?.toDouble(),
errorCode: json['error_code'] as String?,
);
}
class LinuxDesktopLaunchRequest {
const LinuxDesktopLaunchRequest({
required this.address,
required this.user,
required this.certificateSha256,
required this.width,
required this.height,
required this.framesPerSecond,
required this.fullscreen,
required this.followWindow,
required this.captureInput,
required this.clipboardRead,
required this.clipboardWrite,
});
final String address;
final String user;
final String certificateSha256;
final int width;
final int height;
final int framesPerSecond;
final bool fullscreen;
final bool followWindow;
final bool captureInput;
final bool clipboardRead;
final bool clipboardWrite;
Map<String, Object> toJson() => {
'address': address,
'user': user,
'certificate_sha256': certificateSha256,
'width': width,
'height': height,
'frames_per_second': framesPerSecond,
'fullscreen': fullscreen,
'follow_window': followWindow,
'capture_input': captureInput,
'clipboard_read': clipboardRead,
'clipboard_write': clipboardWrite,
};
}
class ControlService {
ControlService({Uri? baseUri})
: _baseUri = baseUri ?? Uri.parse('http://127.0.0.1:4173');
final Uri _baseUri;
bool _startAttempted = false;
Future<ControlServiceResult> ensureRunning() async {
final healthResult = await health();
if (healthResult.status == ControlServiceStatus.available) {
return healthResult;
}
if (!_startAttempted) {
_startAttempted = true;
try {
await _startSiblingService();
} on ProcessException {
// The health result below remains the user-facing source of truth.
}
}
for (var attempt = 0; attempt < 10; attempt++) {
await Future<void>.delayed(const Duration(milliseconds: 300));
final result = await health();
if (result.status == ControlServiceStatus.available) return result;
}
return const ControlServiceResult(
status: ControlServiceStatus.unavailable,
message: 'Local control service is unavailable',
);
}
Future<ControlServiceResult> health() async {
try {
final response = await _request('GET', '/api/v1/health');
if (response.statusCode == HttpStatus.ok) {
return const ControlServiceResult(
status: ControlServiceStatus.available,
message: 'Control service online',
);
}
return const ControlServiceResult(
status: ControlServiceStatus.unavailable,
message: 'Control service rejected the request',
);
} on Object catch (error) {
return ControlServiceResult(
status: ControlServiceStatus.unavailable,
message: _networkMessage(error),
);
}
}
Future<ApiResult<List<SavedHost>>> loadHosts() async {
try {
final response = await _request('GET', '/api/v1/hosts');
if (response.statusCode != HttpStatus.ok) {
return ApiResult.failure(
_errorMessage(response, 'Unable to load hosts'),
);
}
final value = jsonDecode(response.body);
if (value is! List) {
return const ApiResult.failure('Host database has an invalid format');
}
final hosts = value
.whereType<Map<String, dynamic>>()
.map(SavedHost.fromJson)
.where((host) => host.id.isNotEmpty)
.toList();
return ApiResult.success(hosts, '${hosts.length} hosts loaded');
} on Object catch (error) {
return ApiResult.failure(_networkMessage(error));
}
}
Future<ApiResult<void>> saveHosts(List<SavedHost> hosts) async {
try {
final response = await _request(
'PUT',
'/api/v1/hosts',
body: jsonEncode(hosts.map((host) => host.toJson()).toList()),
);
if (response.statusCode == HttpStatus.ok) {
return const ApiResult.success(null, 'Host list saved');
}
return ApiResult.failure(_errorMessage(response, 'Unable to save hosts'));
} on Object catch (error) {
return ApiResult.failure(_networkMessage(error));
}
}
Future<ApiResult<AgentProbe>> probeLinuxAgent(
String address,
String certificateSha256,
) async {
try {
final response = await _request(
'POST',
'/api/v1/linux/probe',
body: jsonEncode({
'address': address,
'certificate_sha256': certificateSha256,
}),
);
final data = _decodeJson(response.body);
if (response.statusCode == HttpStatus.ok && data['ok'] == true) {
final probe = AgentProbe.fromJson(data);
return ApiResult.success(
probe,
'Agent responded in ${probe.latencyMs} ms',
);
}
return ApiResult.failure(_errorMessage(response, 'Agent probe failed'));
} on Object catch (error) {
return ApiResult.failure(_networkMessage(error));
}
}
Future<DesktopLaunchResult> launchLinuxDesktop(
LinuxDesktopLaunchRequest request,
) async {
try {
final response = await _request(
'POST',
'/api/v1/linux/desktop/launch',
body: jsonEncode(request.toJson()),
);
final data = _decodeJson(response.body);
if (response.statusCode == HttpStatus.accepted &&
data['session_id'] is String) {
return DesktopLaunchResult(
ok: true,
message: 'Native desktop session started',
sessionId: data['session_id'] as String,
);
}
return DesktopLaunchResult(
ok: false,
message: _errorMessage(response, 'Unable to start desktop session'),
);
} on Object catch (error) {
return DesktopLaunchResult(ok: false, message: _networkMessage(error));
}
}
Future<ApiResult<void>> launchLinuxTerminal(
String address,
String user,
String certificateSha256,
) async {
try {
final response = await _request(
'POST',
'/api/v1/linux/terminal/launch',
body: jsonEncode({
'address': address,
'user': user,
'certificate_sha256': certificateSha256,
'pair': false,
}),
);
if (response.statusCode == HttpStatus.accepted) {
return const ApiResult.success(null, 'Native terminal started');
}
return ApiResult.failure(
_errorMessage(response, 'Unable to start terminal'),
);
} on Object catch (error) {
return ApiResult.failure(_networkMessage(error));
}
}
Future<ApiResult<DesktopSessionDiagnostics>> sessionDiagnostics(
String sessionId,
) async {
try {
final response = await _request(
'GET',
'/api/v1/linux/desktop/session/$sessionId',
);
if (response.statusCode == HttpStatus.ok) {
return ApiResult.success(
DesktopSessionDiagnostics.fromJson(_decodeJson(response.body)),
'Session diagnostics updated',
);
}
return ApiResult.failure(
_errorMessage(response, 'Session diagnostics unavailable'),
);
} on Object catch (error) {
return ApiResult.failure(_networkMessage(error));
}
}
Future<_ControlResponse> _request(
String method,
String path, {
String? body,
}) async {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 2);
try {
final request = await client.openUrl(
method,
_baseUri.replace(path: path),
);
request.headers.set(
HttpHeaders.hostHeader,
'${_baseUri.host}:${_baseUri.port}',
);
request.headers.contentType = ContentType.json;
if (body != null) request.write(body);
final response = await request.close().timeout(
const Duration(seconds: 8),
);
final responseBody = await response.transform(utf8.decoder).join();
return _ControlResponse(response.statusCode, responseBody);
} finally {
client.close(force: true);
}
}
Future<void> _startSiblingService() async {
if (!Platform.isWindows) return;
final root = File(Platform.resolvedExecutable).parent;
final service = File(
'${root.path}${Platform.pathSeparator}remotedesk-control-service.exe',
);
final webRoot = Directory('${root.path}${Platform.pathSeparator}web');
if (!await service.exists() ||
!await File(
'${webRoot.path}${Platform.pathSeparator}index.html',
).exists()) {
return;
}
await Process.start(
service.path,
['--web-root', webRoot.path],
mode: ProcessStartMode.detached,
runInShell: false,
);
}
Map<String, dynamic> _decodeJson(String body) {
if (body.isEmpty) return const {};
final value = jsonDecode(body);
return value is Map<String, dynamic> ? value : const {};
}
String _errorMessage(_ControlResponse response, String fallback) {
try {
return _decodeJson(response.body)['error'] as String? ?? fallback;
} on FormatException {
return fallback;
}
}
String _networkMessage(Object error) {
if (error is TimeoutException) return 'Control service did not respond';
if (error is FormatException) {
return 'Control service returned invalid data';
}
return 'Control service is unavailable';
}
}
class _ControlResponse {
const _ControlResponse(this.statusCode, this.body);
final int statusCode;
final String body;
}