import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/common/shared_state.dart'; import 'package:flutter_hbb/common/widgets/dialog.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/models/model.dart'; import 'package:flutter_hbb/models/platform_model.dart'; import 'package:get/get.dart'; bool isEditOsPassword = false; class TTextMenu { final Widget child; final VoidCallback onPressed; Widget? trailingIcon; bool divider; TTextMenu( {required this.child, required this.onPressed, this.trailingIcon, this.divider = false}); Widget getChild() { if (trailingIcon != null) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ child, trailingIcon!, ], ); } else { return child; } } } class TRadioMenu { final Widget child; final T value; final T groupValue; final ValueChanged? onChanged; TRadioMenu( {required this.child, required this.value, required this.groupValue, required this.onChanged}); } class TToggleMenu { final Widget child; final bool value; final ValueChanged? onChanged; TToggleMenu( {required this.child, required this.value, required this.onChanged}); } handleOsPasswordEditIcon( SessionID sessionId, OverlayDialogManager dialogManager) { isEditOsPassword = true; showSetOSPassword( sessionId, false, dialogManager, null, () => isEditOsPassword = false); } handleOsPasswordAction( SessionID sessionId, OverlayDialogManager dialogManager) async { if (isEditOsPassword) { isEditOsPassword = false; return; } final password = await bind.sessionGetOption(sessionId: sessionId, arg: 'os-password') ?? ''; if (password.isEmpty) { showSetOSPassword(sessionId, true, dialogManager, password, () => isEditOsPassword = false); } else { bind.sessionInputOsPassword(sessionId: sessionId, value: password); } } List toolbarControls(BuildContext context, String id, FFI ffi) { final ffiModel = ffi.ffiModel; final pi = ffiModel.pi; final perms = ffiModel.permissions; final sessionId = ffi.sessionId; List v = []; // elevation if (perms['keyboard'] != false && ffi.elevationModel.showRequestMenu) { v.add( TTextMenu( child: Text(translate('Request Elevation')), onPressed: () => showRequestElevationDialog(sessionId, ffi.dialogManager)), ); } // osAccount / osPassword if (perms['keyboard'] != false) { v.add( TTextMenu( child: Row(children: [ Text(translate(pi.isHeadless ? 'OS Account' : 'OS Password')), ]), trailingIcon: Transform.scale( scale: (isDesktop || isWebDesktop) ? 0.8 : 1, child: IconButton( onPressed: () { if (isMobile && Navigator.canPop(context)) { Navigator.pop(context); } if (pi.isHeadless) { showSetOSAccount(sessionId, ffi.dialogManager); } else { handleOsPasswordEditIcon(sessionId, ffi.dialogManager); } }, icon: Icon(Icons.edit, color: isMobile ? MyTheme.accent : null), ), ), onPressed: () => pi.isHeadless ? showSetOSAccount(sessionId, ffi.dialogManager) : handleOsPasswordAction(sessionId, ffi.dialogManager), ), ); } // paste if (isMobile && pi.platform != kPeerPlatformAndroid && perms['keyboard'] != false && perms['clipboard'] != false) { v.add(TTextMenu( child: Text(translate('Paste')), onPressed: () async { ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain); if (data != null && data.text != null) { bind.sessionInputString( sessionId: sessionId, value: data.text ?? ""); } })); } // reset canvas if (isMobile) { v.add(TTextMenu( child: Text(translate('Reset canvas')), onPressed: () => ffi.cursorModel.reset())); } // transferFile if (isDesktop) { v.add( TTextMenu( child: Text(translate('Transfer file')), onPressed: () => connect(context, id, isFileTransfer: true)), ); } // tcpTunneling if (isDesktop) { v.add( TTextMenu( child: Text(translate('TCP tunneling')), onPressed: () => connect(context, id, isTcpTunneling: true)), ); } // note if (bind .sessionGetAuditServerSync(sessionId: sessionId, typ: "conn") .isNotEmpty) { v.add( TTextMenu( child: Text(translate('Note')), onPressed: () => showAuditDialog(ffi)), ); } // divider if (isDesktop || isWebDesktop) { v.add(TTextMenu(child: Offstage(), onPressed: () {}, divider: true)); } // ctrlAltDel if (!ffiModel.viewOnly && ffiModel.keyboard && (pi.platform == kPeerPlatformLinux || pi.sasEnabled)) { v.add( TTextMenu( child: Text('${translate("Insert")} Ctrl + Alt + Del'), onPressed: () => bind.sessionCtrlAltDel(sessionId: sessionId)), ); } // restart if (perms['restart'] != false && (pi.platform == kPeerPlatformLinux || pi.platform == kPeerPlatformWindows || pi.platform == kPeerPlatformMacOS)) { v.add( TTextMenu( child: Text(translate('Restart remote device')), onPressed: () => showRestartRemoteDevice(pi, id, sessionId, ffi.dialogManager)), ); } // insertLock if (!ffiModel.viewOnly && ffi.ffiModel.keyboard) { v.add( TTextMenu( child: Text(translate('Insert Lock')), onPressed: () => bind.sessionLockScreen(sessionId: sessionId)), ); } // blockUserInput if (ffi.ffiModel.keyboard && ffi.ffiModel.permissions['block_input'] != false && pi.platform == kPeerPlatformWindows) // privacy-mode != true ?? { v.add(TTextMenu( child: Obx(() => Text(translate( '${BlockInputState.find(id).value ? 'Unb' : 'B'}lock user input'))), onPressed: () { RxBool blockInput = BlockInputState.find(id); bind.sessionToggleOption( sessionId: sessionId, value: '${blockInput.value ? 'un' : ''}block-input'); blockInput.value = !blockInput.value; })); } // switchSides if (isDesktop && ffiModel.keyboard && pi.platform != kPeerPlatformAndroid && pi.platform != kPeerPlatformMacOS && versionCmp(pi.version, '1.2.0') >= 0 && bind.peerGetDefaultSessionsCount(id: id) == 1) { v.add(TTextMenu( child: Text(translate('Switch Sides')), onPressed: () => showConfirmSwitchSidesDialog(sessionId, id, ffi.dialogManager))); } // refresh if (pi.version.isNotEmpty) { v.add(TTextMenu( child: Text(translate('Refresh')), onPressed: () => sessionRefreshVideo(sessionId, pi), )); } // record if (!(isDesktop || isWeb) && (ffi.recordingModel.start || (perms["recording"] != false))) { v.add(TTextMenu( child: Row( children: [ Text(translate(ffi.recordingModel.start ? 'Stop session recording' : 'Start session recording')), Padding( padding: EdgeInsets.only(left: 12), child: Icon( ffi.recordingModel.start ? Icons.pause_circle_filled : Icons.videocam_outlined, color: MyTheme.accent), ) ], ), onPressed: () => ffi.recordingModel.toggle())); } // fingerprint if (!(isDesktop || isWebDesktop)) { v.add(TTextMenu( child: Text(translate('Copy Fingerprint')), onPressed: () => onCopyFingerprint(FingerprintState.find(id).value), )); } return v; } Future>> toolbarViewStyle( BuildContext context, String id, FFI ffi) async { final groupValue = await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? ''; void onChanged(String? value) async { if (value == null) return; bind .sessionSetViewStyle(sessionId: ffi.sessionId, value: value) .then((_) => ffi.canvasModel.updateViewStyle()); } return [ TRadioMenu( child: Text(translate('Scale original')), value: kRemoteViewStyleOriginal, groupValue: groupValue, onChanged: onChanged), TRadioMenu( child: Text(translate('Scale adaptive')), value: kRemoteViewStyleAdaptive, groupValue: groupValue, onChanged: onChanged) ]; } Future>> toolbarImageQuality( BuildContext context, String id, FFI ffi) async { final groupValue = await bind.sessionGetImageQuality(sessionId: ffi.sessionId) ?? ''; onChanged(String? value) async { if (value == null) return; await bind.sessionSetImageQuality(sessionId: ffi.sessionId, value: value); } return [ TRadioMenu( child: Text(translate('Good image quality')), value: kRemoteImageQualityBest, groupValue: groupValue, onChanged: onChanged), TRadioMenu( child: Text(translate('Balanced')), value: kRemoteImageQualityBalanced, groupValue: groupValue, onChanged: onChanged), TRadioMenu( child: Text(translate('Optimize reaction time')), value: kRemoteImageQualityLow, groupValue: groupValue, onChanged: onChanged), TRadioMenu( child: Text(translate('Custom')), value: kRemoteImageQualityCustom, groupValue: groupValue, onChanged: (value) { onChanged(value); customImageQualityDialog(ffi.sessionId, id, ffi); }, ), ]; } Future>> toolbarCodec( BuildContext context, String id, FFI ffi) async { final sessionId = ffi.sessionId; final alternativeCodecs = await bind.sessionAlternativeCodecs(sessionId: sessionId); final groupValue = await bind.sessionGetOption( sessionId: sessionId, arg: kOptionCodecPreference) ?? ''; final List codecs = []; try { final Map codecsJson = jsonDecode(alternativeCodecs); final vp8 = codecsJson['vp8'] ?? false; final av1 = codecsJson['av1'] ?? false; final h264 = codecsJson['h264'] ?? false; final h265 = codecsJson['h265'] ?? false; codecs.add(vp8); codecs.add(av1); codecs.add(h264); codecs.add(h265); } catch (e) { debugPrint("Show Codec Preference err=$e"); } final visible = codecs.length == 4 && (codecs[0] || codecs[1] || codecs[2] || codecs[3]); if (!visible) return []; onChanged(String? value) async { if (value == null) return; await bind.sessionPeerOption( sessionId: sessionId, name: kOptionCodecPreference, value: value); bind.sessionChangePreferCodec(sessionId: sessionId); } TRadioMenu radio(String label, String value, bool enabled) { return TRadioMenu( child: Text(label), value: value, groupValue: groupValue, onChanged: enabled ? onChanged : null); } var autoLabel = translate('Auto'); if (groupValue == 'auto' && ffi.qualityMonitorModel.data.codecFormat != null) { autoLabel = '$autoLabel (${ffi.qualityMonitorModel.data.codecFormat})'; } return [ radio(autoLabel, 'auto', true), if (codecs[0]) radio('VP8', 'vp8', codecs[0]), radio('VP9', 'vp9', true), if (codecs[1]) radio('AV1', 'av1', codecs[1]), if (codecs[2]) radio('H264', 'h264', codecs[2]), if (codecs[3]) radio('H265', 'h265', codecs[3]), ]; } Future> toolbarCursor( BuildContext context, String id, FFI ffi) async { List v = []; final ffiModel = ffi.ffiModel; final pi = ffiModel.pi; final sessionId = ffi.sessionId; // show remote cursor if (pi.platform != kPeerPlatformAndroid && !ffi.canvasModel.cursorEmbedded && !pi.isWayland) { final state = ShowRemoteCursorState.find(id); final lockState = ShowRemoteCursorLockState.find(id); final enabled = !ffiModel.viewOnly; final option = 'show-remote-cursor'; if (pi.currentDisplay == kAllDisplayValue || bind.sessionIsMultiUiSession(sessionId: sessionId)) { lockState.value = false; } v.add(TToggleMenu( child: Text(translate('Show remote cursor')), value: state.value, onChanged: enabled && !lockState.value ? (value) async { if (value == null) return; await bind.sessionToggleOption( sessionId: sessionId, value: option); state.value = bind.sessionGetToggleOptionSync( sessionId: sessionId, arg: option); } : null)); } // follow remote cursor if (pi.platform != kPeerPlatformAndroid && !ffi.canvasModel.cursorEmbedded && !pi.isWayland && versionCmp(pi.version, "1.2.4") >= 0 && pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue && !bind.sessionIsMultiUiSession(sessionId: sessionId)) { final option = 'follow-remote-cursor'; final value = bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option); final showCursorOption = 'show-remote-cursor'; final showCursorState = ShowRemoteCursorState.find(id); final showCursorLockState = ShowRemoteCursorLockState.find(id); final showCursorEnabled = bind.sessionGetToggleOptionSync( sessionId: sessionId, arg: showCursorOption); showCursorLockState.value = value; if (value && !showCursorEnabled) { await bind.sessionToggleOption( sessionId: sessionId, value: showCursorOption); showCursorState.value = bind.sessionGetToggleOptionSync( sessionId: sessionId, arg: showCursorOption); } v.add(TToggleMenu( child: Text(translate('Follow remote cursor')), value: value, onChanged: (value) async { if (value == null) return; await bind.sessionToggleOption(sessionId: sessionId, value: option); value = bind.sessionGetToggleOptionSync( sessionId: sessionId, arg: option); showCursorLockState.value = value; if (!showCursorEnabled) { await bind.sessionToggleOption( sessionId: sessionId, value: showCursorOption); showCursorState.value = bind.sessionGetToggleOptionSync( sessionId: sessionId, arg: showCursorOption); } })); } // follow remote window focus if (pi.platform != kPeerPlatformAndroid && !ffi.canvasModel.cursorEmbedded && !pi.isWayland && versionCmp(pi.version, "1.2.4") >= 0 && pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue && !bind.sessionIsMultiUiSession(sessionId: sessionId)) { final option = 'follow-remote-window'; final value = bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option); v.add(TToggleMenu( child: Text(translate('Follow remote window focus')), value: value, onChanged: (value) async { if (value == null) return; await bind.sessionToggleOption(sessionId: sessionId, value: option); value = bind.sessionGetToggleOptionSync( sessionId: sessionId, arg: option); })); } // zoom cursor final viewStyle = await bind.sessionGetViewStyle(sessionId: sessionId) ?? ''; if (!isMobile && pi.platform != kPeerPlatformAndroid && viewStyle != kRemoteViewStyleOriginal) { final option = 'zoom-cursor'; final peerState = PeerBoolOption.find(id, option); v.add(TToggleMenu( child: Text(translate('Zoom cursor')), value: peerState.value, onChanged: (value) async { if (value == null) return; await bind.sessionToggleOption(sessionId: sessionId, value: option); peerState.value = bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option); }, )); } return v; } Future> toolbarDisplayToggle( BuildContext context, String id, FFI ffi) async { List v = []; final ffiModel = ffi.ffiModel; final pi = ffiModel.pi; final perms = ffiModel.permissions; final sessionId = ffi.sessionId; // show quality monitor final option = 'show-quality-monitor'; v.add(TToggleMenu( value: bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option), onChanged: (value) async { if (value == null) return; await bind.sessionToggleOption(sessionId: sessionId, value: option); ffi.qualityMonitorModel.checkShowQualityMonitor(sessionId); }, child: Text(translate('Show quality monitor')))); // mute if (perms['audio'] != false) { final option = 'disable-audio'; final value = bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option); v.add(TToggleMenu( value: value, onChanged: (value) { if (value == null) return; bind.sessionToggleOption(sessionId: sessionId, value: option); }, child: Text(translate('Mute')))); } // file copy and paste // If the version is less than 1.2.4, file copy and paste is supported on Windows only. final isSupportIfPeer_1_2_3 = versionCmp(pi.version, '1.2.4') < 0 && isWindows && pi.platform == kPeerPlatformWindows; // If the version is 1.2.4 or later, file copy and paste is supported when kPlatformAdditionsHasFileClipboard is set. final isSupportIfPeer_1_2_4 = versionCmp(pi.version, '1.2.4') >= 0 && bind.mainHasFileClipboard() && pi.platformAdditions.containsKey(kPlatformAdditionsHasFileClipboard); if (ffiModel.keyboard && perms['file'] != false && (isSupportIfPeer_1_2_3 || isSupportIfPeer_1_2_4)) { final enabled = !ffiModel.viewOnly; final value = bind.sessionGetToggleOptionSync( sessionId: sessionId, arg: kOptionEnableFileCopyPaste); v.add(TToggleMenu( value: value, onChanged: enabled ? (value) { if (value == null) return; bind.sessionToggleOption( sessionId: sessionId, value: kOptionEnableFileCopyPaste); } : null, child: Text(translate('Enable file copy and paste')))); } // disable clipboard if (ffiModel.keyboard && perms['clipboard'] != false) { final enabled = !ffiModel.viewOnly; final option = 'disable-clipboard'; var value = bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option); if (ffiModel.viewOnly) value = true; v.add(TToggleMenu( value: value, onChanged: enabled ? (value) { if (value == null) return; bind.sessionToggleOption(sessionId: sessionId, value: option); } : null, child: Text(translate('Disable clipboard')))); } // lock after session end if (ffiModel.keyboard && !ffiModel.isPeerAndroid) { final enabled = !ffiModel.viewOnly; final option = 'lock-after-session-end'; final value = bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option); v.add(TToggleMenu( value: value, onChanged: enabled ? (value) { if (value == null) return; bind.sessionToggleOption(sessionId: sessionId, value: option); } : null, child: Text(translate('Lock after session end')))); } if (pi.isSupportMultiDisplay && PrivacyModeState.find(id).isEmpty && pi.displaysCount.value > 1 && bind.mainGetUserDefaultOption(key: kKeyShowMonitorsToolbar) == 'Y') { final value = bind.sessionGetDisplaysAsIndividualWindows(sessionId: ffi.sessionId) == 'Y'; v.add(TToggleMenu( value: value, onChanged: (value) { if (value == null) return; bind.sessionSetDisplaysAsIndividualWindows( sessionId: sessionId, value: value ? 'Y' : 'N'); }, child: Text(translate('Show displays as individual windows')))); } final isMultiScreens = !isWeb && (await getScreenRectList()).length > 1; if (pi.isSupportMultiDisplay && isMultiScreens) { final value = bind.sessionGetUseAllMyDisplaysForTheRemoteSession( sessionId: ffi.sessionId) == 'Y'; v.add(TToggleMenu( value: value, onChanged: (value) { if (value == null) return; bind.sessionSetUseAllMyDisplaysForTheRemoteSession( sessionId: sessionId, value: value ? 'Y' : 'N'); }, child: Text(translate('Use all my displays for the remote session')))); } // 444 final codec_format = ffi.qualityMonitorModel.data.codecFormat; if (versionCmp(pi.version, "1.2.4") >= 0 && (codec_format == "AV1" || codec_format == "VP9")) { final option = 'i444'; final value = bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option); v.add(TToggleMenu( value: value, onChanged: (value) async { if (value == null) return; await bind.sessionToggleOption(sessionId: sessionId, value: option); bind.sessionChangePreferCodec(sessionId: sessionId); }, child: Text(translate('True color (4:4:4)')))); } if (isMobile) { v.addAll(toolbarKeyboardToggles(ffi)); } // view mode (mobile only, desktop is in keyboard menu) if (isMobile && versionCmp(pi.version, '1.2.0') >= 0) { v.add(TToggleMenu( value: ffiModel.viewOnly, onChanged: (value) async { if (value == null) return; await bind.sessionToggleOption( sessionId: ffi.sessionId, value: kOptionToggleViewOnly); ffiModel.setViewOnly(id, value); }, child: Text(translate('View Mode')))); } return v; } var togglePrivacyModeTime = DateTime.now().subtract(const Duration(hours: 1)); List toolbarPrivacyMode( RxString privacyModeState, BuildContext context, String id, FFI ffi) { final ffiModel = ffi.ffiModel; final pi = ffiModel.pi; final sessionId = ffi.sessionId; getDefaultMenu(Future Function(SessionID sid, String opt) toggleFunc) { final enabled = !ffi.ffiModel.viewOnly; return TToggleMenu( value: privacyModeState.isNotEmpty, onChanged: enabled ? (value) { if (value == null) return; if (ffiModel.pi.currentDisplay != 0 && ffiModel.pi.currentDisplay != kAllDisplayValue) { msgBox( sessionId, 'custom-nook-nocancel-hasclose', 'info', 'Please switch to Display 1 first', '', ffi.dialogManager); return; } final option = 'privacy-mode'; toggleFunc(sessionId, option); } : null, child: Text(translate('Privacy mode'))); } final privacyModeImpls = pi.platformAdditions[kPlatformAdditionsSupportedPrivacyModeImpl] as List?; if (privacyModeImpls == null) { return [ getDefaultMenu((sid, opt) async { bind.sessionToggleOption(sessionId: sid, value: opt); togglePrivacyModeTime = DateTime.now(); }) ]; } if (privacyModeImpls.isEmpty) { return []; } if (privacyModeImpls.length == 1) { final implKey = (privacyModeImpls[0] as List)[0] as String; return [ getDefaultMenu((sid, opt) async { bind.sessionTogglePrivacyMode( sessionId: sid, implKey: implKey, on: privacyModeState.isEmpty); togglePrivacyModeTime = DateTime.now(); }) ]; } else { return privacyModeImpls.map((e) { final implKey = (e as List)[0] as String; final implName = (e)[1] as String; return TToggleMenu( child: Text(translate(implName)), value: privacyModeState.value == implKey, onChanged: (value) { if (value == null) return; togglePrivacyModeTime = DateTime.now(); bind.sessionTogglePrivacyMode( sessionId: sessionId, implKey: implKey, on: value); }); }).toList(); } } List toolbarKeyboardToggles(FFI ffi) { final ffiModel = ffi.ffiModel; final pi = ffiModel.pi; final sessionId = ffi.sessionId; List v = []; // swap key if (ffiModel.keyboard && ((isMacOS && pi.platform != kPeerPlatformMacOS) || (!isMacOS && pi.platform == kPeerPlatformMacOS))) { final option = 'allow_swap_key'; final value = bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option); onChanged(bool? value) { if (value == null) return; bind.sessionToggleOption(sessionId: sessionId, value: option); } final enabled = !ffi.ffiModel.viewOnly; v.add(TToggleMenu( value: value, onChanged: enabled ? onChanged : null, child: Text(translate('Swap control-command key')))); } // reverse mouse wheel if (ffiModel.keyboard) { var optionValue = bind.sessionGetReverseMouseWheelSync(sessionId: sessionId) ?? ''; if (optionValue == '') { optionValue = bind.mainGetUserDefaultOption(key: kKeyReverseMouseWheel); } onChanged(bool? value) async { if (value == null) return; await bind.sessionSetReverseMouseWheel( sessionId: sessionId, value: value ? 'Y' : 'N'); } final enabled = !ffi.ffiModel.viewOnly; v.add(TToggleMenu( value: optionValue == 'Y', onChanged: enabled ? onChanged : null, child: Text(translate('Reverse mouse wheel')))); } // swap left right mouse if (ffiModel.keyboard) { final option = 'swap-left-right-mouse'; final value = bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option); onChanged(bool? value) { if (value == null) return; bind.sessionToggleOption(sessionId: sessionId, value: option); } final enabled = !ffi.ffiModel.viewOnly; v.add(TToggleMenu( value: value, onChanged: enabled ? onChanged : null, child: Text(translate('swap-left-right-mouse')))); } return v; }