import 'dart:async'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:uuid/uuid.dart'; import 'agent_client.dart'; import 'edge_client.dart'; import 'l10n.dart'; import 'models.dart'; import 'storage.dart'; import 'windows_agent_client.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); runApp(RemoteDeskApp()); } class RemoteDeskApp extends StatefulWidget { RemoteDeskApp({super.key, AppStorage? storage}) : storage = storage ?? AppStorage(); final AppStorage storage; @override State createState() => _RemoteDeskAppState(); } class _RemoteDeskAppState extends State { List _hosts = const []; AppSettings _settings = const AppSettings(); bool _loaded = false; @override void initState() { super.initState(); unawaited(_load()); } Future _load() async { final values = await Future.wait([ widget.storage.loadHosts(), widget.storage.loadSettings(), ]); if (!mounted) return; setState(() { _hosts = values[0] as List; _settings = values[1] as AppSettings; _loaded = true; }); } Future _saveHost(RemoteHost host) async { final next = [..._hosts]; final index = next.indexWhere((item) => item.id == host.id); if (index < 0) { next.add(host); } else { next[index] = host; } setState(() => _hosts = next); await widget.storage.saveHosts(next); } Future _deleteHost(RemoteHost host) async { final next = _hosts.where((item) => item.id != host.id).toList(); setState(() => _hosts = next); await widget.storage.saveHosts(next); } Future _saveSettings(AppSettings settings) async { setState(() => _settings = settings); await widget.storage.saveSettings(settings); } @override Widget build(BuildContext context) { final locale = _settings.languageCode == null ? null : Locale(_settings.languageCode!); return MaterialApp( debugShowCheckedModeBanner: false, onGenerateTitle: (context) => AppLocalizations.of(context).text('appName'), locale: locale, supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: const [ AppLocalizations.delegate, GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], theme: ThemeData( useMaterial3: true, colorScheme: ColorScheme.fromSeed( seedColor: const Color(0xff006c5b), brightness: Brightness.light, ), scaffoldBackgroundColor: const Color(0xfff6f8f7), cardTheme: const CardThemeData( margin: EdgeInsets.zero, elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(8)), side: BorderSide(color: Color(0xffd9dfdc)), ), ), inputDecorationTheme: const InputDecorationTheme( border: OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(6)), ), ), ), home: !_loaded ? const Scaffold(body: Center(child: CircularProgressIndicator())) : HostListScreen( hosts: _hosts, settings: _settings, onSaveHost: _saveHost, onDeleteHost: _deleteHost, onSaveSettings: _saveSettings, ), ); } } class HostListScreen extends StatelessWidget { const HostListScreen({ super.key, required this.hosts, required this.settings, required this.onSaveHost, required this.onDeleteHost, required this.onSaveSettings, }); final List hosts; final AppSettings settings; final Future Function(RemoteHost) onSaveHost; final Future Function(RemoteHost) onDeleteHost; final Future Function(AppSettings) onSaveSettings; Future _editHost(BuildContext context, [RemoteHost? host]) async { await Navigator.of(context).push( MaterialPageRoute( builder: (_) => HostEditorScreen(host: host, onSave: onSaveHost), ), ); } Future _openSettings(BuildContext context) async { await Navigator.of(context).push( MaterialPageRoute( builder: (_) => SettingsScreen(settings: settings, onSave: onSaveSettings), ), ); } Future _delete(BuildContext context, RemoteHost host) async { final l10n = AppLocalizations.of(context); final confirmed = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(l10n.text('deleteDevice')), content: Text(l10n.text('deleteDeviceBody')), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext, false), child: Text(l10n.text('cancel')), ), FilledButton( onPressed: () => Navigator.pop(dialogContext, true), child: Text(l10n.text('delete')), ), ], ), ); if (confirmed == true) await onDeleteHost(host); } Future _connect( BuildContext context, RemoteHost host, { required bool pair, }) async { String? pairingCode; if (pair && host.platform == HostPlatform.linux) { pairingCode = await _requestPairingCode(context); if (pairingCode == null || !context.mounted) return; } final totpCode = await _requestTotpCode(context); if (totpCode == null || !context.mounted) return; await Navigator.of(context).push( MaterialPageRoute( builder: (_) => DesktopScreen( host: host, settings: settings, pairingCode: pairingCode, initialTotpCode: totpCode, ), ), ); } Future _requestPairingCode(BuildContext context) { final controller = TextEditingController(); final formKey = GlobalKey(); final l10n = AppLocalizations.of(context); return showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(l10n.text('pairingCode')), content: Form( key: formKey, child: TextFormField( controller: controller, autofocus: true, keyboardType: TextInputType.number, maxLength: 8, inputFormatters: [FilteringTextInputFormatter.digitsOnly], decoration: InputDecoration( labelText: l10n.text('pairingCode'), helperText: l10n.text('pairingCodeHint'), ), validator: (value) => RegExp(r'^\d{8}$').hasMatch(value ?? '') ? null : l10n.text('invalidPairingCode'), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext), child: Text(l10n.text('cancel')), ), FilledButton( onPressed: () { if (formKey.currentState!.validate()) { Navigator.pop(dialogContext, controller.text); } }, child: Text(l10n.text('continueAction')), ), ], ), ).whenComplete(controller.dispose); } Future _requestTotpCode(BuildContext context) { final controller = TextEditingController(); final formKey = GlobalKey(); final l10n = AppLocalizations.of(context); return showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(l10n.text('totpCode')), content: Form( key: formKey, child: TextFormField( controller: controller, autofocus: true, keyboardType: TextInputType.number, maxLength: 6, obscureText: true, inputFormatters: [FilteringTextInputFormatter.digitsOnly], decoration: InputDecoration( labelText: l10n.text('totpCode'), helperText: l10n.text('totpCodeHint'), ), validator: (value) => RegExp(r'^\d{6}$').hasMatch(value ?? '') ? null : l10n.text('invalidTotpCode'), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext), child: Text(l10n.text('cancel')), ), FilledButton( onPressed: () { if (formKey.currentState!.validate()) { Navigator.pop(dialogContext, controller.text); } }, child: Text(l10n.text('continueAction')), ), ], ), ).whenComplete(controller.dispose); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return Scaffold( appBar: AppBar( title: Text(l10n.text('devices')), actions: [ IconButton( onPressed: () => _openSettings(context), tooltip: l10n.text('settings'), icon: const Icon(Icons.settings_outlined), ), ], ), body: hosts.isEmpty ? Center( child: Padding( padding: const EdgeInsets.all(32), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 360), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.desktop_windows_outlined, size: 52, color: Theme.of(context).colorScheme.secondary, ), const SizedBox(height: 16), Text( l10n.text('noDevices'), style: Theme.of(context).textTheme.titleLarge, textAlign: TextAlign.center, ), const SizedBox(height: 8), Text( l10n.text('noDevicesHint'), textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium, ), ], ), ), ), ) : ListView.separated( padding: const EdgeInsets.fromLTRB(16, 12, 16, 96), itemCount: hosts.length, separatorBuilder: (_, _) => const SizedBox(height: 10), itemBuilder: (context, index) { final host = hosts[index]; return Card( child: Padding( padding: const EdgeInsets.fromLTRB(14, 14, 8, 10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ CircleAvatar( backgroundColor: Theme.of( context, ).colorScheme.secondaryContainer, child: const Icon(Icons.computer), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( host.name, style: Theme.of( context, ).textTheme.titleMedium, ), const SizedBox(height: 2), Text( host.platform == HostPlatform.linux ? '${host.user} @ ${host.address}' : host.address, maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), Text( l10n.text( host.platform == HostPlatform.windows ? 'windowsPlainConnection' : switch (host.connectionMode) { ConnectionMode.automatic => 'automaticConnection', ConnectionMode.direct => 'directConnection', ConnectionMode.relay => 'relayConnection', }, ), style: Theme.of( context, ).textTheme.bodySmall, ), ], ), ), PopupMenuButton( onSelected: (value) { if (value == 'edit') _editHost(context, host); if (value == 'delete') _delete(context, host); }, itemBuilder: (_) => [ PopupMenuItem( value: 'edit', child: ListTile( leading: const Icon(Icons.edit_outlined), title: Text(l10n.text('edit')), contentPadding: EdgeInsets.zero, ), ), PopupMenuItem( value: 'delete', child: ListTile( leading: const Icon(Icons.delete_outline), title: Text(l10n.text('delete')), contentPadding: EdgeInsets.zero, ), ), ], ), ], ), const SizedBox(height: 10), Wrap( spacing: 8, runSpacing: 8, children: [ FilledButton.icon( onPressed: () => _connect(context, host, pair: false), icon: const Icon(Icons.play_arrow), label: Text(l10n.text('connect')), ), if (host.platform == HostPlatform.linux) OutlinedButton.icon( onPressed: () => _connect(context, host, pair: true), icon: const Icon(Icons.link), label: Text(l10n.text('pair')), ), ], ), ], ), ), ); }, ), floatingActionButton: FloatingActionButton( onPressed: () => _editHost(context), tooltip: l10n.text('addDevice'), child: const Icon(Icons.add), ), ); } } class HostEditorScreen extends StatefulWidget { const HostEditorScreen({super.key, this.host, required this.onSave}); final RemoteHost? host; final Future Function(RemoteHost) onSave; @override State createState() => _HostEditorScreenState(); } class _HostEditorScreenState extends State { final _formKey = GlobalKey(); late final TextEditingController _name; late final TextEditingController _address; late final TextEditingController _user; late final TextEditingController _certificate; late final TextEditingController _edgeApiUrl; late final TextEditingController _agentPublicKey; late HostPlatform _platform; late ConnectionMode _connectionMode; bool _saving = false; @override void initState() { super.initState(); _name = TextEditingController(text: widget.host?.name ?? ''); _address = TextEditingController(text: widget.host?.address ?? ''); _user = TextEditingController(text: widget.host?.user ?? ''); _certificate = TextEditingController( text: widget.host?.certificateSha256 ?? '', ); _edgeApiUrl = TextEditingController(text: widget.host?.edgeApiUrl ?? ''); _agentPublicKey = TextEditingController( text: widget.host?.agentPublicKey ?? '', ); _platform = widget.host?.platform ?? HostPlatform.linux; _connectionMode = widget.host?.connectionMode ?? ConnectionMode.automatic; } @override void dispose() { _name.dispose(); _address.dispose(); _user.dispose(); _certificate.dispose(); _edgeApiUrl.dispose(); _agentPublicKey.dispose(); super.dispose(); } Future _save() async { if (!_formKey.currentState!.validate()) return; setState(() => _saving = true); final host = RemoteHost( id: widget.host?.id ?? const Uuid().v4(), name: _name.text.trim(), address: _address.text.trim(), user: _platform == HostPlatform.linux ? _user.text.trim() : '', certificateSha256: _platform == HostPlatform.linux ? normalizeFingerprint(_certificate.text) : '', platform: _platform, connectionMode: _platform == HostPlatform.linux ? _connectionMode : ConnectionMode.direct, edgeApiUrl: _platform == HostPlatform.linux && _connectionMode != ConnectionMode.direct ? _edgeApiUrl.text.trim() : null, agentPublicKey: _platform == HostPlatform.linux && _connectionMode != ConnectionMode.direct ? _agentPublicKey.text.trim() : null, ); await widget.onSave(host); if (mounted) Navigator.pop(context); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return Scaffold( appBar: AppBar( title: Text( l10n.text(widget.host == null ? 'addDevice' : 'editDevice'), ), ), body: SafeArea( child: Form( key: _formKey, child: ListView( padding: const EdgeInsets.all(16), children: [ TextFormField( controller: _name, textInputAction: TextInputAction.next, decoration: InputDecoration(labelText: l10n.text('deviceName')), validator: (value) => (value ?? '').trim().isEmpty ? l10n.text('invalidName') : null, ), const SizedBox(height: 14), Text( l10n.text('devicePlatform'), style: Theme.of(context).textTheme.labelLarge, ), const SizedBox(height: 8), SegmentedButton( segments: [ ButtonSegment( value: HostPlatform.linux, icon: const Icon(Icons.terminal), label: Text(l10n.text('linux')), ), ButtonSegment( value: HostPlatform.windows, icon: const Icon(Icons.desktop_windows_outlined), label: Text(l10n.text('windows')), ), ], selected: {_platform}, onSelectionChanged: (selection) => setState(() { _platform = selection.single; if (_platform == HostPlatform.windows) { _connectionMode = ConnectionMode.direct; } }), ), const SizedBox(height: 14), TextFormField( controller: _address, keyboardType: TextInputType.url, textInputAction: TextInputAction.next, autocorrect: false, decoration: InputDecoration( labelText: l10n.text('address'), hintText: l10n.text( _platform == HostPlatform.windows ? 'windowsAddressHint' : 'addressHint', ), ), validator: (value) { try { if (_platform == HostPlatform.windows) { normalizeWindowsAgentUri(value ?? ''); } else { normalizeAgentUri(value ?? ''); } return null; } on FormatException { return l10n.text( _platform == HostPlatform.windows ? 'invalidWindowsAddress' : 'invalidAddress', ); } }, ), if (_platform == HostPlatform.windows) ...[ const SizedBox(height: 12), Card( color: Theme.of(context).colorScheme.errorContainer, child: Padding( padding: const EdgeInsets.all(12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Icon(Icons.shield_outlined), const SizedBox(width: 10), Expanded(child: Text(l10n.text('windowsPlainWarning'))), ], ), ), ), ], if (_platform == HostPlatform.linux) ...[ const SizedBox(height: 14), TextFormField( controller: _user, textInputAction: TextInputAction.next, autocorrect: false, decoration: InputDecoration( labelText: l10n.text('linuxUser'), ), validator: (value) => (value ?? '').trim().isEmpty ? l10n.text('invalidUser') : null, ), const SizedBox(height: 14), TextFormField( controller: _certificate, autocorrect: false, enableSuggestions: false, maxLength: 95, decoration: InputDecoration( labelText: l10n.text('certificate'), helperText: l10n.text('certificateHint'), ), validator: (value) => isValidFingerprint(value ?? '') ? null : l10n.text('invalidCertificate'), ), const SizedBox(height: 6), Text( l10n.text('connectionMode'), style: Theme.of(context).textTheme.labelLarge, ), const SizedBox(height: 8), SegmentedButton( segments: [ ButtonSegment( value: ConnectionMode.automatic, icon: const Icon(Icons.alt_route), label: Text(l10n.text('automatic')), ), ButtonSegment( value: ConnectionMode.direct, icon: const Icon(Icons.link), label: Text(l10n.text('direct')), ), ButtonSegment( value: ConnectionMode.relay, icon: const Icon(Icons.hub_outlined), label: Text(l10n.text('relay')), ), ], selected: {_connectionMode}, onSelectionChanged: (selection) => setState(() => _connectionMode = selection.single), ), const SizedBox(height: 6), Text( l10n.text(switch (_connectionMode) { ConnectionMode.automatic => 'automaticConnectionHint', ConnectionMode.direct => 'directConnectionHint', ConnectionMode.relay => 'relayConnectionHint', }), style: Theme.of(context).textTheme.bodySmall, ), if (_connectionMode != ConnectionMode.direct) ...[ const SizedBox(height: 8), TextFormField( controller: _edgeApiUrl, keyboardType: TextInputType.url, textInputAction: TextInputAction.next, autocorrect: false, decoration: InputDecoration( labelText: l10n.text('edgeApiUrl'), hintText: l10n.text('edgeApiUrlHint'), ), validator: (value) { if (_connectionMode == ConnectionMode.direct) return null; try { normalizeEdgeApiUri(value ?? ''); return null; } on FormatException { return l10n.text('invalidEdgeUrl'); } }, ), const SizedBox(height: 14), TextFormField( controller: _agentPublicKey, autocorrect: false, enableSuggestions: false, decoration: InputDecoration( labelText: l10n.text('agentPublicKey'), helperText: l10n.text('agentPublicKeyHint'), ), validator: (value) => _connectionMode == ConnectionMode.direct || isValidAgentPublicKey(value ?? '') ? null : l10n.text('invalidAgentPublicKey'), ), ], ], const SizedBox(height: 20), FilledButton.icon( onPressed: _saving ? null : _save, icon: _saving ? const SizedBox.square( dimension: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.save_outlined), label: Text(l10n.text('save')), ), ], ), ), ), ); } } class SettingsScreen extends StatefulWidget { const SettingsScreen({ super.key, required this.settings, required this.onSave, }); final AppSettings settings; final Future Function(AppSettings) onSave; @override State createState() => _SettingsScreenState(); } class _SettingsScreenState extends State { late AppSettings _settings = widget.settings; Future _update(AppSettings value) async { setState(() => _settings = value); await widget.onSave(value); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final resolution = '${_settings.maxWidth}x${_settings.maxHeight}'; return Scaffold( appBar: AppBar(title: Text(l10n.text('settings'))), body: ListView( padding: const EdgeInsets.all(16), children: [ Text( l10n.text('language'), style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 10), SegmentedButton( segments: [ ButtonSegment(value: 'en', label: Text(l10n.text('english'))), ButtonSegment(value: 'zh', label: Text(l10n.text('chinese'))), ], selected: {_settings.languageCode ?? 'en'}, onSelectionChanged: (value) => _update(_settings.copyWith(languageCode: value.first)), ), const SizedBox(height: 28), DropdownButtonFormField( initialValue: resolution, decoration: InputDecoration(labelText: l10n.text('resolution')), items: const [ DropdownMenuItem(value: '960x540', child: Text('960 x 540')), DropdownMenuItem(value: '1280x720', child: Text('1280 x 720')), DropdownMenuItem(value: '1600x900', child: Text('1600 x 900')), DropdownMenuItem(value: '1920x1080', child: Text('1920 x 1080')), ], onChanged: (value) { if (value == null) return; final parts = value.split('x'); _update( _settings.copyWith( maxWidth: int.parse(parts[0]), maxHeight: int.parse(parts[1]), ), ); }, ), const SizedBox(height: 28), Text( '${l10n.text('frameRate')}: ${l10n.text('fps', {'value': _settings.framesPerSecond})}', style: Theme.of(context).textTheme.titleSmall, ), Slider( min: 5, max: 30, divisions: 5, value: _settings.framesPerSecond.toDouble(), label: l10n.text('fps', {'value': _settings.framesPerSecond}), onChanged: (value) => setState( () => _settings = _settings.copyWith( framesPerSecond: value.round(), ), ), onChangeEnd: (value) => _update(_settings.copyWith(framesPerSecond: value.round())), ), ], ), ); } } class DesktopScreen extends StatefulWidget { const DesktopScreen({ super.key, required this.host, required this.settings, this.pairingCode, required this.initialTotpCode, }); final RemoteHost host; final AppSettings settings; final String? pairingCode; final String initialTotpCode; @override State createState() => _DesktopScreenState(); } class _DesktopScreenState extends State { late final RemoteDesktopClient _client; StreamSubscription? _stateSubscription; StreamSubscription? _frameSubscription; final TextEditingController _keyboardController = TextEditingController(); final FocusNode _keyboardFocus = FocusNode(); AgentState _state = const AgentState(AgentPhase.connecting); ui.Image? _image; int? _textureId; int _frameWidth = 0; int _frameHeight = 0; bool _showKeyboard = false; late String? _totpCode = widget.initialTotpCode; @override void initState() { super.initState(); _client = switch (widget.host.platform) { HostPlatform.linux => AgentClient( host: widget.host, settings: widget.settings, ), HostPlatform.windows => WindowsAgentClient( host: widget.host, settings: widget.settings, ), }; _stateSubscription = _client.states.listen((state) { if (mounted) setState(() => _state = state); }); _frameSubscription = _client.frames.listen(_decodeFrame); unawaited( SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky), ); unawaited(_connect()); } Future _connect() async { var totpCode = _totpCode; if (totpCode == null) { totpCode = await _requestTotpCode(); if (totpCode == null) return; } _totpCode = null; try { await _client.connect( pairingCode: widget.pairingCode, totpCode: totpCode, ); } on AgentException { // The state stream provides the localized error category. } } Future _requestTotpCode() { final controller = TextEditingController(); final formKey = GlobalKey(); final l10n = AppLocalizations.of(context); return showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(l10n.text('totpCode')), content: Form( key: formKey, child: TextFormField( controller: controller, autofocus: true, keyboardType: TextInputType.number, maxLength: 6, obscureText: true, inputFormatters: [FilteringTextInputFormatter.digitsOnly], decoration: InputDecoration(helperText: l10n.text('totpCodeHint')), validator: (value) => RegExp(r'^\d{6}$').hasMatch(value ?? '') ? null : l10n.text('invalidTotpCode'), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext), child: Text(l10n.text('cancel')), ), FilledButton( onPressed: () { if (formKey.currentState!.validate()) { Navigator.pop(dialogContext, controller.text); } }, child: Text(l10n.text('continueAction')), ), ], ), ).whenComplete(controller.dispose); } void _decodeFrame(RemoteFrame frame) { if (frame.textureId case final textureId?) { if (!mounted) return; final previous = _image; setState(() { _textureId = textureId; _image = null; _frameWidth = frame.width; _frameHeight = frame.height; }); _client.acknowledgeFrame(frame.sequence); previous?.dispose(); return; } ui.decodeImageFromPixels( frame.bgra, frame.width, frame.height, ui.PixelFormat.bgra8888, (image) { if (!mounted) { image.dispose(); return; } final previous = _image; setState(() { _textureId = null; _image = image; _frameWidth = frame.width; _frameHeight = frame.height; }); _client.acknowledgeFrame(frame.sequence); if (previous != null) { WidgetsBinding.instance.addPostFrameCallback( (_) => previous.dispose(), ); } }, ); } void _toggleKeyboard() { setState(() => _showKeyboard = !_showKeyboard); if (_showKeyboard) { WidgetsBinding.instance.addPostFrameCallback( (_) => _keyboardFocus.requestFocus(), ); } else { _keyboardFocus.unfocus(); } } void _sendPointer(Offset local, Size size) { if (_frameWidth == 0 || size.width <= 0 || size.height <= 0) return; final x = (local.dx / size.width * _frameWidth).floor().clamp( 0, _frameWidth - 1, ); final y = (local.dy / size.height * _frameHeight).floor().clamp( 0, _frameHeight - 1, ); _client.sendPointer(x, y); } @override void dispose() { unawaited(_stateSubscription?.cancel()); unawaited(_frameSubscription?.cancel()); _keyboardController.dispose(); _keyboardFocus.dispose(); _image?.dispose(); unawaited(_client.dispose()); unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge)); super.dispose(); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return PopScope( onPopInvokedWithResult: (_, _) => unawaited(_client.close()), child: Scaffold( backgroundColor: Colors.black, body: Stack( fit: StackFit.expand, children: [ if (_image == null && _textureId == null) _SessionStatus(state: _state, onRetry: _connect) else LayoutBuilder( builder: (context, constraints) { final imageAspect = _frameWidth / _frameHeight; final viewAspect = constraints.maxWidth / constraints.maxHeight; final width = imageAspect > viewAspect ? constraints.maxWidth : constraints.maxHeight * imageAspect; final height = imageAspect > viewAspect ? constraints.maxWidth / imageAspect : constraints.maxHeight; return Center( child: SizedBox( width: width, height: height, child: GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (details) => _sendPointer( details.localPosition, Size(width, height), ), onTapUp: (_) { _client.sendButton('left', true); _client.sendButton('left', false); }, onPanStart: (details) => _sendPointer( details.localPosition, Size(width, height), ), onPanUpdate: (details) => _sendPointer( details.localPosition, Size(width, height), ), onLongPressStart: (_) => _client.sendButton('right', true), onLongPressEnd: (_) => _client.sendButton('right', false), child: _textureId != null ? Texture(textureId: _textureId!) : RawImage( image: _image, fit: BoxFit.fill, filterQuality: FilterQuality.low, ), ), ), ); }, ), Positioned( left: 8, right: 8, top: 0, child: SafeArea( bottom: false, child: Material( color: const Color(0xcc171b1a), borderRadius: BorderRadius.circular(6), child: SizedBox( height: 48, child: Row( children: [ const SizedBox(width: 12), Container( width: 8, height: 8, decoration: BoxDecoration( color: _state.phase == AgentPhase.connected ? const Color(0xff45d483) : const Color(0xffffc857), shape: BoxShape.circle, ), ), const SizedBox(width: 8), Expanded( child: Text( widget.host.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: Colors.white), ), ), IconButton( onPressed: _toggleKeyboard, tooltip: l10n.text( _showKeyboard ? 'hideKeyboard' : 'showKeyboard', ), color: Colors.white, icon: Icon( _showKeyboard ? Icons.keyboard_hide : Icons.keyboard_alt_outlined, ), ), IconButton( onPressed: () => Navigator.pop(context), tooltip: l10n.text('disconnect'), color: Colors.white, icon: const Icon(Icons.close), ), ], ), ), ), ), ), if (_showKeyboard) Positioned( left: 8, right: 8, bottom: 8, child: SafeArea( top: false, child: Material( color: const Color(0xf21f2422), borderRadius: BorderRadius.circular(6), child: Padding( padding: const EdgeInsets.all(10), child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: _keyboardController, focusNode: _keyboardFocus, style: const TextStyle(color: Colors.white), decoration: InputDecoration( isDense: true, hintText: l10n.text('keyboardInput'), hintStyle: const TextStyle(color: Colors.white60), filled: true, fillColor: Colors.black26, ), onChanged: (value) { if (value.isEmpty) return; _client.sendText(value); _keyboardController.clear(); }, ), const SizedBox(height: 8), SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ _KeyButton( label: l10n.text('escape'), onPressed: () => _client.sendKey(0xff1b), ), _KeyButton( label: l10n.text('tab'), onPressed: () => _client.sendKey(0xff09), ), _KeyButton( label: l10n.text('backspace'), onPressed: () => _client.sendKey(0xff08), ), _KeyButton( label: l10n.text('enter'), onPressed: () => _client.sendKey(0xff0d), ), ], ), ), ], ), ), ), ), ), ], ), ), ); } } class _SessionStatus extends StatelessWidget { const _SessionStatus({required this.state, required this.onRetry}); final AgentState state; final Future Function() onRetry; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); if (state.phase == AgentPhase.failed) { final errorKey = switch (state.error) { AgentError.network => 'errorNetwork', AgentError.certificate => 'errorCertificate', AgentError.protocol => 'errorProtocol', AgentError.authentication => 'errorAuthentication', AgentError.totp => 'errorTotp', AgentError.permission => 'errorPermission', AgentError.desktop => 'errorDesktop', AgentError.frame => 'errorFrame', _ => 'errorUnknown', }; return Center( child: Padding( padding: const EdgeInsets.all(32), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 360), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.error_outline, color: Color(0xffff8a80), size: 44, ), const SizedBox(height: 14), Text( l10n.text('errorTitle'), style: Theme.of( context, ).textTheme.titleLarge?.copyWith(color: Colors.white), ), const SizedBox(height: 8), Text( l10n.text(errorKey), textAlign: TextAlign.center, style: const TextStyle(color: Colors.white70), ), const SizedBox(height: 18), FilledButton.icon( onPressed: onRetry, icon: const Icon(Icons.refresh), label: Text(l10n.text('retry')), ), ], ), ), ), ); } final statusKey = switch (state.phase) { AgentPhase.switchingToRelay => 'switchingToRelay', AgentPhase.authenticating => 'authenticating', AgentPhase.openingDesktop => 'openingDesktop', AgentPhase.waitingForFrame => 'waitingForFrame', _ => 'connecting', }; return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator(color: Colors.white), const SizedBox(height: 16), Text( l10n.text(statusKey), style: const TextStyle(color: Colors.white), ), ], ), ); } } class _KeyButton extends StatelessWidget { const _KeyButton({required this.label, required this.onPressed}); final String label; final VoidCallback onPressed; @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.only(right: 8), child: OutlinedButton(onPressed: onPressed, child: Text(label)), ); }