448 lines
13 KiB
Dart
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;
|
|
}
|