Files

1323 lines
46 KiB
Dart

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<RemoteDeskApp> createState() => _RemoteDeskAppState();
}
class _RemoteDeskAppState extends State<RemoteDeskApp> {
List<RemoteHost> _hosts = const [];
AppSettings _settings = const AppSettings();
bool _loaded = false;
@override
void initState() {
super.initState();
unawaited(_load());
}
Future<void> _load() async {
final values = await Future.wait<Object>([
widget.storage.loadHosts(),
widget.storage.loadSettings(),
]);
if (!mounted) return;
setState(() {
_hosts = values[0] as List<RemoteHost>;
_settings = values[1] as AppSettings;
_loaded = true;
});
}
Future<void> _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<void> _deleteHost(RemoteHost host) async {
final next = _hosts.where((item) => item.id != host.id).toList();
setState(() => _hosts = next);
await widget.storage.saveHosts(next);
}
Future<void> _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<RemoteHost> hosts;
final AppSettings settings;
final Future<void> Function(RemoteHost) onSaveHost;
final Future<void> Function(RemoteHost) onDeleteHost;
final Future<void> Function(AppSettings) onSaveSettings;
Future<void> _editHost(BuildContext context, [RemoteHost? host]) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => HostEditorScreen(host: host, onSave: onSaveHost),
),
);
}
Future<void> _openSettings(BuildContext context) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) =>
SettingsScreen(settings: settings, onSave: onSaveSettings),
),
);
}
Future<void> _delete(BuildContext context, RemoteHost host) async {
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
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<void> _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<void>(
MaterialPageRoute(
builder: (_) => DesktopScreen(
host: host,
settings: settings,
pairingCode: pairingCode,
initialTotpCode: totpCode,
),
),
);
}
Future<String?> _requestPairingCode(BuildContext context) {
final controller = TextEditingController();
final formKey = GlobalKey<FormState>();
final l10n = AppLocalizations.of(context);
return showDialog<String>(
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<String?> _requestTotpCode(BuildContext context) {
final controller = TextEditingController();
final formKey = GlobalKey<FormState>();
final l10n = AppLocalizations.of(context);
return showDialog<String>(
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<String>(
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<void> Function(RemoteHost) onSave;
@override
State<HostEditorScreen> createState() => _HostEditorScreenState();
}
class _HostEditorScreenState extends State<HostEditorScreen> {
final _formKey = GlobalKey<FormState>();
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<void> _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<HostPlatform>(
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<ConnectionMode>(
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<void> Function(AppSettings) onSave;
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
late AppSettings _settings = widget.settings;
Future<void> _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<String>(
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<String>(
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<DesktopScreen> createState() => _DesktopScreenState();
}
class _DesktopScreenState extends State<DesktopScreen> {
late final RemoteDesktopClient _client;
StreamSubscription<AgentState>? _stateSubscription;
StreamSubscription<RemoteFrame>? _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<void> _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<String?> _requestTotpCode() {
final controller = TextEditingController();
final formKey = GlobalKey<FormState>();
final l10n = AppLocalizations.of(context);
return showDialog<String>(
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<void> 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)),
);
}