1323 lines
46 KiB
Dart
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)),
|
|
);
|
|
}
|