import 'dart:async'; import 'dart:io'; import 'dart:convert'; import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/common/widgets/animated_rotation_widget.dart'; import 'package:flutter_hbb/common/widgets/custom_password.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/desktop/pages/connection_page.dart'; import 'package:flutter_hbb/desktop/pages/desktop_setting_page.dart'; import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart'; import 'package:flutter_hbb/desktop/widgets/scroll_wrapper.dart'; import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/server_model.dart'; import 'package:flutter_hbb/plugin/ui_manager.dart'; import 'package:flutter_hbb/utils/multi_window_manager.dart'; import 'package:get/get.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:window_size/window_size.dart' as window_size; import '../widgets/button.dart'; class DesktopHomePage extends StatefulWidget { const DesktopHomePage({Key? key}) : super(key: key); @override State createState() => _DesktopHomePageState(); } const borderColor = Color(0xFF2F65BA); class _DesktopHomePageState extends State with AutomaticKeepAliveClientMixin { final _leftPaneScrollController = ScrollController(); @override bool get wantKeepAlive => true; var updateUrl = ''; var systemError = ''; StreamSubscription? _uniLinksSubscription; var svcStopped = false.obs; var watchIsCanScreenRecording = false; var watchIsProcessTrust = false; var watchIsInputMonitoring = false; var watchIsCanRecordAudio = false; Timer? _updateTimer; bool isCardClosed = false; @override Widget build(BuildContext context) { super.build(context); return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ buildLeftPane(context), const VerticalDivider(width: 1), Expanded( child: buildRightPane(context), ), ], ); } Widget buildLeftPane(BuildContext context) { return ChangeNotifierProvider.value( value: gFFI.serverModel, child: Container( width: 200, color: Theme.of(context).colorScheme.background, child: DesktopScrollWrapper( scrollController: _leftPaneScrollController, child: SingleChildScrollView( controller: _leftPaneScrollController, physics: DraggableNeverScrollableScrollPhysics(), child: Column( children: [ buildTip(context), buildIDBoard(context), buildPasswordBoard(context), FutureBuilder( future: buildHelpCards(), builder: (_, data) { if (data.hasData) { return data.data!; } else { return const Offstage(); } }, ), buildPluginEntry() ], ), ), ), ), ); } buildRightPane(BuildContext context) { return Container( color: Theme.of(context).scaffoldBackgroundColor, child: ConnectionPage(), ); } buildIDBoard(BuildContext context) { final model = gFFI.serverModel; return Container( margin: const EdgeInsets.only(left: 20, right: 11), height: 57, child: Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ Container( width: 2, decoration: const BoxDecoration(color: MyTheme.accent), ).marginOnly(top: 5), Expanded( child: Padding( padding: const EdgeInsets.only(left: 7), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( height: 25, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( translate("ID"), style: TextStyle( fontSize: 14, color: Theme.of(context) .textTheme .titleLarge ?.color ?.withOpacity(0.5)), ).marginOnly(top: 5), buildPopupMenu(context) ], ), ), Flexible( child: GestureDetector( onDoubleTap: () { Clipboard.setData( ClipboardData(text: model.serverId.text)); showToast(translate("Copied")); }, child: TextFormField( controller: model.serverId, readOnly: true, decoration: InputDecoration( border: InputBorder.none, contentPadding: EdgeInsets.only(top: 10, bottom: 10), ), style: TextStyle( fontSize: 22, ), ), ), ) ], ), ), ), ], ), ); } Widget buildPopupMenu(BuildContext context) { final textColor = Theme.of(context).textTheme.titleLarge?.color; RxBool hover = false.obs; return InkWell( onTap: DesktopTabPage.onAddSetting, child: Obx( () => CircleAvatar( radius: 15, backgroundColor: hover.value ? Theme.of(context).scaffoldBackgroundColor : Theme.of(context).colorScheme.background, child: Tooltip( message: translate('Settings'), child: Icon( Icons.more_vert_outlined, size: 20, color: hover.value ? textColor : textColor?.withOpacity(0.5), )), ), ), onHover: (value) => hover.value = value, ); } buildPasswordBoard(BuildContext context) { final model = gFFI.serverModel; RxBool refreshHover = false.obs; RxBool editHover = false.obs; final textColor = Theme.of(context).textTheme.titleLarge?.color; return Container( margin: EdgeInsets.only(left: 20.0, right: 16, top: 13, bottom: 13), child: Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ Container( width: 2, height: 52, decoration: BoxDecoration(color: MyTheme.accent), ), Expanded( child: Padding( padding: const EdgeInsets.only(left: 7), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ AutoSizeText( translate("One-time Password"), style: TextStyle( fontSize: 14, color: textColor?.withOpacity(0.5)), maxLines: 1, ), Row( children: [ Expanded( child: GestureDetector( onDoubleTap: () { if (model.verificationMethod != kUsePermanentPassword) { Clipboard.setData( ClipboardData(text: model.serverPasswd.text)); showToast(translate("Copied")); } }, child: TextFormField( controller: model.serverPasswd, readOnly: true, decoration: InputDecoration( border: InputBorder.none, contentPadding: EdgeInsets.only(top: 14, bottom: 10), ), style: TextStyle(fontSize: 15), ), ), ), AnimatedRotationWidget( onPressed: () => bind.mainUpdateTemporaryPassword(), child: Obx(() => RotatedBox( quarterTurns: 2, child: Tooltip( message: translate('Refresh Password'), child: Icon( Icons.refresh, color: refreshHover.value ? textColor : Color(0xFFDDDDDD), size: 22, )))), onHover: (value) => refreshHover.value = value, ).marginOnly(right: 8, top: 4), InkWell( child: Obx( () => Tooltip( message: translate('Change Password'), child: Icon( Icons.edit, color: editHover.value ? textColor : Color(0xFFDDDDDD), size: 22, )).marginOnly(right: 8, top: 4), ), onTap: () => DesktopSettingPage.switch2page(1), onHover: (value) => editHover.value = value, ), ], ), ], ), ), ), ], ), ); } buildTip(BuildContext context) { return Padding( padding: const EdgeInsets.only(left: 20.0, right: 16, top: 16.0, bottom: 5), child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( translate("Your Desktop"), style: Theme.of(context).textTheme.titleLarge, // style: TextStyle( // // color: MyTheme.color(context).text, // fontWeight: FontWeight.normal, // fontSize: 19), ), SizedBox( height: 10.0, ), Text( translate("desk_tip"), overflow: TextOverflow.clip, style: Theme.of(context).textTheme.bodySmall, ) ], ), ); } Future buildHelpCards() async { if (updateUrl.isNotEmpty && !isCardClosed) { return buildInstallCard( "Status", "There is a newer version of ${bind.mainGetAppNameSync()} ${bind.mainGetNewVersion()} available.", "Click to download", () async { final Uri url = Uri.parse('https://rustdesk.com/download'); await launchUrl(url); }, closeButton: true); } if (systemError.isNotEmpty) { return buildInstallCard("", systemError, "", () {}); } if (Platform.isWindows) { if (!bind.mainIsInstalled()) { return buildInstallCard( "", "install_tip", "Install", bind.mainGotoInstall); } else if (bind.mainIsInstalledLowerVersion()) { return buildInstallCard("Status", "Your installation is lower version.", "Click to upgrade", bind.mainUpdateMe); } } else if (Platform.isMacOS) { if (!bind.mainIsCanScreenRecording(prompt: false)) { return buildInstallCard("Permissions", "config_screen", "Configure", () async { bind.mainIsCanScreenRecording(prompt: true); watchIsCanScreenRecording = true; }, help: 'Help', link: translate("doc_mac_permission")); } else if (!bind.mainIsProcessTrusted(prompt: false)) { return buildInstallCard("Permissions", "config_acc", "Configure", () async { bind.mainIsProcessTrusted(prompt: true); watchIsProcessTrust = true; }, help: 'Help', link: translate("doc_mac_permission")); } else if (!bind.mainIsCanInputMonitoring(prompt: false)) { return buildInstallCard("Permissions", "config_input", "Configure", () async { bind.mainIsCanInputMonitoring(prompt: true); watchIsInputMonitoring = true; }, help: 'Help', link: translate("doc_mac_permission")); } else if (!svcStopped.value && bind.mainIsInstalled() && !bind.mainIsInstalledDaemon(prompt: false)) { return buildInstallCard("", "install_daemon_tip", "Install", () async { bind.mainIsInstalledDaemon(prompt: true); }); } //// Disable microphone configuration for macOS. We will request the permission when needed. // else if ((await osxCanRecordAudio() != // PermissionAuthorizeType.authorized)) { // return buildInstallCard("Permissions", "config_microphone", "Configure", // () async { // osxRequestAudio(); // watchIsCanRecordAudio = true; // }); // } } else if (Platform.isLinux) { if (bind.mainCurrentIsWayland()) { return buildInstallCard( "Warning", "wayland_experiment_tip", "", () async {}, help: 'Help', link: 'https://rustdesk.com/docs/en/manual/linux/#x11-required'); } else if (bind.mainIsLoginWayland()) { return buildInstallCard("Warning", "Login screen using Wayland is not supported", "", () async {}, help: 'Help', link: 'https://rustdesk.com/docs/en/manual/linux/#login-screen'); } } return Container(); } Widget buildInstallCard(String title, String content, String btnText, GestureTapCallback onPressed, {String? help, String? link, bool? closeButton}) { void closeCard() { setState(() { isCardClosed = true; }); } return Stack( children: [ Container( margin: EdgeInsets.only(top: 20), child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, colors: [ Color.fromARGB(255, 226, 66, 188), Color.fromARGB(255, 244, 114, 124), ], )), padding: EdgeInsets.all(20), child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: (title.isNotEmpty ? [ Center( child: Text( translate(title), style: TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15), ).marginOnly(bottom: 6)), ] : []) + [ Text( translate(content), style: TextStyle( height: 1.5, color: Colors.white, fontWeight: FontWeight.normal, fontSize: 13), ).marginOnly(bottom: 20) ] + (btnText.isNotEmpty ? [ Row( mainAxisAlignment: MainAxisAlignment.center, children: [ FixedWidthButton( width: 150, padding: 8, isOutline: true, text: translate(btnText), textColor: Colors.white, borderColor: Colors.white, textSize: 20, radius: 10, onTap: onPressed, ) ]) ] : []) + (help != null ? [ Center( child: InkWell( onTap: () async => await launchUrl(Uri.parse(link!)), child: Text( translate(help), style: TextStyle( decoration: TextDecoration.underline, color: Colors.white, fontSize: 12), )).marginOnly(top: 6)), ] : []))), ), if (closeButton != null && closeButton == true) Positioned( top: 18, right: 0, child: IconButton( icon: Icon( Icons.close, color: Colors.white, size: 20, ), onPressed: closeCard, ), ), ], ); } @override void initState() { super.initState(); Timer(const Duration(seconds: 1), () async { updateUrl = await bind.mainGetSoftwareUpdateUrl(); if (updateUrl.isNotEmpty) setState(() {}); }); _updateTimer = periodic_immediate(const Duration(seconds: 1), () async { await gFFI.serverModel.fetchID(); final error = await bind.mainGetError(); if (systemError != error) { systemError = error; setState(() {}); } final v = await bind.mainGetOption(key: "stop-service") == "Y"; if (v != svcStopped.value) { svcStopped.value = v; setState(() {}); } if (watchIsCanScreenRecording) { if (bind.mainIsCanScreenRecording(prompt: false)) { watchIsCanScreenRecording = false; setState(() {}); } } if (watchIsProcessTrust) { if (bind.mainIsProcessTrusted(prompt: false)) { watchIsProcessTrust = false; setState(() {}); } } if (watchIsInputMonitoring) { if (bind.mainIsCanInputMonitoring(prompt: false)) { watchIsInputMonitoring = false; // Do not notify for now. // Monitoring may not take effect until the process is restarted. // rustDeskWinManager.call( // WindowType.RemoteDesktop, kWindowDisableGrabKeyboard, ''); setState(() {}); } } if (watchIsCanRecordAudio) { if (Platform.isMacOS) { Future.microtask(() async { if ((await osxCanRecordAudio() == PermissionAuthorizeType.authorized)) { watchIsCanRecordAudio = false; setState(() {}); } }); } else { watchIsCanRecordAudio = false; setState(() {}); } } }); Get.put(svcStopped, tag: 'stop-service'); rustDeskWinManager.registerActiveWindowListener(onActiveWindowChanged); rustDeskWinManager.setMethodHandler((call, fromWindowId) async { debugPrint( "[Main] call ${call.method} with args ${call.arguments} from window $fromWindowId"); if (call.method == kWindowMainWindowOnTop) { windowOnTop(null); } else if (call.method == kWindowGetWindowInfo) { final screen = (await window_size.getWindowInfo()).screen; if (screen == null) { return ""; } else { return jsonEncode({ 'frame': { 'l': screen.frame.left, 't': screen.frame.top, 'r': screen.frame.right, 'b': screen.frame.bottom, }, 'visibleFrame': { 'l': screen.visibleFrame.left, 't': screen.visibleFrame.top, 'r': screen.visibleFrame.right, 'b': screen.visibleFrame.bottom, }, 'scaleFactor': screen.scaleFactor, }); } } else if (call.method == kWindowActionRebuild) { reloadCurrentWindow(); } else if (call.method == kWindowEventShow) { await rustDeskWinManager.registerActiveWindow(call.arguments["id"]); } else if (call.method == kWindowEventHide) { await rustDeskWinManager.unregisterActiveWindow(call.arguments['id']); } else if (call.method == kWindowConnect) { await connectMainDesktop( call.arguments['id'], isFileTransfer: call.arguments['isFileTransfer'], isTcpTunneling: call.arguments['isTcpTunneling'], isRDP: call.arguments['isRDP'], forceRelay: call.arguments['forceRelay'], ); } else if (call.method == kWindowEventMoveTabToNewWindow) { final args = call.arguments.split(','); int? windowId; try { windowId = int.parse(args[0]); } catch (e) { debugPrint("Failed to parse window id '${call.arguments}': $e"); } if (windowId != null) { await rustDeskWinManager.moveTabToNewWindow( windowId, args[1], args[2]); } } else if (call.method == kWindowEventOpenMonitorSession) { final args = jsonDecode(call.arguments); final windowId = args['window_id'] as int; final peerId = args['peer_id'] as String; final display = args['display'] as int; final displayCount = args['display_count'] as int; await rustDeskWinManager.openMonitorSession( windowId, peerId, display, displayCount); } }); _uniLinksSubscription = listenUniLinks(); } @override void dispose() { _uniLinksSubscription?.cancel(); Get.delete(tag: 'stop-service'); _updateTimer?.cancel(); super.dispose(); } Widget buildPluginEntry() { final entries = PluginUiManager.instance.entries.entries; return Offstage( offstage: entries.isEmpty, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ...entries.map((entry) { return entry.value; }) ], ), ); } } void setPasswordDialog() async { final pw = await bind.mainGetPermanentPassword(); final p0 = TextEditingController(text: pw); final p1 = TextEditingController(text: pw); var errMsg0 = ""; var errMsg1 = ""; final RxString rxPass = pw.trim().obs; final rules = [ DigitValidationRule(), UppercaseValidationRule(), LowercaseValidationRule(), // SpecialCharacterValidationRule(), MinCharactersValidationRule(8), ]; gFFI.dialogManager.show((setState, close, context) { submit() { setState(() { errMsg0 = ""; errMsg1 = ""; }); final pass = p0.text.trim(); if (pass.isNotEmpty) { final Iterable violations = rules.where((r) => !r.validate(pass)); if (violations.isNotEmpty) { setState(() { errMsg0 = '${translate('Prompt')}: ${violations.map((r) => r.name).join(', ')}'; }); return; } } if (p1.text.trim() != pass) { setState(() { errMsg1 = '${translate('Prompt')}: ${translate("The confirmation is not identical.")}'; }); return; } bind.mainSetPermanentPassword(password: pass); close(); } return CustomAlertDialog( title: Text(translate("Set Password")), content: ConstrainedBox( constraints: const BoxConstraints(minWidth: 500), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox( height: 8.0, ), Row( children: [ Expanded( child: TextField( obscureText: true, decoration: InputDecoration( labelText: translate('Password'), errorText: errMsg0.isNotEmpty ? errMsg0 : null), controller: p0, autofocus: true, onChanged: (value) { rxPass.value = value.trim(); setState(() { errMsg0 = ''; }); }, ), ), ], ), Row( children: [ Expanded(child: PasswordStrengthIndicator(password: rxPass)), ], ).marginSymmetric(vertical: 8), const SizedBox( height: 8.0, ), Row( children: [ Expanded( child: TextField( obscureText: true, decoration: InputDecoration( labelText: translate('Confirmation'), errorText: errMsg1.isNotEmpty ? errMsg1 : null), controller: p1, onChanged: (value) { setState(() { errMsg1 = ''; }); }, ), ), ], ), const SizedBox( height: 8.0, ), Obx(() => Wrap( runSpacing: 8, spacing: 4, children: rules.map((e) { var checked = e.validate(rxPass.value.trim()); return Chip( label: Text( e.name, style: TextStyle( color: checked ? const Color(0xFF0A9471) : Color.fromARGB(255, 198, 86, 157)), ), backgroundColor: checked ? const Color(0xFFD0F7ED) : Color.fromARGB(255, 247, 205, 232)); }).toList(), )) ], ), ), actions: [ dialogButton("Cancel", onPressed: close, isOutline: true), dialogButton("OK", onPressed: submit), ], onSubmit: submit, onCancel: close, ); }); }