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_manager/window_manager.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; final RxBool _editHover = false.obs; final GlobalKey _childKey = GlobalKey(); @override Widget build(BuildContext context) { super.build(context); final isIncomingOnly = bind.isIncomingOnly(); return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ buildLeftPane(context), if (!isIncomingOnly) const VerticalDivider(width: 1), if (!isIncomingOnly) Expanded(child: buildRightPane(context)), ], ); } Widget buildLeftPane(BuildContext context) { final isIncomingOnly = bind.isIncomingOnly(); final isOutgoingOnly = bind.isOutgoingOnly(); final children = [ if (!isOutgoingOnly) buildPresetPasswordWarning(), if (bind.isCustomClient()) Align( alignment: Alignment.center, child: loadPowered(context), ), Align( alignment: Alignment.center, child: loadLogo(), ), buildTip(context), if (!isOutgoingOnly) buildIDBoard(context), if (!isOutgoingOnly) buildPasswordBoard(context), FutureBuilder( future: buildHelpCards(), builder: (_, data) { if (data.hasData) { if (isIncomingOnly) { if (isInHomePage()) { Future.delayed(Duration(milliseconds: 300), () { _updateWindowSize(); }); } } return data.data!; } else { return const Offstage(); } }, ), buildPluginEntry(), ]; if (isIncomingOnly) { children.addAll([ Divider(), OnlineStatusWidget( onSvcStatusChanged: () { if (isInHomePage()) { Future.delayed(Duration(milliseconds: 300), () { _updateWindowSize(); }); } }, ).marginOnly(bottom: 6, right: 6) ]); } final textColor = Theme.of(context).textTheme.titleLarge?.color; return ChangeNotifierProvider.value( value: gFFI.serverModel, child: Container( width: isIncomingOnly ? 280.0 : 200.0, color: Theme.of(context).colorScheme.background, child: DesktopScrollWrapper( scrollController: _leftPaneScrollController, child: Stack( children: [ SingleChildScrollView( controller: _leftPaneScrollController, physics: DraggableNeverScrollableScrollPhysics(), child: Column( key: _childKey, children: children, ), ), if (isOutgoingOnly) Positioned( bottom: 6, left: 12, child: Align( alignment: Alignment.centerLeft, child: InkWell( child: Obx( () => Icon( Icons.settings, color: _editHover.value ? textColor : Colors.grey.withOpacity(0.5), size: 22, ), ), onTap: () => { if (DesktopSettingPage.tabKeys.isNotEmpty) { DesktopSettingPage.switch2page( DesktopSettingPage.tabKeys[0]) } }, onHover: (value) => _editHover.value = value, ), ), ) ], ), ), ), ); } 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: Tooltip( message: translate('Settings'), child: Obx( () => CircleAvatar( radius: 15, backgroundColor: hover.value ? Theme.of(context).scaffoldBackgroundColor : Theme.of(context).colorScheme.background, 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: Tooltip( message: translate('Refresh Password'), child: Obx(() => RotatedBox( quarterTurns: 2, child: Icon( Icons.refresh, color: refreshHover.value ? textColor : Color(0xFFDDDDDD), size: 22, ))), ), onHover: (value) => refreshHover.value = value, ).marginOnly(right: 8, top: 4), if (!bind.isDisableSettings()) InkWell( child: Tooltip( message: translate('Change Password'), child: Obx( () => Icon( Icons.edit, color: editHover.value ? textColor : Color(0xFFDDDDDD), size: 22, ).marginOnly(right: 8, top: 4), ), ), onTap: () => DesktopSettingPage.switch2page( SettingsTabKey.safety), onHover: (value) => editHover.value = value, ), ], ), ], ), ), ), ], ), ); } buildTip(BuildContext context) { final isOutgoingOnly = bind.isOutgoingOnly(); 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: [ Column( children: [ if (!isOutgoingOnly) Align( alignment: Alignment.centerLeft, child: Text( translate("Your Desktop"), style: Theme.of(context).textTheme.titleLarge, ), ), ], ), SizedBox( height: 10.0, ), if (!isOutgoingOnly) Text( translate("desk_tip"), overflow: TextOverflow.clip, style: Theme.of(context).textTheme.bodySmall, ), if (isOutgoingOnly) Text( translate("outgoing_only_desk_tip"), overflow: TextOverflow.clip, style: Theme.of(context).textTheme.bodySmall, ), ], ), ); } Future buildHelpCards() async { if (bind.mainGetLocalOption(key: 'hide-help-cards') == 'Y') { return const SizedBox(); } if (!bind.isCustomClient() && updateUrl.isNotEmpty && !isCardClosed && bind.mainUriPrefixSync().contains('rustdesk')) { 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 (isWindows && !bind.isDisableInstallation()) { if (!bind.mainIsInstalled()) { return buildInstallCard( "", bind.isOutgoingOnly() ? "" : "install_tip", "Install", () async { await rustDeskWinManager.closeAllSubWindows(); bind.mainGotoInstall(); }); } else if (bind.mainIsInstalledLowerVersion()) { return buildInstallCard( "Status", "Your installation is lower version.", "Click to upgrade", () async { await rustDeskWinManager.closeAllSubWindows(); bind.mainUpdateMe(); }); } } else if (isMacOS) { if (!(bind.isOutgoingOnly() || 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 (isLinux) { if (bind.isOutgoingOnly()) { return Container(); } final LinuxCards = []; if (bind.isSelinuxEnforcing()) { // Check is SELinux enforcing, but show user a tip of is SELinux enabled for simple. final keyShowSelinuxHelpTip = "show-selinux-help-tip"; if (bind.mainGetLocalOption(key: keyShowSelinuxHelpTip) != 'N') { LinuxCards.add(buildInstallCard( "Warning", "selinux_tip", "", () async {}, marginTop: LinuxCards.isEmpty ? 20.0 : 5.0, help: 'Help', link: 'https://rustdesk.com/docs/en/client/linux/#permissions-issue', closeButton: true, closeOption: keyShowSelinuxHelpTip, )); } } if (bind.mainCurrentIsWayland()) { LinuxCards.add(buildInstallCard( "Warning", "wayland_experiment_tip", "", () async {}, marginTop: LinuxCards.isEmpty ? 20.0 : 5.0, help: 'Help', link: 'https://rustdesk.com/docs/en/client/linux/#x11-required')); } else if (bind.mainIsLoginWayland()) { LinuxCards.add(buildInstallCard("Warning", "Login screen using Wayland is not supported", "", () async {}, marginTop: LinuxCards.isEmpty ? 20.0 : 5.0, help: 'Help', link: 'https://rustdesk.com/docs/en/client/linux/#login-screen')); } if (LinuxCards.isNotEmpty) { return Column( children: LinuxCards, ); } } if (bind.isIncomingOnly()) { return Align( alignment: Alignment.centerRight, child: OutlinedButton( onPressed: () { SystemNavigator.pop(); // Close the application // https://github.com/flutter/flutter/issues/66631 if (isWindows) { exit(0); } }, child: Text(translate('Quit')), ), ).marginAll(14); } return Container(); } Widget buildInstallCard(String title, String content, String btnText, GestureTapCallback onPressed, {double marginTop = 20.0, String? help, String? link, bool? closeButton, String? closeOption}) { void closeCard() async { if (closeOption != null) { await bind.mainSetLocalOption(key: closeOption, value: 'N'); if (bind.mainGetLocalOption(key: closeOption) == 'N') { setState(() { isCardClosed = true; }); } } else { setState(() { isCardClosed = true; }); } } return Stack( children: [ Container( margin: EdgeInsets.fromLTRB( 0, marginTop, 0, bind.isIncomingOnly() ? marginTop : 0), 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)), ] : []) + [ if (content.isNotEmpty) 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(); if (!bind.isCustomClient()) { 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 mainGetBoolOption(kOptionStopService); 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 (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); screenToMap(window_size.Screen screen) => { '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, }; 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(screenToMap(screen)); } } else if (call.method == kWindowGetScreenList) { return jsonEncode( (await window_size.getScreenList()).map(screenToMap).toList()); } 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'], password: call.arguments['password'], 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; final screenRect = parseParamScreenRect(args); await rustDeskWinManager.openMonitorSession( windowId, peerId, display, displayCount, screenRect); } else if (call.method == kWindowEventRemoteWindowCoords) { final windowId = int.tryParse(call.arguments); if (windowId != null) { return jsonEncode( await rustDeskWinManager.getOtherRemoteWindowCoords(windowId)); } } }); _uniLinksSubscription = listenUniLinks(); if (bind.isIncomingOnly()) { WidgetsBinding.instance.addPostFrameCallback((_) { _updateWindowSize(); }); } } _updateWindowSize() { RenderObject? renderObject = _childKey.currentContext?.findRenderObject(); if (renderObject == null) { return; } if (renderObject is RenderBox) { final size = renderObject.size; if (size != imcomingOnlyHomeSize) { imcomingOnlyHomeSize = size; windowManager.setSize(getIncomingOnlyHomeSize()); } } } @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({VoidCallback? notEmptyCallback}) 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); if (pass.isNotEmpty) { notEmptyCallback?.call(); } 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, ); }); }