Files

1740 lines
55 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter/services.dart';
import 'app_localizations.dart';
import 'app_preferences.dart';
import 'control_service.dart';
import 'desktop_tray.dart';
final _desktopTray = DesktopTrayController();
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await _desktopTray.initialize();
runApp(const RemoteDeskControlApp());
}
class RemoteDeskControlApp extends StatefulWidget {
const RemoteDeskControlApp({
super.key,
this.preferences = const SharedAppPreferences(),
});
final AppPreferences preferences;
@override
State<RemoteDeskControlApp> createState() => _RemoteDeskControlAppState();
}
class _RemoteDeskControlAppState extends State<RemoteDeskControlApp> {
AppSettings _settings = const AppSettings();
Locale? get _locale => switch (_settings.localeCode) {
'zh' => const Locale('zh', 'Hans'),
'en' => const Locale('en'),
_ => null,
};
@override
void initState() {
super.initState();
unawaited(_loadSettings());
}
Future<void> _loadSettings() async {
AppSettings settings;
try {
settings = await widget.preferences.load();
} on Object {
settings = const AppSettings();
}
if (!mounted) return;
setState(() => _settings = settings);
_desktopTray
..updateCloseToTray(settings.closeToTray)
..updateLocale(_locale);
}
void _updateSettings(AppSettings settings) {
if (settings == _settings) return;
setState(() => _settings = settings);
_desktopTray
..updateCloseToTray(settings.closeToTray)
..updateLocale(_locale);
unawaited(_saveSettings(settings));
}
Future<void> _saveSettings(AppSettings settings) async {
try {
await widget.preferences.save(settings);
} on Object {
// Keep runtime settings usable if the preference backend is unavailable.
}
}
@override
Widget build(BuildContext context) {
const colors = ColorScheme.light(
primary: Color(0xFF006D77),
onPrimary: Colors.white,
secondary: Color(0xFFE76F51),
surface: Color(0xFFF9FBFA),
onSurface: Color(0xFF172121),
outline: Color(0xFFB8C5C4),
error: Color(0xFFB23B28),
);
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'RemoteDesk',
locale: _locale,
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
theme: ThemeData(
colorScheme: colors,
scaffoldBackgroundColor: const Color(0xFFF1F5F4),
useMaterial3: true,
visualDensity: VisualDensity.standard,
inputDecorationTheme: const InputDecorationTheme(
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(6)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(6)),
borderSide: BorderSide(color: Color(0xFFC8D3D2)),
),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 12),
),
cardTheme: const CardThemeData(
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
side: BorderSide(color: Color(0xFFD5DEDD)),
),
),
),
home: ControlWorkspace(
locale: _locale,
settings: _settings,
onSettingsChanged: _updateSettings,
onLocaleChanged: (locale) {
_updateSettings(_settings.withLocale(locale?.languageCode));
},
),
);
}
}
enum _WorkspacePage { devices, sessions, settings }
class _SessionRecord {
const _SessionRecord({
required this.id,
required this.hostName,
required this.startedAt,
this.diagnostics,
});
final String id;
final String hostName;
final DateTime startedAt;
final DesktopSessionDiagnostics? diagnostics;
_SessionRecord withDiagnostics(DesktopSessionDiagnostics value) =>
_SessionRecord(
id: id,
hostName: hostName,
startedAt: startedAt,
diagnostics: value,
);
}
class ControlWorkspace extends StatefulWidget {
const ControlWorkspace({
super.key,
this.locale,
required this.settings,
required this.onSettingsChanged,
required this.onLocaleChanged,
});
final Locale? locale;
final AppSettings settings;
final ValueChanged<AppSettings> onSettingsChanged;
final ValueChanged<Locale?> onLocaleChanged;
@override
State<ControlWorkspace> createState() => _ControlWorkspaceState();
}
class _ControlWorkspaceState extends State<ControlWorkspace> {
final _service = ControlService();
final _formKey = GlobalKey<FormState>();
final _name = TextEditingController();
final _address = TextEditingController();
final _user = TextEditingController();
final _fingerprint = TextEditingController();
final _search = TextEditingController();
final _searchFocus = FocusNode();
Timer? _healthTimer;
Timer? _sessionTimer;
ControlServiceStatus _serviceStatus = ControlServiceStatus.checking;
String _serviceMessage = 'Checking control service';
_WorkspacePage _page = _WorkspacePage.devices;
List<SavedHost> _hosts = const [];
List<_SessionRecord> _sessions = const [];
String? _selectedHostId;
AgentProbe? _probe;
bool _loadingHosts = false;
bool _savingHost = false;
bool _probing = false;
bool _launching = false;
bool _terminalLaunching = false;
bool _updatingForm = false;
bool _formDirty = false;
late bool _fullscreen;
late bool _followWindow;
late bool _captureInput;
late bool _clipboardRead;
late bool _clipboardWrite;
late int _framesPerSecond;
late String _resolution;
@override
void initState() {
super.initState();
_applySettings(widget.settings);
_search.addListener(_refresh);
for (final controller in [_name, _address, _user, _fingerprint]) {
controller.addListener(_markFormDirty);
}
unawaited(_initialize());
_healthTimer = Timer.periodic(
const Duration(seconds: 5),
(_) => unawaited(_checkService()),
);
_sessionTimer = Timer.periodic(
const Duration(seconds: 2),
(_) => unawaited(_refreshSessions()),
);
}
@override
void didUpdateWidget(ControlWorkspace oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.settings != widget.settings) {
_applySettings(widget.settings);
}
}
@override
void dispose() {
_healthTimer?.cancel();
_sessionTimer?.cancel();
_name.dispose();
_address.dispose();
_user.dispose();
_fingerprint.dispose();
_search.dispose();
_searchFocus.dispose();
super.dispose();
}
void _applySettings(AppSettings settings) {
_fullscreen = settings.fullscreen;
_followWindow = settings.followWindow;
_captureInput = settings.captureInput;
_clipboardRead = settings.clipboardRead;
_clipboardWrite = settings.clipboardWrite;
_framesPerSecond = settings.framesPerSecond;
_resolution = settings.resolution;
}
void _markFormDirty() {
if (!_updatingForm && !_formDirty && mounted) {
setState(() => _formDirty = true);
}
}
void _refresh() {
if (mounted) setState(() {});
}
Future<void> _initialize() async {
final result = await _service.ensureRunning();
if (!mounted) return;
setState(() {
_serviceStatus = result.status;
_serviceMessage = result.message;
});
if (result.status == ControlServiceStatus.available) await _loadHosts();
}
Future<void> _checkService() async {
final result = await _service.health();
if (!mounted) return;
final becameAvailable =
_serviceStatus != ControlServiceStatus.available &&
result.status == ControlServiceStatus.available;
setState(() {
_serviceStatus = result.status;
_serviceMessage = result.message;
});
if (becameAvailable && _hosts.isEmpty) await _loadHosts();
}
Future<void> _loadHosts() async {
if (_loadingHosts) return;
setState(() => _loadingHosts = true);
final result = await _service.loadHosts();
if (!mounted) return;
setState(() {
_loadingHosts = false;
if (result.ok) _hosts = result.data ?? const [];
});
if (!result.ok) _notify(result.message, error: true);
}
Future<void> _selectHost(SavedHost host) async {
if (host.id == _selectedHostId || !await _confirmDiscardChanges()) return;
setState(() {
_updatingForm = true;
_selectedHostId = host.id;
_name.text = host.name;
_address.text = host.address;
_user.text = host.user;
_fingerprint.text = host.certificateSha256;
_probe = null;
_formDirty = false;
_updatingForm = false;
});
}
Future<void> _newHost() async {
if (!await _confirmDiscardChanges()) return;
_clearHostEditor();
}
void _clearHostEditor() {
setState(() {
_updatingForm = true;
_selectedHostId = null;
_name.clear();
_address.clear();
_user.clear();
_fingerprint.clear();
_probe = null;
_page = _WorkspacePage.devices;
_formDirty = false;
_updatingForm = false;
});
}
Future<bool> _confirmDiscardChanges() async {
if (!_formDirty) return true;
final l10n = AppLocalizations.of(context);
return await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.text('Discard unsaved changes?')),
content: Text(
l10n.text('The current host changes have not been saved.'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(l10n.text('Keep editing')),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(l10n.text('Discard')),
),
],
),
) ??
false;
}
SavedHost _hostFromForm() => SavedHost(
id:
_selectedHostId ??
'${DateTime.now().microsecondsSinceEpoch}-${_address.text.hashCode.abs()}',
name: _name.text.trim(),
address: _address.text.trim(),
user: _user.text.trim(),
certificateSha256: _fingerprint.text.trim(),
);
Future<bool> _saveHost() async {
if (!_formKey.currentState!.validate() || _savingHost) return false;
setState(() => _savingHost = true);
final host = _hostFromForm();
final updated = [..._hosts];
final index = updated.indexWhere((item) => item.id == host.id);
if (index == -1) {
updated.add(host);
} else {
updated[index] = host;
}
final result = await _service.saveHosts(updated);
if (!mounted) return false;
setState(() {
_savingHost = false;
if (result.ok) {
_hosts = updated;
_selectedHostId = host.id;
_formDirty = false;
}
});
_notify(result.message, error: !result.ok);
return result.ok;
}
Future<void> _deleteHost() async {
final id = _selectedHostId;
if (id == null) return;
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.text('Delete host?')),
content: Text(
AppLocalizations.of(context).format(
'Remove {name} from this device?',
{'name': _name.text.trim()},
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(l10n.text('Cancel')),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(l10n.text('Delete')),
),
],
),
);
if (confirmed != true || !mounted) return;
final updated = _hosts.where((host) => host.id != id).toList();
final result = await _service.saveHosts(updated);
if (!mounted) return;
if (result.ok) {
setState(() => _hosts = updated);
_clearHostEditor();
}
_notify(result.message, error: !result.ok);
}
Future<void> _probeAgent() async {
if (!_formKey.currentState!.validate() || _probing) return;
setState(() {
_probing = true;
_probe = null;
});
final result = await _service.probeLinuxAgent(
_address.text.trim(),
_fingerprint.text.trim(),
);
if (!mounted) return;
setState(() {
_probing = false;
_probe = result.data;
});
_notify(result.message, error: !result.ok);
}
Future<void> _launchDesktop() async {
if (!_formKey.currentState!.validate() || _launching) return;
setState(() => _launching = true);
final size = _resolution.split('x').map(int.parse).toList();
final result = await _service.launchLinuxDesktop(
LinuxDesktopLaunchRequest(
address: _address.text.trim(),
user: _user.text.trim(),
certificateSha256: _fingerprint.text.trim(),
width: size[0],
height: size[1],
framesPerSecond: _framesPerSecond,
fullscreen: _fullscreen,
followWindow: _followWindow,
captureInput: _captureInput,
clipboardRead: _clipboardRead,
clipboardWrite: _clipboardWrite,
),
);
if (!mounted) return;
setState(() {
_launching = false;
if (result.ok && result.sessionId != null) {
_sessions = [
_SessionRecord(
id: result.sessionId!,
hostName: _name.text.trim(),
startedAt: DateTime.now(),
),
..._sessions.where((session) => session.id != result.sessionId),
];
}
});
_notify(result.message, error: !result.ok);
}
Future<void> _launchTerminal() async {
if (!_formKey.currentState!.validate() || _terminalLaunching) return;
setState(() => _terminalLaunching = true);
final result = await _service.launchLinuxTerminal(
_address.text.trim(),
_user.text.trim(),
_fingerprint.text.trim(),
);
if (!mounted) return;
setState(() => _terminalLaunching = false);
_notify(result.message, error: !result.ok);
}
Future<void> _refreshSessions() async {
if (_sessions.isEmpty || _serviceStatus != ControlServiceStatus.available) {
return;
}
final updated = [..._sessions];
var changed = false;
for (var index = 0; index < updated.length; index++) {
final result = await _service.sessionDiagnostics(updated[index].id);
if (result.ok && result.data != null) {
updated[index] = updated[index].withDiagnostics(result.data!);
changed = true;
}
}
if (mounted && changed) setState(() => _sessions = updated);
}
void _notify(String message, {required bool error}) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).text(message)),
backgroundColor: error ? const Color(0xFF8F3425) : null,
),
);
}
void _updateSessionDefaults(VoidCallback update) {
setState(update);
widget.onSettingsChanged(
widget.settings.copyWith(
fullscreen: _fullscreen,
followWindow: _followWindow,
captureInput: _captureInput,
clipboardRead: _clipboardRead,
clipboardWrite: _clipboardWrite,
framesPerSecond: _framesPerSecond,
resolution: _resolution,
),
);
}
void _showPage(_WorkspacePage page) => setState(() => _page = page);
void _focusSearch() {
setState(() => _page = _WorkspacePage.devices);
_searchFocus.requestFocus();
}
@override
Widget build(BuildContext context) {
final compact = MediaQuery.sizeOf(context).width < 760;
final l10n = AppLocalizations.of(context);
final content = Column(
children: [
_WorkspaceHeader(
page: _page,
serviceStatus: _serviceStatus,
serviceMessage: _serviceMessage,
onRefresh: _initialize,
onAdd: _page == _WorkspacePage.devices
? () => unawaited(_newHost())
: null,
),
const SizedBox(height: 18),
Expanded(child: _buildPage()),
],
);
return CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.keyN, control: true): () =>
unawaited(_newHost()),
const SingleActivator(LogicalKeyboardKey.keyS, control: true): () {
if (_page == _WorkspacePage.devices) unawaited(_saveHost());
},
const SingleActivator(LogicalKeyboardKey.keyF, control: true):
_focusSearch,
const SingleActivator(LogicalKeyboardKey.f5): () =>
unawaited(_initialize()),
const SingleActivator(LogicalKeyboardKey.digit1, alt: true): () =>
_showPage(_WorkspacePage.devices),
const SingleActivator(LogicalKeyboardKey.digit2, alt: true): () =>
_showPage(_WorkspacePage.sessions),
const SingleActivator(LogicalKeyboardKey.digit3, alt: true): () =>
_showPage(_WorkspacePage.settings),
},
child: Focus(
autofocus: true,
child: Scaffold(
body: SafeArea(
child: compact
? Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 0),
child: content,
)
: Row(
children: [
_AppRail(
page: _page,
status: _serviceStatus,
onChanged: _showPage,
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(24),
child: content,
),
),
],
),
),
bottomNavigationBar: compact
? NavigationBar(
selectedIndex: _page.index,
onDestinationSelected: (index) =>
_showPage(_WorkspacePage.values[index]),
destinations: [
NavigationDestination(
icon: const Icon(Icons.computer_outlined),
selectedIcon: const Icon(Icons.computer),
label: l10n.text('Devices'),
),
NavigationDestination(
icon: const Icon(Icons.monitor_heart_outlined),
selectedIcon: const Icon(Icons.monitor_heart),
label: l10n.text('Sessions'),
),
NavigationDestination(
icon: const Icon(Icons.tune_outlined),
selectedIcon: const Icon(Icons.tune),
label: l10n.text('Settings'),
),
],
)
: null,
),
),
);
}
Widget _buildPage() => switch (_page) {
_WorkspacePage.devices => _DevicesPage(state: this),
_WorkspacePage.sessions => _SessionsPage(
sessions: _sessions,
onRefresh: _refreshSessions,
onOpenDevices: () => _showPage(_WorkspacePage.devices),
),
_WorkspacePage.settings => _SettingsPage(state: this),
};
}
class _AppRail extends StatelessWidget {
const _AppRail({
required this.page,
required this.status,
required this.onChanged,
});
final _WorkspacePage page;
final ControlServiceStatus status;
final ValueChanged<_WorkspacePage> onChanged;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Container(
width: 84,
color: const Color(0xFF172121),
child: Column(
children: [
const SizedBox(height: 22),
const Icon(
Icons.screen_share_outlined,
color: Color(0xFFBFE7E2),
size: 30,
),
const SizedBox(height: 26),
for (final item in _WorkspacePage.values)
Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: Tooltip(
message: l10n.text(_pageLabel(item)),
child: IconButton(
onPressed: () => onChanged(item),
icon: Icon(_pageIcon(item)),
color: page == item ? Colors.white : const Color(0xFF91A5A3),
style: IconButton.styleFrom(
backgroundColor: page == item
? const Color(0xFF006D77)
: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(6)),
),
),
),
),
),
const Spacer(),
Tooltip(
message: l10n.text(
status == ControlServiceStatus.available
? 'Control service online'
: 'Control service offline',
),
child: Container(
width: 10,
height: 10,
margin: const EdgeInsets.only(bottom: 24),
decoration: BoxDecoration(
color: status == ControlServiceStatus.available
? const Color(0xFF70D6A7)
: const Color(0xFFE76F51),
shape: BoxShape.circle,
),
),
),
],
),
);
}
}
class _WorkspaceHeader extends StatelessWidget {
const _WorkspaceHeader({
required this.page,
required this.serviceStatus,
required this.serviceMessage,
required this.onRefresh,
this.onAdd,
});
final _WorkspacePage page;
final ControlServiceStatus serviceStatus;
final String serviceMessage;
final Future<void> Function() onRefresh;
final VoidCallback? onAdd;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final online = serviceStatus == ControlServiceStatus.available;
final title = Text(
l10n.text(_pageLabel(page)),
style: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700),
);
final status = Tooltip(
message: l10n.text(serviceMessage),
child: Chip(
avatar: Icon(
online ? Icons.check_circle_outline : Icons.cloud_off_outlined,
size: 17,
color: online ? const Color(0xFF006D77) : const Color(0xFFB23B28),
),
label: Text(l10n.text(online ? 'Service online' : 'Service offline')),
),
);
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 650) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(child: title),
if (onAdd != null)
IconButton.filledTonal(
tooltip: l10n.text('Add host'),
onPressed: onAdd,
icon: const Icon(Icons.add),
),
],
),
const SizedBox(height: 6),
Row(
children: [
status,
IconButton(
tooltip: l10n.text('Refresh control service'),
onPressed: onRefresh,
icon: const Icon(Icons.refresh),
),
],
),
],
);
}
return Row(
children: [
Expanded(child: title),
if (onAdd != null) ...[
FilledButton.tonalIcon(
onPressed: onAdd,
icon: const Icon(Icons.add),
label: Text(l10n.text('Add host')),
),
const SizedBox(width: 10),
],
status,
IconButton(
tooltip: l10n.text('Refresh control service'),
onPressed: onRefresh,
icon: const Icon(Icons.refresh),
),
],
);
},
);
}
}
class _DevicesPage extends StatelessWidget {
const _DevicesPage({required this.state});
final _ControlWorkspaceState state;
@override
Widget build(BuildContext context) {
final split = MediaQuery.sizeOf(context).width >= 1050;
final list = _HostList(state: state);
final editor = _HostEditor(state: state);
if (!split) {
return SingleChildScrollView(
child: Column(
children: [
list,
const SizedBox(height: 16),
editor,
const SizedBox(height: 24),
],
),
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 330, child: list),
const SizedBox(width: 18),
Expanded(child: SingleChildScrollView(child: editor)),
],
);
}
}
class _HostList extends StatelessWidget {
const _HostList({required this.state});
final _ControlWorkspaceState state;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final query = state._search.text.trim().toLowerCase();
final hosts = state._hosts
.where(
(host) =>
query.isEmpty ||
host.name.toLowerCase().contains(query) ||
host.address.toLowerCase().contains(query),
)
.toList();
return _Surface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: state._search,
focusNode: state._searchFocus,
decoration: InputDecoration(
hintText: l10n.text('Search hosts'),
prefixIcon: const Icon(Icons.search),
suffixIcon: query.isEmpty
? null
: IconButton(
tooltip: l10n.text('Clear search'),
onPressed: state._search.clear,
icon: const Icon(Icons.close),
),
),
),
const SizedBox(height: 12),
if (state._loadingHosts)
const LinearProgressIndicator()
else if (hosts.isEmpty)
_EmptyState(
icon: Icons.dns_outlined,
title: l10n.text(
query.isEmpty ? 'No saved hosts' : 'No matching hosts',
),
actionLabel: l10n.text(
query.isEmpty ? 'Add host' : 'Clear search',
),
onAction: query.isEmpty
? () => unawaited(state._newHost())
: state._search.clear,
)
else
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 500),
child: ListView.separated(
shrinkWrap: true,
itemCount: hosts.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final host = hosts[index];
final selected = state._selectedHostId == host.id;
return ListTile(
selected: selected,
selectedTileColor: const Color(0xFFE4F1EF),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(6)),
),
leading: CircleAvatar(
backgroundColor: selected
? const Color(0xFF006D77)
: const Color(0xFFDDE7E5),
foregroundColor: selected
? Colors.white
: const Color(0xFF365453),
child: const Icon(Icons.computer, size: 20),
),
title: Text(
host.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
host.address,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onTap: () => unawaited(state._selectHost(host)),
);
},
),
),
],
),
);
}
}
class _HostEditor extends StatelessWidget {
const _HostEditor({required this.state});
final _ControlWorkspaceState state;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final online = state._serviceStatus == ControlServiceStatus.available;
return Form(
key: state._formKey,
child: Column(
children: [
_Surface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
state._selectedHostId == null
? l10n.text('New Linux host')
: l10n.text('Linux host'),
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
),
),
if (state._formDirty)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(
l10n.text('Unsaved changes'),
style: Theme.of(context).textTheme.labelMedium
?.copyWith(
color: Theme.of(context).colorScheme.error,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
tooltip: l10n.text('Delete host'),
onPressed: state._selectedHostId == null
? null
: state._deleteHost,
icon: const Icon(Icons.delete_outline),
),
],
),
const SizedBox(height: 16),
LayoutBuilder(
builder: (context, constraints) {
final twoColumns = constraints.maxWidth >= 650;
final name = _HostField(
controller: state._name,
label: l10n.text('Display name'),
validator: _required(l10n.text('Enter a display name.')),
);
final address = _HostField(
controller: state._address,
label: l10n.text('Agent address'),
hint: 'host.example:39500',
validator: _required(
l10n.text('Enter the Agent address.'),
),
);
if (!twoColumns) {
return Column(
children: [name, const SizedBox(height: 14), address],
);
}
return Row(
children: [
Expanded(child: name),
const SizedBox(width: 14),
Expanded(child: address),
],
);
},
),
const SizedBox(height: 14),
TextFormField(
controller: state._user,
decoration: InputDecoration(
labelText: l10n.text('Linux user'),
),
validator: _required(l10n.text('Enter the Linux user.')),
),
const SizedBox(height: 14),
TextFormField(
controller: state._fingerprint,
decoration: InputDecoration(
labelText: l10n.text('TLS certificate SHA-256'),
hintText: l10n.text('64 lowercase hexadecimal characters'),
prefixIcon: const Icon(Icons.fingerprint),
),
validator: (value) =>
RegExp(r'^[0-9a-f]{64}$').hasMatch(value?.trim() ?? '')
? null
: l10n.text(
'Enter a lowercase 64-character SHA-256 fingerprint.',
),
),
const SizedBox(height: 18),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
FilledButton.icon(
onPressed: online && !state._launching
? state._launchDesktop
: null,
icon: state._launching
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.open_in_full),
label: Text(l10n.text('Connect desktop')),
),
OutlinedButton.icon(
onPressed: online && !state._terminalLaunching
? state._launchTerminal
: null,
icon: const Icon(Icons.terminal),
label: Text(l10n.text('Open terminal')),
),
OutlinedButton.icon(
onPressed: online && !state._probing
? state._probeAgent
: null,
icon: state._probing
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.network_ping),
label: Text(l10n.text('Test connection')),
),
TextButton.icon(
onPressed: online && !state._savingHost
? state._saveHost
: null,
icon: const Icon(Icons.save_outlined),
label: Text(l10n.text('Save')),
),
],
),
if (state._probe != null) ...[
const Divider(height: 30),
_ProbeSummary(probe: state._probe!),
],
],
),
),
const SizedBox(height: 16),
_RendererControls(state: state, compact: true),
const SizedBox(height: 20),
],
),
);
}
}
class _ProbeSummary extends StatelessWidget {
const _ProbeSummary({required this.probe});
final AgentProbe probe;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Wrap(
spacing: 18,
runSpacing: 8,
children: [
_StatusMetric(
icon: Icons.speed,
label: l10n.format('Agent responded in {ms} ms', {
'ms': probe.latencyMs,
}),
),
_StatusMetric(
icon: Icons.hub_outlined,
label: l10n.format('Protocol {major}.{minor}', {
'major': probe.protocolMajor,
'minor': probe.protocolMinor,
}),
),
_StatusMetric(
icon: Icons.desktop_windows_outlined,
label: l10n.text(probe.desktop ? 'Desktop ready' : 'No desktop'),
),
_StatusMetric(
icon: Icons.terminal,
label: l10n.text(probe.terminal ? 'Terminal ready' : 'No terminal'),
),
_StatusMetric(
icon: Icons.folder_outlined,
label: l10n.text(probe.files ? 'Files ready' : 'No files'),
),
],
);
}
}
class _SessionsPage extends StatelessWidget {
const _SessionsPage({
required this.sessions,
required this.onRefresh,
required this.onOpenDevices,
});
final List<_SessionRecord> sessions;
final Future<void> Function() onRefresh;
final VoidCallback onOpenDevices;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
if (sessions.isEmpty) {
return _Surface(
child: _EmptyState(
icon: Icons.monitor_heart_outlined,
title: l10n.text('No sessions yet'),
actionLabel: l10n.text('Open devices'),
onAction: onOpenDevices,
),
);
}
return _Surface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
l10n.text('Native sessions'),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
IconButton(
tooltip: l10n.text('Refresh session diagnostics'),
onPressed: onRefresh,
icon: const Icon(Icons.refresh),
),
],
),
const SizedBox(height: 8),
Expanded(
child: ListView.separated(
itemCount: sessions.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) =>
_SessionRow(session: sessions[index]),
),
),
],
),
);
}
}
class _SessionRow extends StatelessWidget {
const _SessionRow({required this.session});
final _SessionRecord session;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final data = session.diagnostics;
final state = data?.state ?? 'starting';
final active =
state == 'connected' ||
state == 'connecting' ||
state == 'reconnecting';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(
color: active ? const Color(0xFFDDF1EA) : const Color(0xFFF1E4E1),
borderRadius: const BorderRadius.all(Radius.circular(6)),
),
child: Icon(
active
? Icons.desktop_windows_outlined
: Icons.desktop_access_disabled_outlined,
size: 20,
color: active ? const Color(0xFF006D77) : const Color(0xFF8F3425),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
session.hostName,
style: const TextStyle(fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
_StateBadge(label: state),
],
),
const SizedBox(height: 5),
Text(
data == null
? l10n.text('Waiting for diagnostics')
: '${data.width}x${data.height} | ${_metric(data.framesPerSecond, 'FPS')} | ${_metric(data.networkLatencyMs, 'ms RTT')}',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 3),
Text(
'${l10n.text('Session')} ${session.id}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: const Color(0xFF687A78),
),
),
],
),
),
if (data != null && MediaQuery.sizeOf(context).width >= 700)
Wrap(
spacing: 18,
children: [
_CompactMetric(
label: l10n.text('Decode'),
value: _metric(data.decodeLatencyMs, 'ms'),
),
_CompactMetric(
label: l10n.text('Present'),
value: _metric(data.presentationLatencyMs, 'ms'),
),
_CompactMetric(
label: l10n.text('Frames'),
value: '${data.frameCount}',
),
],
),
],
),
);
}
}
class _SettingsPage extends StatelessWidget {
const _SettingsPage({required this.state});
final _ControlWorkspaceState state;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return SingleChildScrollView(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 820),
child: Column(
children: [
_Surface(
child: LayoutBuilder(
builder: (context, constraints) {
final languageField = DropdownButtonFormField<String>(
key: ValueKey(
state.widget.locale?.languageCode ?? 'system',
),
initialValue: state.widget.locale == null
? 'system'
: state.widget.locale!.languageCode,
isExpanded: true,
decoration: const InputDecoration(isDense: true),
items: [
DropdownMenuItem(
value: 'system',
child: Text(l10n.text('System default')),
),
DropdownMenuItem(
value: 'en',
child: Text(l10n.text('English')),
),
DropdownMenuItem(
value: 'zh',
child: Text(l10n.text('简体中文')),
),
],
onChanged: (value) {
state.widget.onLocaleChanged(
value == 'zh'
? const Locale('zh', 'Hans')
: value == 'en'
? const Locale('en')
: null,
);
},
);
final title = Text(
l10n.text('Language'),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
);
if (constraints.maxWidth < 430) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
title,
const SizedBox(height: 12),
SizedBox(
width: constraints.maxWidth,
child: languageField,
),
],
);
}
return Row(
children: [
Expanded(child: title),
SizedBox(width: 210, child: languageField),
],
);
},
),
),
const SizedBox(height: 16),
_Surface(
child: SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.move_to_inbox_outlined),
title: Text(l10n.text('Close to system tray')),
subtitle: Text(
l10n.text(
'Keep RemoteDesk running when the window is closed.',
),
),
value: state.widget.settings.closeToTray,
onChanged: (value) => state.widget.onSettingsChanged(
state.widget.settings.copyWith(closeToTray: value),
),
),
),
const SizedBox(height: 16),
_RendererControls(state: state, compact: false),
const SizedBox(height: 16),
_Surface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.text('Local service'),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.lan_outlined),
title: const Text('127.0.0.1:4173'),
subtitle: Text(l10n.text(state._serviceMessage)),
trailing: IconButton(
tooltip: l10n.text('Reconnect service'),
onPressed: state._initialize,
icon: const Icon(Icons.refresh),
),
),
],
),
),
],
),
),
);
}
}
class _RendererControls extends StatelessWidget {
const _RendererControls({required this.state, required this.compact});
final _ControlWorkspaceState state;
final bool compact;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return _Surface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.text('Session defaults'),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 14),
LayoutBuilder(
builder: (context, constraints) {
final horizontal = !compact && constraints.maxWidth >= 640;
final display = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<String>(
key: ValueKey(state._resolution),
initialValue: state._resolution,
decoration: InputDecoration(
labelText: l10n.text('Stream resolution'),
),
items: const ['1280x720', '1920x1080', '2560x1440']
.map(
(value) => DropdownMenuItem(
value: value,
child: Text(value.replaceFirst('x', ' x ')),
),
)
.toList(),
onChanged: (value) => state._updateSessionDefaults(
() => state._resolution = value ?? '1920x1080',
),
),
const SizedBox(height: 14),
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 15, label: Text('15 FPS')),
ButtonSegment(value: 30, label: Text('30 FPS')),
],
selected: {state._framesPerSecond},
onSelectionChanged: (value) => state._updateSessionDefaults(
() => state._framesPerSecond = value.first,
),
),
],
);
final toggles = Column(
children: [
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: Text(l10n.text('Open fullscreen')),
value: state._fullscreen,
onChanged: (value) => state._updateSessionDefaults(
() => state._fullscreen = value,
),
),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: Text(l10n.text('Follow window size')),
value: state._followWindow,
onChanged: (value) => state._updateSessionDefaults(
() => state._followWindow = value,
),
),
],
);
if (!horizontal) {
return Column(
children: [display, const Divider(height: 28), toggles],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: display),
const SizedBox(width: 28),
Expanded(child: toggles),
],
);
},
),
const Divider(height: 28),
Wrap(
spacing: 20,
runSpacing: 4,
children: [
_PermissionToggle(
label: l10n.text('Remote input'),
value: state._captureInput,
onChanged: (value) => state._updateSessionDefaults(
() => state._captureInput = value,
),
),
_PermissionToggle(
label: l10n.text('Read clipboard'),
value: state._clipboardRead,
onChanged: (value) => state._updateSessionDefaults(
() => state._clipboardRead = value,
),
),
_PermissionToggle(
label: l10n.text('Write clipboard'),
value: state._clipboardWrite,
onChanged: (value) => state._updateSessionDefaults(
() => state._clipboardWrite = value,
),
),
],
),
],
),
);
}
}
class _PermissionToggle extends StatelessWidget {
const _PermissionToggle({
required this.label,
required this.value,
required this.onChanged,
});
final String label;
final bool value;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 190,
child: CheckboxListTile(
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: Text(label),
value: value,
onChanged: (value) => onChanged(value ?? false),
),
);
}
}
class _Surface extends StatelessWidget {
const _Surface({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border.all(color: const Color(0xFFD4DEDD)),
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
child: child,
);
}
}
class _HostField extends StatelessWidget {
const _HostField({
required this.controller,
required this.label,
required this.validator,
this.hint,
});
final TextEditingController controller;
final String label;
final String? hint;
final FormFieldValidator<String> validator;
@override
Widget build(BuildContext context) => TextFormField(
controller: controller,
decoration: InputDecoration(labelText: label, hintText: hint),
validator: validator,
);
}
class _StatusMetric extends StatelessWidget {
const _StatusMetric({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 17, color: const Color(0xFF006D77)),
const SizedBox(width: 6),
Text(label),
],
);
}
class _CompactMetric extends StatelessWidget {
const _CompactMetric({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(label, style: Theme.of(context).textTheme.labelSmall),
Text(value, style: const TextStyle(fontWeight: FontWeight.w600)),
],
);
}
class _StateBadge extends StatelessWidget {
const _StateBadge({required this.label});
final String label;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: const BoxDecoration(
color: Color(0xFFE2EFEC),
borderRadius: BorderRadius.all(Radius.circular(4)),
),
child: Text(
AppLocalizations.of(context).text(label),
style: Theme.of(context).textTheme.labelSmall,
),
);
}
class _EmptyState extends StatelessWidget {
const _EmptyState({
required this.icon,
required this.title,
required this.actionLabel,
this.onAction,
});
final IconData icon;
final String title;
final String actionLabel;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: 34),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 34, color: const Color(0xFF7B908E)),
const SizedBox(height: 10),
Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
if (onAction != null) ...[
const SizedBox(height: 10),
TextButton(onPressed: onAction, child: Text(actionLabel)),
],
],
),
),
);
}
FormFieldValidator<String> _required(String message) =>
(value) => value == null || value.trim().isEmpty ? message : null;
String _pageLabel(_WorkspacePage page) => switch (page) {
_WorkspacePage.devices => 'Devices',
_WorkspacePage.sessions => 'Sessions',
_WorkspacePage.settings => 'Settings',
};
IconData _pageIcon(_WorkspacePage page) => switch (page) {
_WorkspacePage.devices => Icons.computer_outlined,
_WorkspacePage.sessions => Icons.monitor_heart_outlined,
_WorkspacePage.settings => Icons.tune_outlined,
};
String _metric(double? value, String suffix) =>
value == null ? '--' : '${value.toStringAsFixed(1)} $suffix';