diff --git a/Cargo.lock b/Cargo.lock index 57177174d..78ff9eb46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -771,6 +771,26 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.9.1", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "log", + "prettyplease", + "proc-macro2 1.0.93", + "quote 1.0.36", + "regex", + "rustc-hash 2.1.1", + "shlex", + "syn 2.0.98", +] + [[package]] name = "bit_field" version = "0.10.2" @@ -2329,7 +2349,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" dependencies = [ - "libloading 0.7.4", + "libloading 0.8.4", ] [[package]] @@ -2694,7 +2714,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4494,7 +4514,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d" dependencies = [ "cfg-if 1.0.0", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -6920,7 +6940,7 @@ dependencies = [ [[package]] name = "rdev" version = "0.5.0-2" -source = "git+https://github.com/rustdesk-org/rdev#871bf1c856d6a30af2f56ab8848396a025140855" +source = "git+https://github.com/rustdesk-org/rdev#23e24dd6b35452a495dae0ae6d99395e9755ab0f" dependencies = [ "cocoa 0.24.1", "core-foundation 0.9.4", @@ -7434,7 +7454,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7491,7 +7511,7 @@ dependencies = [ "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7578,7 +7598,7 @@ name = "scrap" version = "0.5.0" dependencies = [ "android_logger", - "bindgen 0.65.1", + "bindgen 0.72.1", "block", "cfg-if 1.0.0", "dbus", diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart index 83638000b..0e4c5b7a5 100644 --- a/flutter/lib/common/widgets/toolbar.dart +++ b/flutter/lib/common/widgets/toolbar.dart @@ -583,6 +583,7 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { } // record if (!(isDesktop || isWeb) && + bind.mainGetLocalOption(key: kOptionHideRecordingButton) != 'Y' && (ffi.recordingModel.start || (perms["recording"] != false))) { v.add(TTextMenu( child: Row( diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 7c7f47995..6c22057f9 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -105,6 +105,7 @@ const String kOptionAutoDisconnectTimeout = "auto-disconnect-timeout"; const String kOptionEnableHwcodec = "enable-hwcodec"; const String kOptionAllowAutoRecordIncoming = "allow-auto-record-incoming"; const String kOptionAllowAutoRecordOutgoing = "allow-auto-record-outgoing"; +const String kOptionHideRecordingButton = "hide-recording-button"; const String kOptionVideoSaveDirectory = "video-save-directory"; const String kOptionAccessMode = "access-mode"; const String kOptionEnableKeyboard = "enable-keyboard"; @@ -178,6 +179,7 @@ const String kOptionAllowAskForNoteAtEndOfConnection = "allow-ask-for-note"; const String kOptionAllowMonitorSwitchMainToolbar = "allow-monitor-switch-main-toolbar"; const String kOptionAllowMonitorSwitchMinToolbar = "allow-monitor-switch-min-toolbar"; const String kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys"; +const String kOptionShowTerminalCtrlKeys = "show-terminal-extra-ctrl-keys"; // network options const String kOptionAllowWebSocket = "allow-websocket"; diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index 2ba77b9b4..b2aab1cfb 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -485,7 +485,8 @@ class _GeneralState extends State<_General> { Widget other() { final incomingOnly = bind.isIncomingOnly(); final outgoingOnly = bind.isOutgoingOnly(); - final showAutoUpdate = isWindows && bind.mainIsInstalled(); + final showAutoUpdate = (isWindows && bind.mainIsInstalled()) || + (isMacOS && bind.mainIsInstalled() && bind.mainIsInstalledDaemon(prompt: false) && !bind.isCustomClient()); final children = [ if (!isWeb && !incomingOnly) _OptionCheckBox(context, 'Confirm before closing multiple tabs', diff --git a/flutter/lib/desktop/pages/macos_full_screen_focus_recovery.dart b/flutter/lib/desktop/pages/macos_full_screen_focus_recovery.dart new file mode 100644 index 000000000..96493c62c --- /dev/null +++ b/flutter/lib/desktop/pages/macos_full_screen_focus_recovery.dart @@ -0,0 +1,24 @@ +class MacOSFullScreenFocusRecovery { + int _generation = 0; + int? _pendingGeneration; + + int? get pendingGeneration => _pendingGeneration; + + int queue() { + _generation += 1; + _pendingGeneration = _generation; + return _generation; + } + + void cancel() { + _pendingGeneration = null; + } + + bool isCurrent(int generation) => _pendingGeneration == generation; + + bool consume(int generation) { + if (!isCurrent(generation)) return false; + _pendingGeneration = null; + return true; + } +} diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index f4669644b..a9185d6a3 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -22,6 +22,7 @@ import '../../utils/image.dart'; import '../widgets/remote_toolbar.dart'; import '../widgets/kb_layout_type_chooser.dart'; import '../widgets/tabbar_widget.dart'; +import 'macos_full_screen_focus_recovery.dart'; import 'package:flutter_hbb/native/custom_cursor.dart' if (dart.library.html) 'package:flutter_hbb/web/custom_cursor.dart'; @@ -64,6 +65,13 @@ class RemotePage extends StatefulWidget { FFI get ffi => (_lastState.value! as _RemotePageState)._ffi; + void releaseMacOSInputForTabTransfer() { + if (!isMacOS) return; + // Release before removing the source tab. Its delayed disposal must not + // disable a native keyboard hook already acquired by the destination page. + (_lastState.value! as _RemotePageState)._releaseMacOSRemoteInput(); + } + @override State createState() { final state = _RemotePageState(id); @@ -76,10 +84,28 @@ class _RemotePageState extends State with AutomaticKeepAliveClientMixin, MultiWindowListener, + WidgetsBindingObserver, TickerProviderStateMixin { Timer? _timer; String keyboardMode = "legacy"; bool _isWindowBlur = false; + // Known macOS remote-input trade-offs (kept simple intentionally): + // 1. Dialogs rely on FocusNode loss plus middleBlocked, not mirrored dialog + // state. Reproduce: activate remote input, open a dialog, then type. + // 2. Delayed fullscreen recovery can race a local-control focus change; no + // owner state is added. Reproduce: focus the toolbar during a Space switch. + // 3. Input-source switching releases native input without updating this + // page's cache. Reproduce: switch sources, then type before and after + // clicking the remote image; the click reasserts input. + // These latches compensate for out-of-order macOS focus events. Treat them + // as coupled when changing a transition or _syncMacOSKeyboardGrab(). + AppLifecycleState? _macOSLifecycleState; + bool _macOSLocalFocusLost = false; + bool _macOSInputActive = false; + bool _macOSInputSuppressed = false; + final _macOSFullScreenFocusRecovery = MacOSFullScreenFocusRecovery(); + bool _macOSExplicitFocusRequestPending = false; + StreamSubscription? _tabStateSubscription; final _cursorOverImage = false.obs; late RxBool _showRemoteCursor; late RxBool _zoomCursor; @@ -122,6 +148,13 @@ class _RemotePageState extends State void initState() { super.initState(); _ffi = FFI(widget.sessionId); + if (isMacOS) { + // SchedulerBinding.instance.lifecycleState is null in the first connection in a new window. + _macOSLifecycleState = SchedulerBinding.instance.lifecycleState; + WidgetsBinding.instance.addObserver(this); + _tabStateSubscription = + widget.tabController?.state.listen(_onMacOSTabStateChanged); + } Get.put(_ffi, tag: widget.id); _ffi.imageModel.addCallbackOnFirstImage((String peerId) { _ffi.canvasModel.activateLocalCursor(); @@ -231,19 +264,224 @@ class _RemotePageState extends State _pointerLockCenterDebounceTimer = null; } + bool get _isSelectedTab { + final controller = widget.tabController; + if (controller == null) return true; + final tabState = controller.state.value; + final selected = tabState.selected; + return selected >= 0 && + selected < tabState.tabs.length && + tabState.tabs[selected].key == widget.id; + } + + bool get _isMacOSKeyboardContextActive { + return stateGlobal.isFocused.value && !_isWindowBlur && _isSelectedTab; + } + + void _onMacOSTabStateChanged(DesktopTabState _) { + if (!_isSelectedTab) { + _macOSFullScreenFocusRecovery.cancel(); + _syncMacOSKeyboardGrab(); + return; + } + // Tab listeners run synchronously. Defer the selected page so the previous + // page releases first; a late leave from it can disable the new session. + scheduleMicrotask(() { + if (mounted) { + _syncMacOSKeyboardGrab(reassert: true); + } + }); + } + + void _releaseMacOSRemoteInput() { + _macOSFullScreenFocusRecovery.cancel(); + _macOSExplicitFocusRequestPending = false; + _macOSInputSuppressed = true; + _macOSLocalFocusLost = true; + _ffi.inputModel.enterOrLeave(false); + _macOSInputActive = false; + _rawKeyFocusNode.unfocus(); + } + + void _onMacOSFocusChange() { + // requestFocus() notifies later; only a recorded explicit request may clear + // the local-focus-loss latch. + if (_rawKeyFocusNode.hasPrimaryFocus) { + final explicitRequest = _macOSExplicitFocusRequestPending; + _macOSExplicitFocusRequestPending = false; + if (explicitRequest && _isMacOSKeyboardContextActive) { + _macOSLocalFocusLost = false; + } + _syncMacOSKeyboardGrab(allowInactiveLifecycle: explicitRequest); + } else { + if (_macOSInputActive) { + _ffi.inputModel.enterOrLeave(false); + _macOSInputActive = false; + } + if (_isMacOSKeyboardContextActive) { + _macOSLocalFocusLost = true; + } + } + } + + // 1. Sync the keyboard grab state with the current context. + // 2. Call enterOrLeave() to update the input state in the FFI layer. + // 3. Request or unfocus the raw key focus node based on the current context. + // Flutter focus and native input are separate; native input activates only + // after the FocusNode has primary focus. + void _syncMacOSKeyboardGrab({ + bool reassert = false, + bool allowInactiveLifecycle = false, + }) { + if (!isMacOS) return; + // A secondary engine may stay hidden while its window is visible, so + // explicit pointer/fullscreen recovery must bypass the global lifecycle. + final lifecycleAllowsInput = allowInactiveLifecycle || + _macOSLifecycleState == null || + _macOSLifecycleState == AppLifecycleState.resumed; + // Input stays pointer-gated except for focused fullscreen recovery, which + // compensates when macOS omits PointerEnter during a Space switch. + final shouldFocus = lifecycleAllowsInput && + _isMacOSKeyboardContextActive && + !_macOSInputSuppressed && + _blockableOverlayState.middleBlocked.isFalse && + _cursorOverImage.value && + !_macOSLocalFocusLost; + final hasFocus = _rawKeyFocusNode.hasPrimaryFocus; + final shouldActivateInput = shouldFocus && hasFocus; + + if (shouldActivateInput != _macOSInputActive || + (shouldActivateInput && reassert)) { + _ffi.inputModel.enterOrLeave(shouldActivateInput); + } + _macOSInputActive = shouldActivateInput; + + if (!shouldFocus) { + _macOSExplicitFocusRequestPending = false; + if (hasFocus) _rawKeyFocusNode.unfocus(); + } else if (!hasFocus) { + _macOSExplicitFocusRequestPending = allowInactiveLifecycle; + _rawKeyFocusNode.requestFocus(); + } else { + _macOSExplicitFocusRequestPending = false; + } + } + + void _restoreMacOSKeyboardAfterFullScreen({ + required int generation, + bool allowHiddenLifecycle = false, + }) { + // Fullscreen callbacks preserve recovery while hidden. Native window focus + // may bypass a stale hidden lifecycle for the newly visible Space. + if (!_macOSFullScreenFocusRecovery.isCurrent(generation) || + (!allowHiddenLifecycle && + _macOSLifecycleState == AppLifecycleState.hidden)) { + return; + } + final contextActive = + stateGlobal.isFocused.value && !_isWindowBlur && _isSelectedTab; + // macOS can focus a fullscreen Space without sending PointerEnter. Native + // window focus is authoritative here; a later blur cancels this generation + // before an off-screen window can restore input. + final shouldInferPointerInside = !_cursorOverImage.value && + allowHiddenLifecycle && + stateGlobal.fullscreen.isTrue && + contextActive; + final canRestore = contextActive && + _blockableOverlayState.middleBlocked.isFalse && + (_cursorOverImage.value || shouldInferPointerInside); + if (!_macOSFullScreenFocusRecovery.consume(generation)) return; + if (!canRestore) { + // Consuming recovery here requires a later pointer/window/tab event. + return; + } + if (shouldInferPointerInside) { + _cursorOverImage.value = true; + } + _macOSLocalFocusLost = false; + stateGlobal.getInputSource(force: true); + _syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true); + } + + void _scheduleMacOSKeyboardAfterFullScreen({ + required int generation, + bool allowHiddenLifecycle = false, + }) { + // Fullscreen can deliver FocusNode loss after its callback; wait for frame + // completion and then advance one event-loop turn before restoring. + WidgetsBinding.instance.addPostFrameCallback((_) { + Timer.run(() { + if (mounted) { + _restoreMacOSKeyboardAfterFullScreen( + generation: generation, + allowHiddenLifecycle: allowHiddenLifecycle, + ); + } + }); + }); + WidgetsBinding.instance.ensureVisualUpdate(); + } + + void _queueMacOSKeyboardAfterFullScreen({ + bool allowHiddenLifecycle = false, + }) { + final generation = _macOSFullScreenFocusRecovery.queue(); + if (_macOSLifecycleState == AppLifecycleState.paused || + _macOSLifecycleState == AppLifecycleState.detached) { + _macOSFullScreenFocusRecovery.cancel(); + return; + } + _scheduleMacOSKeyboardAfterFullScreen( + generation: generation, + allowHiddenLifecycle: allowHiddenLifecycle, + ); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + if (!isMacOS || _macOSLifecycleState == state) return; + _macOSLifecycleState = state; + if (state == AppLifecycleState.resumed) { + _syncMacOSKeyboardGrab(reassert: true); + } else if (_macOSInputActive) { + _ffi.inputModel.enterOrLeave(false); + _macOSInputActive = false; + } + + final generation = _macOSFullScreenFocusRecovery.pendingGeneration; + if (generation == null) return; + if (state == AppLifecycleState.inactive || + state == AppLifecycleState.resumed) { + _scheduleMacOSKeyboardAfterFullScreen(generation: generation); + } else if (state == AppLifecycleState.paused || + state == AppLifecycleState.detached) { + _macOSFullScreenFocusRecovery.cancel(); + } + } + @override void onWindowBlur() { super.onWindowBlur(); // On windows, we use `focus` way to handle keyboard better. // Now on Linux, there's some rdev issues which will break the input. - // We disable the `focus` way for non-Windows temporarily. - if (isWindows) { + // We disable the `focus` way for Linux temporarily. + if (isWindows || isMacOS) { _isWindowBlur = true; + } + if (isMacOS) { + _macOSFullScreenFocusRecovery.cancel(); + // A blur or Space switch may not emit PointerExit, so cursor state alone + // cannot prevent the old remote surface from reclaiming the keyboard. + _macOSLocalFocusLost = true; + } + if (isWindows) { // unfocus the primary-focus when the whole window is lost focus, // and let OS to handle events instead. _rawKeyFocusNode.unfocus(); } stateGlobal.isFocused.value = false; + _syncMacOSKeyboardGrab(); // When window loses focus, temporarily release relative mouse mode constraints // to allow user to interact with other applications normally. @@ -257,16 +495,41 @@ class _RemotePageState extends State void onWindowFocus() { super.onWindowFocus(); // See [onWindowBlur]. - if (isWindows) { + if (isWindows || isMacOS) { _isWindowBlur = false; } + if (isMacOS) stateGlobal.getInputSource(force: true); stateGlobal.isFocused.value = true; + // Normal macOS windows wait for PointerEnter or PointerDown. A focused + // fullscreen Space queues delayed recovery; if this window blurs again, the + // pending recovery is cancelled before native input can reactivate. + // Regression: switch directly between fullscreen remote Spaces without + // moving or clicking; only the newly focused session may receive input. + if (isMacOS && + stateGlobal.fullscreen.isTrue && + !_ffi.inputModel.relativeMouseMode.value) { + // Native window focus is authoritative when a secondary engine retains a + // stale hidden lifecycle state after its fullscreen Space becomes visible. + _queueMacOSKeyboardAfterFullScreen(allowHiddenLifecycle: true); + } + // Restore relative mouse mode constraints when window regains focus. if (_ffi.inputModel.relativeMouseMode.value) { - _rawKeyFocusNode.requestFocus(); + if (isMacOS) { + // Native relative mode retains pointer capture and does not emit + // PointerEnter after window focus returns. Restore both latches unless + // a local overlay still owns input. + if (_blockableOverlayState.middleBlocked.isFalse) { + _cursorOverImage.value = true; + _macOSLocalFocusLost = false; + } + } else { + _rawKeyFocusNode.requestFocus(); + } _ffi.inputModel.onWindowFocus(); } + _syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true); } @override @@ -327,6 +590,13 @@ class _RemotePageState extends State void onWindowMinimize() { super.onWindowMinimize(); WakelockManager.disable(_uniqueKey); + if (isMacOS) { + _macOSFullScreenFocusRecovery.cancel(); + _isWindowBlur = true; + _cursorOverImage.value = false; + stateGlobal.isFocused.value = false; + _syncMacOSKeyboardGrab(); + } // Release cursor constraints when minimized if (_ffi.inputModel.relativeMouseMode.value) { _ffi.inputModel.onWindowBlur(); @@ -338,6 +608,7 @@ class _RemotePageState extends State super.onWindowEnterFullScreen(); if (isMacOS) { stateGlobal.setFullscreen(true); + _queueMacOSKeyboardAfterFullScreen(); } } @@ -346,6 +617,7 @@ class _RemotePageState extends State super.onWindowLeaveFullScreen(); if (isMacOS) { stateGlobal.setFullscreen(false); + _queueMacOSKeyboardAfterFullScreen(); } } @@ -354,6 +626,14 @@ class _RemotePageState extends State final closeSession = closeSessionOnDispose.remove(widget.id) ?? true; // https://github.com/flutter/flutter/issues/64935 + if (isMacOS) { + // Tab moves release before transfer to avoid a late retained-session leave. + if (closeSession) { + _releaseMacOSRemoteInput(); + } + _tabStateSubscription?.cancel(); + WidgetsBinding.instance.removeObserver(this); + } super.dispose(); debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}"); @@ -368,8 +648,9 @@ class _RemotePageState extends State _ffi.inputModel.onRelativeMouseModeDisabled = null; // Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...). _ffi.textureModel.onRemotePageDispose(closeSession); - if (closeSession) { + if (closeSession && !isMacOS) { // ensure we leave this session, this is a double check + // enterOrLeave() is already called previously in _releaseMacOSRemoteInput() for macOS. _ffi.inputModel.enterOrLeave(false); } DesktopMultiWindow.removeListener(this); @@ -444,6 +725,8 @@ class _RemotePageState extends State } else { _ffi.inputModel.enterOrLeave(false); } + } else if (isMacOS) { + _onMacOSFocusChange(); } }, inputModel: _ffi.inputModel, @@ -549,7 +832,11 @@ class _RemotePageState extends State } // See [onWindowBlur]. - if (!isWindows) { + if (isMacOS) { + _macOSLocalFocusLost = false; + stateGlobal.getInputSource(force: true); + _syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true); + } else if (!isWindows) { if (!_rawKeyFocusNode.hasFocus) { _rawKeyFocusNode.requestFocus(); } @@ -575,7 +862,9 @@ class _RemotePageState extends State } // See [onWindowBlur]. - if (!isWindows) { + if (isMacOS) { + _syncMacOSKeyboardGrab(); + } else if (!isWindows) { _ffi.inputModel.enterOrLeave(false); } } @@ -600,17 +889,29 @@ class _RemotePageState extends State onEnter: onEnter, onExit: onExit, onPointerDown: (event) { - // A double check for blur status. + // A double check for blur status on Windows and macOS. // Note: If there's an `onPointerDown` event is triggered, `_isWindowBlur` is expected being false. // Sometimes the system does not send the necessary focus event to flutter. We should manually // handle this inconsistent status by setting `_isWindowBlur` to false. So we can // ensure the grab-key thread is running when our users are clicking the remote canvas. - if (_isWindowBlur) { + if ((isWindows || isMacOS) && _isWindowBlur) { debugPrint( "Unexpected status: onPointerDown is triggered while the remote window is in blur status"); _isWindowBlur = false; } - if (!_rawKeyFocusNode.hasFocus) { + if (isMacOS) { + // Regions without matching enter/exit callbacks cannot safely own + // keyboard state. + if (onEnter == null || onExit == null) return; + if (!stateGlobal.isFocused.value) { + stateGlobal.isFocused.value = true; + } + _cursorOverImage.value = true; + _macOSLocalFocusLost = false; + stateGlobal.getInputSource(force: true); + _syncMacOSKeyboardGrab( + reassert: !isInputSourceFlutter, allowInactiveLifecycle: true); + } else if (!_rawKeyFocusNode.hasFocus) { _rawKeyFocusNode.requestFocus(); } }, diff --git a/flutter/lib/desktop/pages/remote_tab_page.dart b/flutter/lib/desktop/pages/remote_tab_page.dart index ccd5935ce..0b94a4916 100644 --- a/flutter/lib/desktop/pages/remote_tab_page.dart +++ b/flutter/lib/desktop/pages/remote_tab_page.dart @@ -513,15 +513,17 @@ class _ConnectionTabPageState extends State { final args = jsonDecode(call.arguments); final id = args['id']; final close = args['close']; + RemotePage? remotePage; try { - final remotePage = tabController.state.value.tabs + remotePage = tabController.state.value.tabs .firstWhere((tab) => tab.key == id) .page as RemotePage; returnValue = remotePage.ffi.ffiModel.cachedPeerData.toString(); } catch (e) { debugPrint('Failed to get cached session data: $e'); } - if (close && returnValue != null) { + if (close && returnValue != null && remotePage != null) { + remotePage.releaseMacOSInputForTabTransfer(); closeSessionOnDispose[id] = false; tabController.closeBy(id); } diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 75fdbe1f8..2373d016a 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -2484,6 +2484,8 @@ class _KeyboardMenu extends StatelessWidget { ? (v) async { if (v != null) { await stateGlobal.setInputSource(ffi.sessionId, v); + // Release native input; see the macOS trade-offs in RemotePage. + if (isMacOS) ffi.inputModel.enterOrLeave(false); await ffi.ffiModel.checkDesktopKeyboardMode(); await ffi.inputModel.updateKeyboardMode(); } @@ -2740,7 +2742,9 @@ class _RecordMenu extends StatelessWidget { Widget build(BuildContext context) { var ffi = Provider.of(context); var recordingModel = Provider.of(context); - final visible = + final hideRecordingButton = + bind.mainGetLocalOption(key: kOptionHideRecordingButton) == 'Y'; + final visible = !hideRecordingButton && (recordingModel.start || ffi.permissions['recording'] != false); if (!visible) return Offstage(); return _IconMenuButton( diff --git a/flutter/lib/mobile/pages/terminal_page.dart b/flutter/lib/mobile/pages/terminal_page.dart index cbf47a7e9..a4a76f9af 100644 --- a/flutter/lib/mobile/pages/terminal_page.dart +++ b/flutter/lib/mobile/pages/terminal_page.dart @@ -5,8 +5,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/common/widgets/dialog.dart'; +import 'package:flutter_hbb/models/input_modifier_utils.dart'; import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/terminal_model.dart'; +import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:xterm/xterm.dart'; import '../../desktop/pages/terminal_connection_manager.dart'; @@ -42,6 +45,11 @@ class _TerminalPageState extends State final GlobalKey _keyboardKey = GlobalKey(); double _keyboardHeight = 0; late bool _showTerminalExtraKeys; + // Ctrl lock state for virtual keyboard: active key presses are mapped to control codes + bool _ctrlLocked = false; + bool _altLocked = false; + // Row3 expand/collapse state for compact keyboard layout + bool _row3Expanded = false; // For iOS edge swipe gesture double _swipeStartX = 0; double _swipeCurrentX = 0; @@ -94,6 +102,18 @@ class _TerminalPageState extends State // terminal extra keys bar is unnecessary and disabled. _showTerminalExtraKeys = !isWebDesktop && mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys); + _terminalModel.isCtrlLocked = () => _ctrlLocked; + _terminalModel.clearCtrlLock = () { + if (_ctrlLocked) setState(() => _ctrlLocked = false); + }; + _terminalModel.isAltLocked = () => _altLocked; + _terminalModel.clearAltLock = () { + if (_altLocked) setState(() => _altLocked = false); + }; + // Load Row3 expand/collapse state from persistent storage. The raw option + // read keeps Row3 collapsed when no value has been saved yet. + _row3Expanded = + bind.mainGetLocalOption(key: kOptionShowTerminalCtrlKeys) == 'Y'; // Initialize terminal connection WidgetsBinding.instance.addPostFrameCallback((_) { _ffi.dialogManager @@ -148,6 +168,39 @@ class _TerminalPageState extends State return EdgeInsets.only(left: 5.0, right: 5.0, top: topBottom, bottom: topBottom + _sysKeyboardHeight + _keyboardHeight); } + /// Pastes clipboard text through TerminalModel so keyboard-only modifiers and + /// mobile Enter normalization never alter clipboard data. + Future _pasteClipboardText() async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + final text = data?.text; + if (text == null || !mounted) return; + + await _terminalModel.pasteText(text); + if (mounted) { + _terminalModel.terminalController.clearSelection(); + } + } + + KeyEventResult _handleTerminalKeyEvent(FocusNode _, KeyEvent event) { + final hardwareKeyboard = HardwareKeyboard.instance; + final shouldPaste = shouldHandleTerminalPasteShortcut( + logicalKey: event.logicalKey, + isKeyDown: event is KeyDownEvent, + isKeyRepeat: event is KeyRepeatEvent, + controlPressed: hardwareKeyboard.isControlPressed, + metaPressed: hardwareKeyboard.isMetaPressed, + altPressed: hardwareKeyboard.isAltPressed, + shiftPressed: hardwareKeyboard.isShiftPressed, + modifierLockActive: _ctrlLocked || _altLocked, + ); + if (!shouldPaste) return KeyEventResult.ignored; + + // Only locked virtual modifiers need interception. Without a lock, keep + // xterm's default hardware paste behavior, including bracketed paste mode. + unawaited(_pasteClipboardText()); + return KeyEventResult.handled; + } + @override Widget build(BuildContext context) { super.build(context); @@ -185,6 +238,7 @@ class _TerminalPageState extends State // // Android works fine without this workaround. deleteDetection: isIOS, + onKeyEvent: _handleTerminalKeyEvent, padding: _calculatePadding(heightPx), onSecondaryTapDown: (details, offset) async { final selection = _terminalModel.terminalController.selection; @@ -193,11 +247,7 @@ class _TerminalPageState extends State _terminalModel.terminalController.clearSelection(); await Clipboard.setData(ClipboardData(text: text)); } else { - final data = await Clipboard.getData('text/plain'); - final text = data?.text; - if (text != null) { - _terminalModel.terminal.paste(text); - } + await _pasteClipboardText(); } }, ); @@ -324,66 +374,171 @@ class _TerminalPageState extends State mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ + // Row 1 follows the latest reviewed PR layout. + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: _buildKeyboardKeyButtons(terminalKeyboardRow1Keys), + ), + // Row 2 ends with the full-width Row3 collapse/expand toggle. Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - _buildKeyButton('Esc'), - const SizedBox(width: 2), - _buildKeyButton('/'), - const SizedBox(width: 2), - _buildKeyButton('|'), - const SizedBox(width: 2), - _buildKeyButton('Home'), - const SizedBox(width: 2), - _buildKeyButton('↑'), - const SizedBox(width: 2), - _buildKeyButton('End'), - const SizedBox(width: 2), - _buildKeyButton('PgUp'), - ], - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildKeyButton('Tab'), - const SizedBox(width: 2), - _buildKeyButton('Ctrl+C'), - const SizedBox(width: 2), - _buildKeyButton('~'), - const SizedBox(width: 2), - _buildKeyButton('←'), - const SizedBox(width: 2), - _buildKeyButton('↓'), - const SizedBox(width: 2), - _buildKeyButton('→'), - const SizedBox(width: 2), - _buildKeyButton('PgDn'), + ..._buildKeyboardKeyButtons(terminalKeyboardRow2Keys), + const SizedBox(width: terminalKeyboardKeySpacing), + _buildCollapseButton(), ], ), + // Row 3 restores paging keys and trailing alignment placeholders. + if (_row3Expanded) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ..._buildKeyboardKeyButtons(terminalKeyboardRow3Keys), + for (var i = 0; + i < terminalKeyboardRow3TrailingPlaceholderCount; + i++) ...[ + const SizedBox(width: terminalKeyboardKeySpacing), + const SizedBox(width: terminalKeyboardKeyWidth), + ], + ], + ), ], ), ), ); } + // Ctrl toggle button with highlighted locked state + Widget _buildCtrlKeyButton() { + return _buildModifierToggleButton( + text: 'Ctrl', + semanticsLabel: 'Ctrl', + isLocked: _ctrlLocked, + onPressed: () => setState(() => _ctrlLocked = !_ctrlLocked), + ); + } + + // Alt toggle button with highlighted locked state + Widget _buildAltKeyButton() { + return _buildModifierToggleButton( + text: 'Alt', + semanticsLabel: 'Alt', + isLocked: _altLocked, + onPressed: () => setState(() => _altLocked = !_altLocked), + ); + } + + // Collapse/expand toggle button for Row3 + void _toggleRow3Expanded() { + final willExpand = !_row3Expanded; + final shouldClearModifiers = shouldClearTerminalModifiersWhenRow3Collapses( + wasExpanded: _row3Expanded, + willExpand: willExpand, + ctrlLocked: _ctrlLocked, + altLocked: _altLocked, + ); + setState(() { + _row3Expanded = willExpand; + if (shouldClearModifiers) { + _ctrlLocked = false; + _altLocked = false; + } + }); + mainSetLocalBoolOption(kOptionShowTerminalCtrlKeys, willExpand); + + // The floating keyboard height changes after Row3 is inserted/removed. + // Re-measure on the next frame so terminal padding uses the new height. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_showTerminalExtraKeys) return; + setState(() { + _updateKeyboardHeight(); + }); + }); + } + + Widget _buildCollapseButton() { + return Semantics( + label: translate('Show terminal extra keys'), + toggled: _row3Expanded, + child: ElevatedButton( + onPressed: _toggleRow3Expanded, + child: Text(_row3Expanded ? '∧' : '∨'), + style: ElevatedButton.styleFrom( + minimumSize: const Size(terminalKeyboardKeyWidth, 32), + padding: EdgeInsets.zero, + textStyle: const TextStyle(fontSize: 12), + backgroundColor: + Theme.of(context).colorScheme.surfaceContainerHighest, + foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + } + + /// Builds a fixed-width key sequence with the reviewed 2dp spacing. + List _buildKeyboardKeyButtons(List labels) { + return [ + for (var i = 0; i < labels.length; i++) ...[ + _buildKeyButton(labels[i]), + if (i < labels.length - 1) + const SizedBox(width: terminalKeyboardKeySpacing), + ], + ]; + } + + /// Build a modifier toggle button (Ctrl/Alt) with one-shot behavior. + /// When [isLocked] is true, the button highlights in blue and the next + /// single-character input is mapped to its modified equivalent. + Widget _buildModifierToggleButton({ + required String text, + required String semanticsLabel, + required bool isLocked, + required VoidCallback onPressed, + }) { + return Semantics( + // Ctrl and Alt are technical key names and intentionally stay unchanged. + label: semanticsLabel, + toggled: isLocked, + child: ElevatedButton( + onPressed: onPressed, + child: Text(text), + style: ElevatedButton.styleFrom( + minimumSize: const Size(terminalKeyboardKeyWidth, 32), + padding: EdgeInsets.zero, + textStyle: const TextStyle(fontSize: 12), + backgroundColor: isLocked + ? Colors.blue + : Theme.of(context).colorScheme.surfaceContainerHighest, + foregroundColor: isLocked + ? Colors.white + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + } + Widget _buildKeyButton(String label) { + if (label == 'Ctrl') return _buildCtrlKeyButton(); + if (label == 'Alt') return _buildAltKeyButton(); + return ElevatedButton( onPressed: () { _sendKeyToTerminal(label); }, child: Text(label), style: ElevatedButton.styleFrom( - minimumSize: const Size(48, 32), + minimumSize: const Size(terminalKeyboardKeyWidth, 32), padding: EdgeInsets.zero, textStyle: const TextStyle(fontSize: 12), - backgroundColor: Theme.of(context).colorScheme.surfaceVariant, + backgroundColor: + Theme.of(context).colorScheme.surfaceContainerHighest, foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant, ), ); } void _sendKeyToTerminal(String key) { - String? send; + String send; switch (key) { case 'Esc': @@ -427,9 +582,7 @@ class _TerminalPageState extends State break; } - if (send != null) { - _terminalModel.sendVirtualKey(send); - } + _terminalModel.sendVirtualKey(send); } // https://github.com/TerminalStudio/xterm.dart/issues/42#issuecomment-877495472 diff --git a/flutter/lib/mobile/terminal_keyboard_utils.dart b/flutter/lib/mobile/terminal_keyboard_utils.dart new file mode 100644 index 000000000..9248d1d4a --- /dev/null +++ b/flutter/lib/mobile/terminal_keyboard_utils.dart @@ -0,0 +1,20 @@ +/// Reviewed mobile terminal keyboard layout from PR #15532. +/// +/// Keeping the key order outside the widget makes the intended layout explicit +/// and prevents behavior fixes from silently moving keys between rows. +const terminalKeyboardRow1Keys = ['Esc', '/', '|', 'Home', '↑', 'End', r'\']; +const terminalKeyboardRow2Keys = ['Tab', 'Ctrl+C', '~', '←', '↓', '→']; +const terminalKeyboardRow3Keys = ['Ctrl', 'Alt', '-', 'PgUp', 'PgDn']; + +const terminalKeyboardKeyWidth = 48.0; +const terminalKeyboardKeySpacing = 2.0; + +/// Empty 48dp slots keep expanded Row3 aligned with the two rows above it. +const terminalKeyboardRow3TrailingPlaceholderCount = 2; + +/// Returns the fixed width occupied by a row of equally sized key slots. +double terminalKeyboardRowWidth(int slotCount) { + if (slotCount <= 0) return 0; + return slotCount * terminalKeyboardKeyWidth + + (slotCount - 1) * terminalKeyboardKeySpacing; +} diff --git a/flutter/lib/models/input_modifier_utils.dart b/flutter/lib/models/input_modifier_utils.dart index e65c32790..9b8aae881 100644 --- a/flutter/lib/models/input_modifier_utils.dart +++ b/flutter/lib/models/input_modifier_utils.dart @@ -1,4 +1,12 @@ import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +/// Identifies where terminal input originated so paste data can bypass all +/// keyboard-only transformations. +enum TerminalInputSource { + keyboard, + paste, +} /// Returns true when a stale mobile one-shot Shift state should be released /// by replaying a tracked Shift key-down as a synthesized key-up. @@ -36,3 +44,147 @@ bool shouldReleaseStaleMobileShift({ } return true; } + +/// Applies the terminal Ctrl/Alt one-shot modifiers to a single input payload. +/// +String applyTerminalInputModifiers( + String data, { + required bool ctrlLocked, + required bool altLocked, +}) { + var result = data; + if (ctrlLocked) { + result = _applyTerminalCtrlModifier(result); + } + if (altLocked) { + result = '\x1B$result'; + } + return result; +} + +/// Builds the exact payload xterm sends for paste, without applying modifiers. +String terminalPastePayload(String text, {required bool bracketedPasteMode}) { + if (!bracketedPasteMode) { + return text; + } + return '\x1B[200~$text\x1B[201~'; +} + +/// Returns whether one-shot Ctrl/Alt may transform and consume this input. +/// +/// xterm emits terminal control keys as either one control byte or a longer +/// escape sequence. Neither form is ordinary text input, so a pending modifier +/// must survive until the user enters a printable character. +bool shouldApplyTerminalInputModifiers(String data) { + if (data.characters.length != 1) return false; + final codeUnit = data.codeUnitAt(0); + return codeUnit >= 0x20 && codeUnit != 0x7F; +} + +/// Builds the payload sent to the remote terminal for keyboard and paste input. +/// +/// Keyboard input keeps the mobile Enter workaround and one-shot Ctrl/Alt +/// mapping. Paste input deliberately bypasses both transformations so even a +/// one-character clipboard payload is preserved exactly. +String prepareTerminalInputPayload( + String data, { + required TerminalInputSource source, + required bool isMobileOrWebMobile, + required bool bracketedPasteMode, + required bool ctrlLocked, + required bool altLocked, +}) { + if (source == TerminalInputSource.paste) { + return terminalPastePayload( + data, + bracketedPasteMode: bracketedPasteMode, + ); + } + + var result = data; + if (isMobileOrWebMobile && result == '\n') { + result = '\r'; + } + if ((ctrlLocked || altLocked) && shouldApplyTerminalInputModifiers(result)) { + result = applyTerminalInputModifiers( + result, + ctrlLocked: ctrlLocked, + altLocked: altLocked, + ); + } + return result; +} + +/// Returns true when a hardware paste shortcut must bypass keyboard modifiers. +/// +/// xterm already handles hardware Ctrl/Cmd+V correctly in the common case. Only +/// intercept while a virtual Ctrl/Alt lock is active, because xterm can emit a +/// one-character paste as normal text when bracketed paste mode is disabled. +bool shouldHandleTerminalPasteShortcut({ + required LogicalKeyboardKey logicalKey, + required bool isKeyDown, + required bool isKeyRepeat, + required bool controlPressed, + required bool metaPressed, + required bool altPressed, + required bool shiftPressed, + required bool modifierLockActive, +}) { + if (!modifierLockActive) return false; + if (!isKeyDown && !isKeyRepeat) return false; + if (logicalKey != LogicalKeyboardKey.keyV) return false; + if (altPressed || shiftPressed) return false; + return controlPressed != metaPressed; +} + +/// Returns true when collapsing Row3 should also clear hidden modifier state. +bool shouldClearTerminalModifiersWhenRow3Collapses({ + required bool wasExpanded, + required bool willExpand, + required bool ctrlLocked, + required bool altLocked, +}) { + return wasExpanded && !willExpand && (ctrlLocked || altLocked); +} + +String _applyTerminalCtrlModifier(String data) { + // Ctrl mappings are defined only for ASCII scalars. A visible character can + // be multiple scalars (for example, a decomposed accent), so leave those + // graphemes untouched instead of rewriting only their ASCII base letter. + final graphemes = data.characters.toList(growable: false); + if (graphemes.length != 1) { + return data; + } + + final runes = graphemes.single.runes.toList(growable: false); + if (runes.length != 1) { + return data; + } + + final code = runes.single; + if (code >= 0x61 && code <= 0x7A) { + return String.fromCharCode(code - 0x60); + } + if (code >= 0x41 && code <= 0x5A) { + return String.fromCharCode(code - 0x40); + } + if (code == 0x20) { + return String.fromCharCode(0); + } + if (code == 0x5B) { + return String.fromCharCode(27); + } + if (code == 0x5C) { + return String.fromCharCode(28); + } + if (code == 0x5D) { + return String.fromCharCode(29); + } + if (code == 0x5E) { + return String.fromCharCode(30); + } + if (code == 0x5F || code == 0x2F) { + return String.fromCharCode(31); + } + return data; +} diff --git a/flutter/lib/models/terminal_model.dart b/flutter/lib/models/terminal_model.dart index 2b3fd4837..6f179afe2 100644 --- a/flutter/lib/models/terminal_model.dart +++ b/flutter/lib/models/terminal_model.dart @@ -7,6 +7,7 @@ import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/main.dart'; import 'package:xterm/xterm.dart'; +import 'input_modifier_utils.dart'; import 'model.dart'; import 'platform_model.dart'; @@ -22,7 +23,25 @@ class TerminalModel with ChangeNotifier { bool _disposed = false; + /// Callback to check whether Ctrl modifier lock is currently active. + /// When active, keyboard input is mapped to control codes (e.g. 'b' → \x02). + bool Function()? isCtrlLocked; + + /// Callback to clear Ctrl lock after a key is pressed (one-shot mode). + void Function()? clearCtrlLock; + + /// Callback to check whether Alt modifier lock is currently active. + bool Function()? isAltLocked; + + /// Callback to clear Alt lock after a key is pressed (one-shot mode). + void Function()? clearAltLock; + final _inputBuffer = []; + + /// Exposes buffered input only for lifecycle regression tests. + @visibleForTesting + int get debugBufferedInputCount => _inputBuffer.length; + // Buffer for output data received before terminal view has valid dimensions. // This prevents NaN errors when writing to terminal before layout is complete. final _pendingOutputChunks = []; @@ -42,6 +61,10 @@ class TerminalModel with ChangeNotifier { VoidCallback? onClosed; Future _handleInput(String data) async { + // xterm can complete asynchronous input after the Flutter page has gone + // away. Stop before reading or clearing widget-owned modifier state. + if (_disposed) return; + // Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a // real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'. // - Peer Windows: '\r' works, '\n' is just a newline. @@ -49,13 +72,44 @@ class TerminalModel with ChangeNotifier { // (readline, prompt_toolkit, vim, TUI frameworks) expect '\r'. // - Peer macOS: same as Linux, raw-mode apps expect '\r' // (https://github.com/rustdesk/rustdesk/issues/14907). - // So on mobile / web-mobile, always normalize a lone '\n' to '\r'. - // We deliberately do not touch multi-character payloads (e.g. pasted text) - // so embedded newlines in pasted content are preserved. - final isMobileOrWebMobile = (isMobile || (isWeb && !isWebDesktop)); - if (isMobileOrWebMobile && data == '\n') { - data = '\r'; + // So on mobile / web-mobile, normalize the original lone '\n' to '\r' + // before modifier mappings. This keeps Ctrl+J mapped to LF instead of + // having the generated control code rewritten to CR afterward. + // Multi-character keyboard payloads, such as terminal escape sequences, + // remain unchanged. Paste input follows a separate preprocessing path. + final ctrlLocked = isCtrlLocked?.call() ?? false; + final altLocked = isAltLocked?.call() ?? false; + final modifiersActive = ctrlLocked || altLocked; + // Use the same predicate for transformation and consumption. Control keys + // and escape sequences must not silently consume a pending one-shot lock. + final shouldConsumeModifiers = + modifiersActive && shouldApplyTerminalInputModifiers(data); + data = prepareTerminalInputPayload( + data, + // IME soft-keyboard paste prompts currently arrive from xterm as normal + // text input with no paste-origin metadata. Keep them on the keyboard path; + // clipboard-content heuristics can misclassify ordinary typing. + source: TerminalInputSource.keyboard, + isMobileOrWebMobile: isMobile || (isWeb && !isWebDesktop), + bracketedPasteMode: terminal.bracketedPasteMode, + ctrlLocked: ctrlLocked, + altLocked: altLocked, + ); + if (shouldConsumeModifiers) { + if (ctrlLocked) clearCtrlLock?.call(); + if (altLocked) clearAltLock?.call(); } + return _sendInputPayload(data); + } + + /// Sends an already prepared payload without applying keyboard semantics. + /// Both normal input and paste use this transport path after their source- + /// specific preprocessing has completed. + Future _sendInputPayload(String data) async { + // Clipboard reads and native sends may complete after the terminal page has + // closed. Never send or re-buffer input once this model is disposed. + if (_disposed) return; + if (_terminalOpened) { // Send user input to remote terminal try { @@ -176,6 +230,18 @@ class TerminalModel with ChangeNotifier { return _handleInput(data); } + Future pasteText(String data) async { + final payload = prepareTerminalInputPayload( + data, + source: TerminalInputSource.paste, + isMobileOrWebMobile: false, + bracketedPasteMode: terminal.bracketedPasteMode, + ctrlLocked: false, + altLocked: false, + ); + return _sendInputPayload(payload); + } + Future closeTerminal() async { if (_terminalOpened) { try { @@ -516,6 +582,14 @@ class TerminalModel with ChangeNotifier { void dispose() { if (_disposed) return; _disposed = true; + terminal.onOutput = null; + terminal.onResize = null; + isCtrlLocked = null; + clearCtrlLock = null; + isAltLocked = null; + clearAltLock = null; + onResizeExternal = null; + onClosed = null; // Clear buffers to free memory _inputBuffer.clear(); _pendingOutputChunks.clear(); diff --git a/flutter/test/input_modifier_utils_test.dart b/flutter/test/input_modifier_utils_test.dart index 2e1971753..5a1a76a77 100644 --- a/flutter/test/input_modifier_utils_test.dart +++ b/flutter/test/input_modifier_utils_test.dart @@ -122,4 +122,394 @@ void main() { ); }); }); + + group('shouldApplyTerminalInputModifiers', () { + test('accepts ordinary single-character keyboard input', () { + expect(shouldApplyTerminalInputModifiers('a'), isTrue); + expect(shouldApplyTerminalInputModifiers(' '), isTrue); + expect(shouldApplyTerminalInputModifiers('/'), isTrue); + }); + + test('accepts supplementary-plane single-character keyboard input', () { + expect(shouldApplyTerminalInputModifiers('😀'), isTrue); + }); + + test('rejects terminal control bytes and multi-character sequences', () { + for (final input in ['\x00', '\x03', '\t', '\n', '\r', '\x1B', '\x7F']) { + expect( + shouldApplyTerminalInputModifiers(input), + isFalse, + reason: '${input.codeUnits} must not consume a one-shot modifier', + ); + } + expect(shouldApplyTerminalInputModifiers('\x1B[A'), isFalse); + }); + }); + + group('applyTerminalInputModifiers', () { + test('keeps decomposed graphemes intact under Ctrl', () { + const decomposedEAcute = 'e\u0301'; + + expect( + applyTerminalInputModifiers( + decomposedEAcute, + ctrlLocked: true, + altLocked: false, + ), + decomposedEAcute, + ); + }); + + test('keeps non-ASCII graphemes intact under Ctrl', () { + for (final input in ['é', '😀']) { + expect( + applyTerminalInputModifiers( + input, + ctrlLocked: true, + altLocked: false, + ), + input, + ); + } + }); + + test('maps Ctrl underscore to unit separator', () { + expect( + applyTerminalInputModifiers( + '_', + ctrlLocked: true, + altLocked: false, + ), + '\x1F', + ); + }); + + test('maps the complete Ctrl symbol range', () { + const mappings = { + '[': '\x1B', + r'\': '\x1C', + ']': '\x1D', + '^': '\x1E', + '_': '\x1F', + '/': '\x1F', + }; + + for (final entry in mappings.entries) { + expect( + applyTerminalInputModifiers( + entry.key, + ctrlLocked: true, + altLocked: false, + ), + entry.value, + reason: 'Ctrl+${entry.key} should map to ${entry.value.codeUnits}', + ); + } + }); + + test('applies Ctrl before Alt for combined modifiers', () { + expect( + applyTerminalInputModifiers( + 'b', + ctrlLocked: true, + altLocked: true, + ), + '\x1B\x02', + ); + }); + }); + + group('terminalPastePayload', () { + test('wraps paste text when bracketed paste mode is active', () { + expect( + terminalPastePayload('d', bracketedPasteMode: true), + '\x1B[200~d\x1B[201~', + ); + }); + + test('keeps a lone newline unchanged when bracketed paste is disabled', () { + expect( + terminalPastePayload('\n', bracketedPasteMode: false), + '\n', + ); + }); + }); + + group('prepareTerminalInputPayload', () { + test('normalizes a mobile keyboard Enter to carriage return', () { + expect( + prepareTerminalInputPayload( + '\n', + source: TerminalInputSource.keyboard, + isMobileOrWebMobile: true, + bracketedPasteMode: false, + ctrlLocked: false, + altLocked: false, + ), + '\r', + ); + }); + + test('keeps Ctrl+J as line feed on mobile', () { + expect( + prepareTerminalInputPayload( + 'j', + source: TerminalInputSource.keyboard, + isMobileOrWebMobile: true, + bracketedPasteMode: false, + ctrlLocked: true, + altLocked: false, + ), + '\n', + ); + }); + + test('does not apply Alt to a terminal control byte', () { + expect( + prepareTerminalInputPayload( + '\x1B', + source: TerminalInputSource.keyboard, + isMobileOrWebMobile: true, + bracketedPasteMode: false, + ctrlLocked: false, + altLocked: true, + ), + '\x1B', + ); + }); + + test('keeps large keyboard payloads unchanged when modifiers are inactive', + () { + final payload = 'd' * (1024 * 1024); + + expect( + prepareTerminalInputPayload( + payload, + source: TerminalInputSource.keyboard, + isMobileOrWebMobile: false, + bracketedPasteMode: false, + ctrlLocked: false, + altLocked: false, + ), + payload, + ); + }); + + test('keeps decomposed graphemes intact with locked keyboard modifiers', + () { + const decomposedEAcute = 'e\u0301'; + + expect( + prepareTerminalInputPayload( + decomposedEAcute, + source: TerminalInputSource.keyboard, + isMobileOrWebMobile: true, + bracketedPasteMode: false, + ctrlLocked: true, + altLocked: false, + ), + decomposedEAcute, + ); + }); + + test('preserves a lone pasted newline when modifiers are locked', () { + expect( + prepareTerminalInputPayload( + '\n', + source: TerminalInputSource.paste, + isMobileOrWebMobile: true, + bracketedPasteMode: false, + ctrlLocked: true, + altLocked: true, + ), + '\n', + ); + }); + + test('wraps paste without applying locked modifiers', () { + expect( + prepareTerminalInputPayload( + 'd', + source: TerminalInputSource.paste, + isMobileOrWebMobile: true, + bracketedPasteMode: true, + ctrlLocked: true, + altLocked: true, + ), + '\x1B[200~d\x1B[201~', + ); + }); + }); + + group('shouldHandleTerminalPasteShortcut', () { + test( + 'keeps default xterm paste behavior when virtual modifiers are inactive', + () { + expect( + shouldHandleTerminalPasteShortcut( + logicalKey: LogicalKeyboardKey.keyV, + isKeyDown: true, + isKeyRepeat: false, + controlPressed: true, + metaPressed: false, + altPressed: false, + shiftPressed: false, + modifierLockActive: false, + ), + isFalse, + ); + }); + + test('handles Ctrl+V and Meta+V when a virtual modifier lock is active', + () { + expect( + shouldHandleTerminalPasteShortcut( + logicalKey: LogicalKeyboardKey.keyV, + isKeyDown: true, + isKeyRepeat: false, + controlPressed: true, + metaPressed: false, + altPressed: false, + shiftPressed: false, + modifierLockActive: true, + ), + isTrue, + ); + expect( + shouldHandleTerminalPasteShortcut( + logicalKey: LogicalKeyboardKey.keyV, + isKeyDown: true, + isKeyRepeat: false, + controlPressed: false, + metaPressed: true, + altPressed: false, + shiftPressed: false, + modifierLockActive: true, + ), + isTrue, + ); + }); + + test('handles paste shortcut repeats while a virtual lock is active', () { + expect( + shouldHandleTerminalPasteShortcut( + logicalKey: LogicalKeyboardKey.keyV, + isKeyDown: false, + isKeyRepeat: true, + controlPressed: true, + metaPressed: false, + altPressed: false, + shiftPressed: false, + modifierLockActive: true, + ), + isTrue, + ); + }); + + test('ignores key-up and unmodified V events', () { + expect( + shouldHandleTerminalPasteShortcut( + logicalKey: LogicalKeyboardKey.keyV, + isKeyDown: false, + isKeyRepeat: false, + controlPressed: true, + metaPressed: false, + altPressed: false, + shiftPressed: false, + modifierLockActive: true, + ), + isFalse, + ); + expect( + shouldHandleTerminalPasteShortcut( + logicalKey: LogicalKeyboardKey.keyV, + isKeyDown: true, + isKeyRepeat: false, + controlPressed: false, + metaPressed: false, + altPressed: false, + shiftPressed: false, + modifierLockActive: true, + ), + isFalse, + ); + }); + + test('ignores paste shortcuts with extra modifiers', () { + for (final state in [ + (control: true, meta: false, alt: true, shift: false), + (control: true, meta: false, alt: false, shift: true), + (control: false, meta: true, alt: false, shift: true), + (control: true, meta: true, alt: false, shift: false), + ]) { + expect( + shouldHandleTerminalPasteShortcut( + logicalKey: LogicalKeyboardKey.keyV, + isKeyDown: true, + isKeyRepeat: false, + controlPressed: state.control, + metaPressed: state.meta, + altPressed: state.alt, + shiftPressed: state.shift, + modifierLockActive: true, + ), + isFalse, + ); + } + }); + + test('ignores non-V key events', () { + expect( + shouldHandleTerminalPasteShortcut( + logicalKey: LogicalKeyboardKey.keyC, + isKeyDown: true, + isKeyRepeat: false, + controlPressed: true, + metaPressed: false, + altPressed: false, + shiftPressed: false, + modifierLockActive: true, + ), + isFalse, + ); + }); + }); + + group('shouldClearTerminalModifiersWhenRow3Collapses', () { + test('clears visible modifier state when expanded row is collapsed', () { + expect( + shouldClearTerminalModifiersWhenRow3Collapses( + wasExpanded: true, + willExpand: false, + ctrlLocked: true, + altLocked: false, + ), + isTrue, + ); + }); + + test('does not clear modifiers when row expands', () { + expect( + shouldClearTerminalModifiersWhenRow3Collapses( + wasExpanded: false, + willExpand: true, + ctrlLocked: true, + altLocked: true, + ), + isFalse, + ); + }); + + test('clears Alt state when expanded row is collapsed', () { + expect( + shouldClearTerminalModifiersWhenRow3Collapses( + wasExpanded: true, + willExpand: false, + ctrlLocked: false, + altLocked: true, + ), + isTrue, + ); + }); + }); } diff --git a/flutter/test/terminal_keyboard_utils_test.dart b/flutter/test/terminal_keyboard_utils_test.dart new file mode 100644 index 000000000..c93a42413 --- /dev/null +++ b/flutter/test/terminal_keyboard_utils_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('mobile terminal keyboard layout', () { + test('keeps the latest key order from the reviewed PR layout', () { + expect( + terminalKeyboardRow1Keys, + ['Esc', '/', '|', 'Home', '↑', 'End', r'\'], + ); + expect( + terminalKeyboardRow2Keys, + ['Tab', 'Ctrl+C', '~', '←', '↓', '→'], + ); + expect( + terminalKeyboardRow3Keys, + ['Ctrl', 'Alt', '-', 'PgUp', 'PgDn'], + ); + }); + + test('keeps two trailing Row3 placeholders for row alignment', () { + expect(terminalKeyboardRow3TrailingPlaceholderCount, 2); + }); + + test('keeps every expanded row aligned at 348dp', () { + final rowWidths = [ + terminalKeyboardRowWidth(terminalKeyboardRow1Keys.length), + terminalKeyboardRowWidth(terminalKeyboardRow2Keys.length + 1), + terminalKeyboardRowWidth( + terminalKeyboardRow3Keys.length + + terminalKeyboardRow3TrailingPlaceholderCount, + ), + ]; + + expect(terminalKeyboardKeyWidth, 48); + expect(terminalKeyboardKeySpacing, 2); + expect(rowWidths, everyElement(348)); + }); + }); +} diff --git a/flutter/test/terminal_model_lifecycle_test.dart b/flutter/test/terminal_model_lifecycle_test.dart new file mode 100644 index 000000000..d00646b2b --- /dev/null +++ b/flutter/test/terminal_model_lifecycle_test.dart @@ -0,0 +1,51 @@ +import 'dart:async'; + +import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_hbb/models/terminal_model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FakeFFI implements FFI { + @override + String id = 'test-peer'; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + test('ignores paste that completes after the terminal model is disposed', + () async { + final model = TerminalModel(_FakeFFI()); + final delayedClipboardText = Completer(); + + // This mirrors Ctrl/Cmd+V: clipboard access starts first, then the page and + // model are disposed before the asynchronous read supplies its text. + final paste = delayedClipboardText.future.then(model.pasteText); + model.dispose(); + delayedClipboardText.complete('late clipboard text'); + await paste; + + expect(model.debugBufferedInputCount, 0); + }); + + test('ignores terminal text input after the terminal model is disposed', () { + final model = TerminalModel(_FakeFFI()); + var checkedCtrlLock = false; + var clearedCtrlLock = false; + + model.isCtrlLocked = () { + checkedCtrlLock = true; + return true; + }; + model.clearCtrlLock = () { + clearedCtrlLock = true; + }; + + model.dispose(); + model.terminal.textInput('d'); + + expect(checkedCtrlLock, isFalse); + expect(clearedCtrlLock, isFalse); + expect(model.debugBufferedInputCount, 0); + }); +} diff --git a/flutter/windows/runner/main.cpp b/flutter/windows/runner/main.cpp index cd9f386b1..ea9152ec7 100644 --- a/flutter/windows/runner/main.cpp +++ b/flutter/windows/runner/main.cpp @@ -14,6 +14,7 @@ typedef char** (*FUNC_RUSTDESK_CORE_MAIN)(int*); typedef void (*FUNC_RUSTDESK_FREE_ARGS)( char**, int); typedef int (*FUNC_RUSTDESK_GET_APP_NAME)(wchar_t*, int); +typedef int (*FUNC_RUSTDESK_IS_DISABLE_INSTALLATION)(); /// Note: `--server`, `--service` are already handled in [core_main.rs]. const std::vector parameters_white_list = {"--install", "--cm"}; @@ -62,6 +63,22 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, } std::vector rust_args(c_args, c_args + args_len); free_c_args(c_args, args_len); + FUNC_RUSTDESK_IS_DISABLE_INSTALLATION rustdesk_is_disable_installation = + (FUNC_RUSTDESK_IS_DISABLE_INSTALLATION)GetProcAddress(hInstance, "rustdesk_is_disable_installation"); + bool is_disable_installation = + rustdesk_is_disable_installation && rustdesk_is_disable_installation() != 0; + const auto installParam = std::string("--install"); + // Flutter reads the original process command line, not only rust_args, so + // remove the `--install` injected by the portable wrapper here as well. This + // also lets `no-install.exe` continue as a portable app when installation is + // disabled. See: https://github.com/rustdesk/rustdesk-server-pro/issues/991#issuecomment-4978376890 + if (is_disable_installation) { + command_line_arguments.erase( + std::remove(command_line_arguments.begin(), + command_line_arguments.end(), + installParam), + command_line_arguments.end()); + } std::wstring app_name = L"RustDesk"; FUNC_RUSTDESK_GET_APP_NAME get_rustdesk_app_name = (FUNC_RUSTDESK_GET_APP_NAME)GetProcAddress(hInstance, "get_rustdesk_app_name"); @@ -118,7 +135,6 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, is_cm_page = true; } bool is_install_page = false; - auto installParam = std::string("--install"); if (!command_line_arguments.empty() && command_line_arguments.front().compare(0, installParam.size(), installParam.c_str()) == 0) { is_install_page = true; } diff --git a/libs/hbb_common b/libs/hbb_common index 4bfd06776..559176122 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit 4bfd0677658cba953ca614094a1f0349c5290566 +Subproject commit 559176122bdd5c8afa4e8fd5b706c3d901fb0c15 diff --git a/libs/scrap/Cargo.toml b/libs/scrap/Cargo.toml index 505eca2de..0af7dfe0f 100644 --- a/libs/scrap/Cargo.toml +++ b/libs/scrap/Cargo.toml @@ -48,7 +48,7 @@ quest = "0.3" [build-dependencies] target_build_utils = "0.3" -bindgen = "0.65" +bindgen = "0.72.1" pkg-config = { version = "0.3.27", optional = true } [target.'cfg(target_os = "linux")'.dependencies] diff --git a/libs/scrap/src/common/record.rs b/libs/scrap/src/common/record.rs index d121984f1..ffeb25791 100644 --- a/libs/scrap/src/common/record.rs +++ b/libs/scrap/src/common/record.rs @@ -20,6 +20,22 @@ use webm::mux::{self, Segment, Track, VideoTrack, Writer}; const MIN_SECS: u64 = 1; +// Replace characters that are invalid in Windows filename components so recordings remain portable. +// Control characters are also replaced because they can make filenames invalid +// on Windows or invisible and difficult to handle on Linux and macOS. +fn sanitize_filename_component(value: &str) -> String { + value + .chars() + .map(|c| { + if c.is_control() || matches!(c, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') { + '_' + } else { + c + } + }) + .collect() +} + #[derive(Debug, Clone)] pub struct RecorderContext { pub server: bool, @@ -45,7 +61,7 @@ impl RecorderContext2 { } let file = if ctx.server { "incoming" } else { "outgoing" }.to_string() + "_" - + &ctx.id.clone() + + &sanitize_filename_component(&ctx.id) + &chrono::Local::now().format("_%Y%m%d%H%M%S%3f_").to_string() + &format!( "{}{}_", @@ -421,3 +437,24 @@ impl Drop for HwRecorder { self.ctx.tx.as_ref().map(|tx| tx.send(state)); } } + +#[cfg(test)] +mod tests { + use super::sanitize_filename_component; + + #[test] + fn sanitize_recording_filename_component() { + assert_eq!( + sanitize_filename_component("192.168.1.2:21118"), + "192.168.1.2_21118" + ); + assert_eq!( + sanitize_filename_component("[2001:db8::1]:21118"), + "[2001_db8__1]_21118" + ); + assert_eq!( + sanitize_filename_component("peer/name\\with?bad\nchars"), + "peer_name_with_bad_chars" + ); + } +} diff --git a/libs/scrap/src/wayland/display.rs b/libs/scrap/src/wayland/display.rs index a5c937491..bed90fd76 100644 --- a/libs/scrap/src/wayland/display.rs +++ b/libs/scrap/src/wayland/display.rs @@ -14,6 +14,9 @@ lazy_static! { static ref DISPLAYS: Mutex>> = Mutex::new(None); } +static MISSING_LOGICAL_SIZE_WARNED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + const COMMAND_TIMEOUT: Duration = Duration::from_millis(1000); pub struct Displays { @@ -217,7 +220,26 @@ pub fn clear_wayland_displays_cache() { // Return (min_x, max_x, min_y, max_y) pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> { let wayland_displays = get_displays(); - let displays = &wayland_displays.displays; + desktop_rect_of(&wayland_displays.displays) +} + +// The desktop rect and per-display logical rects, always read live from the +// compositor in a single roundtrip. Skips the displays cache and the primary-monitor +// detection (which may spawn external commands), so it is cheap enough to poll for +// layout changes. https://github.com/rustdesk/rustdesk/issues/15601 +pub fn get_layout_for_uinput_live() -> Option<((i32, i32, i32, i32), Vec)> { + match get_wayland_displays() { + Ok(displays) => { + desktop_rect_of(&displays).map(|rect| (rect, logical_rects_of(&displays))) + } + Err(err) => { + warn!("Failed to get wayland displays: {}", err); + None + } + } +} + +fn desktop_rect_of(displays: &[WaylandDisplayInfo]) -> Option<(i32, i32, i32, i32)> { if displays.is_empty() { return None; } @@ -243,10 +265,13 @@ pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> { // This may occur if the Wayland compositor does not provide logical size information, // or if display information is incomplete. We fall back to physical size, which provides // usable dimensions, but may not always be correct depending on compositor behavior. - warn!( + // Warn only once, the live path polls this while a session is active. + if !MISSING_LOGICAL_SIZE_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) { + warn!( "Display at ({}, {}) is missing logical_size; falling back to physical size ({}, {}).", d.x, d.y, d.width, d.height ); + } (d.width, d.height) }; max_x = max_x.max(d.x + size.0); @@ -254,3 +279,289 @@ pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> { } Some((min_x, max_x, min_y, max_y)) } + +/// One display's logical rectangle in the desktop coordinate space the client uses: +/// logical origin plus logical size, falling back to physical size when the compositor +/// reports no logical size (matching `desktop_rect_of`). +#[derive(Clone, Debug, PartialEq)] +pub struct DisplayRect { + pub name: String, + pub x: i32, + pub y: i32, + pub w: i32, + pub h: i32, +} + +fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec { + // Match `desktop_rect_of`: a single display uses its physical size (its scale is + // reported as 1.0 to the client), multiple displays use logical size. This keeps a + // single display a no-op for the remap (its origin never shifts) and keeps the rects + // in the same coordinate space the client's coordinates are expressed in. + let single = displays.len() == 1; + displays + .iter() + .map(|d| { + let (w, h) = if single { + (d.width, d.height) + } else { + d.logical_size.unwrap_or((d.width, d.height)) + }; + DisplayRect { + name: d.name.clone(), + x: d.x, + y: d.y, + w, + h, + } + }) + .collect() +} + +// Per-display logical rects from the cached init snapshot. The client's injected +// coordinates are `local + origin` in this layout, so it is the baseline to map from. +pub fn get_display_rects_for_uinput() -> Vec { + logical_rects_of(&get_displays().displays) +} + +/// Remap an injected coordinate from the layout the client still believes in +/// (`baseline`, captured at session init) to the current compositor layout (`live`). +/// +/// A single-display client sends whole-desktop coordinates: `local + baseline_origin[d]` +/// for whichever display `d` it is following. If that display's origin or logical size +/// has since changed (e.g. another monitor was rescaled, shifting this one), the +/// coordinate lands offset. We find the baseline display the point falls in, then map +/// the point into the same display's live rectangle, matched by connector name (or, when +/// the compositor reports no names, by index while the display count is unchanged). +/// +/// Returns the input unchanged when the point is outside every baseline display or the +/// matched display is gone, so a failed match never moves the cursor further off than +/// leaving it alone. https://github.com/rustdesk/rustdesk/issues/15601 +pub fn remap_to_live_layout( + x: i32, + y: i32, + baseline: &[DisplayRect], + live: &[DisplayRect], +) -> (i32, i32) { + let Some((bi, b)) = baseline + .iter() + .enumerate() + .find(|(_, r)| x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h) + else { + return (x, y); + }; + let matched = if b.name.is_empty() { + // Nameless compositor: index-match, but only while the count is unchanged. A + // named display that is simply gone from the live layout must fall through to + // "unchanged" below, not get index-matched to whatever now sits at its index. + if baseline.len() == live.len() { + live.get(bi) + } else { + None + } + } else { + live.iter().find(|r| r.name == b.name) + }; + let Some(l) = matched else { + return (x, y); + }; + // Map the point into the live rectangle, preserving position within the display so a + // scale change on the followed display itself is corrected too, not only a shift. + // Scale by (extent - 1) so both endpoints land exactly: the client clamps its + // coordinate to `[origin, origin + w - 1]`, and mapping that span to the live span's + // `[0, w' - 1]` keeps the far edge reachable (hot corners) in both directions, and + // stays an exact shift when the size is unchanged. + let nx = map_axis(x, b.x, b.w, l.x, l.w); + let ny = map_axis(y, b.y, b.h, l.y, l.h); + (nx, ny) +} + +fn map_axis(v: i32, base_origin: i32, base_extent: i32, live_origin: i32, live_extent: i32) -> i32 { + if base_extent <= 1 || live_extent <= 1 { + return live_origin; + } + live_origin + ((v - base_origin) as i64 * (live_extent - 1) as i64 / (base_extent - 1) as i64) as i32 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn display( + x: i32, + y: i32, + width: i32, + height: i32, + logical_size: Option<(i32, i32)>, + ) -> WaylandDisplayInfo { + WaylandDisplayInfo { + name: "".to_owned(), + x, + y, + width, + height, + logical_size, + refresh_rate: 60, + } + } + + #[test] + fn test_desktop_rect_empty() { + assert_eq!(desktop_rect_of(&[]), None); + } + + #[test] + fn test_desktop_rect_single_display_uses_physical_size() { + let displays = [display(0, 0, 2880, 1800, Some((1859, 1162)))]; + assert_eq!(desktop_rect_of(&displays), Some((0, 2880, 0, 1800))); + } + + #[test] + fn test_desktop_rect_multi_display_uses_logical_size() { + // Laptop panel at 155% below two stacked externals at 100%. + let displays = [ + display(0, 718, 2880, 1800, Some((1859, 1162))), + display(1859, 0, 1920, 1080, Some((1920, 1080))), + display(1859, 1080, 1920, 1080, Some((1920, 1080))), + ]; + assert_eq!(desktop_rect_of(&displays), Some((0, 3779, 0, 2160))); + } + + #[test] + fn test_desktop_rect_missing_logical_size_falls_back_to_physical() { + let displays = [ + display(0, 0, 2560, 1440, None), + display(2560, 0, 2560, 1440, Some((2560, 1440))), + ]; + assert_eq!(desktop_rect_of(&displays), Some((0, 5120, 0, 1440))); + } + + fn rect(name: &str, x: i32, y: i32, w: i32, h: i32) -> DisplayRect { + DisplayRect { + name: name.to_owned(), + x, + y, + w, + h, + } + } + + // The reported failure: connect to the second display, rescale the primary. + // Baseline: two 2560-wide displays side by side, both at 100%. + // Live: the primary (DP-1) rescaled to 125% -> 2048 logical wide, so the second + // display (DP-2) shifts left from x=2560 to x=2048. A client following DP-2 keeps + // sending coordinates offset by DP-2's old origin (2560). + #[test] + fn test_remap_primary_rescale_shifts_second_display() { + let baseline = [ + rect("DP-1", 0, 0, 2560, 1440), + rect("DP-2", 2560, 0, 2560, 1440), + ]; + let live = [ + rect("DP-1", 0, 0, 2048, 1440), + rect("DP-2", 2048, 0, 2560, 1440), + ]; + // Top-left of DP-2: client sends (2560, 0), should land at live DP-2 origin. + assert_eq!(remap_to_live_layout(2560, 0, &baseline, &live), (2048, 0)); + // Middle of DP-2 keeps its fractional position. + assert_eq!( + remap_to_live_layout(3840, 720, &baseline, &live), + (3328, 720) + ); + } + + // A point on the rescaled display itself is squeezed to its new logical width. + #[test] + fn test_remap_scales_within_resized_display() { + let baseline = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 2560, 1440)]; + let live = [rect("DP-1", 0, 0, 2048, 1440), rect("DP-2", 2048, 0, 2560, 1440)]; + // x=1280 across the 2560-wide baseline DP-1 -> proportionally across the 2048-wide + // live DP-1 (endpoint-preserving scale, so ~1px off the naive midpoint). + assert_eq!(remap_to_live_layout(1280, 500, &baseline, &live), (1023, 500)); + } + + // The far edge of the followed display stays reachable when it is enlarged, so hot + // corners keep working. Baseline DP-1 is 2048 wide, live DP-1 is 2560 wide; the + // client's last column (2047) must map to the live last column (2559), not 2558. + #[test] + fn test_remap_enlarged_display_reaches_far_edge() { + let baseline = [rect("DP-1", 0, 0, 2048, 1440), rect("DP-2", 2048, 0, 1920, 1080)]; + let live = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 1920, 1080)]; + assert_eq!(remap_to_live_layout(2047, 0, &baseline, &live), (2559, 0)); + assert_eq!(remap_to_live_layout(0, 0, &baseline, &live), (0, 0)); + } + + // No drift: identical layouts map every point to itself. + #[test] + fn test_remap_identity_when_unchanged() { + let layout = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 2560, 1440)]; + assert_eq!(remap_to_live_layout(3000, 700, &layout, &layout), (3000, 700)); + } + + // Point outside every baseline display is left untouched. + #[test] + fn test_remap_point_outside_all_displays_unchanged() { + let baseline = [rect("DP-1", 0, 0, 2560, 1440)]; + let live = [rect("DP-1", 0, 0, 2048, 1440)]; + assert_eq!(remap_to_live_layout(9000, 9000, &baseline, &live), (9000, 9000)); + } + + // Matched display gone from the live layout (e.g. unplugged): leave the point be + // rather than mapping it somewhere wrong. + #[test] + fn test_remap_display_removed_unchanged() { + let baseline = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 2560, 1440)]; + let live = [rect("DP-1", 0, 0, 2560, 1440)]; + assert_eq!(remap_to_live_layout(2600, 100, &baseline, &live), (2600, 100)); + } + + // Nameless compositor: fall back to index matching while the count is unchanged. + #[test] + fn test_remap_nameless_index_fallback() { + let baseline = [rect("", 0, 0, 2560, 1440), rect("", 2560, 0, 2560, 1440)]; + let live = [rect("", 0, 0, 2048, 1440), rect("", 2048, 0, 2560, 1440)]; + assert_eq!(remap_to_live_layout(2560, 0, &baseline, &live), (2048, 0)); + } + + // Nameless compositor with a changed count: cannot index-match safely, so no-op. + #[test] + fn test_remap_nameless_count_changed_unchanged() { + let baseline = [rect("", 0, 0, 2560, 1440), rect("", 2560, 0, 2560, 1440)]; + let live = [rect("", 0, 0, 2048, 1440)]; + assert_eq!(remap_to_live_layout(2560, 0, &baseline, &live), (2560, 0)); + } + + // A named display absent from the live layout, but the count is unchanged (e.g. a + // monitor was swapped for a different one at the same index): the index fallback is + // for nameless layouts only, so a named miss stays unchanged rather than mapping to + // whatever now occupies that index. + #[test] + fn test_remap_named_miss_equal_count_unchanged() { + let baseline = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 2560, 1440)]; + let live = [rect("DP-1", 0, 0, 2048, 1440), rect("HDMI-1", 2048, 0, 1920, 1080)]; + assert_eq!(remap_to_live_layout(2600, 100, &baseline, &live), (2600, 100)); + } + + // A single display uses physical size in both baseline and live (scale reported as + // 1.0), so it never drifts and the remap is a no-op even across a rescale. + #[test] + fn test_logical_rects_single_display_uses_physical() { + let displays = [display(0, 0, 2560, 1440, Some((2048, 1152)))]; + assert_eq!( + logical_rects_of(&displays), + vec![rect("", 0, 0, 2560, 1440)] + ); + } + + // Multiple displays use logical size, falling back to physical when absent. + #[test] + fn test_logical_rects_multi_display_uses_logical() { + let displays = [ + display(0, 0, 2560, 1440, Some((2048, 1152))), + display(2048, 0, 1920, 1080, None), + ]; + assert_eq!( + logical_rects_of(&displays), + vec![rect("", 0, 0, 2048, 1152), rect("", 2048, 0, 1920, 1080)] + ); + } +} diff --git a/src/client.rs b/src/client.rs index dcd5941df..5cafeadaf 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1401,6 +1401,10 @@ impl AudioHandler { /// Handle audio format and create an audio decoder. pub fn handle_format(&mut self, f: AudioFormat) { + if !is_supported_audio_channel_count(f.channels) { + log::error!("Unsupported audio channel count: {}", f.channels); + return; + } match AudioDecoder::new(f.sample_rate, if f.channels > 1 { Stereo } else { Mono }) { Ok(d) => { let buffer = vec![0.; f.sample_rate as usize * f.channels as usize]; @@ -1540,6 +1544,23 @@ impl AudioHandler { } } +fn is_supported_audio_channel_count(channels: u32) -> bool { + (1..=2).contains(&channels) +} + +#[cfg(test)] +mod audio_format_tests { + use super::is_supported_audio_channel_count; + + #[test] + fn only_mono_and_stereo_are_supported() { + assert!(is_supported_audio_channel_count(1)); + assert!(is_supported_audio_channel_count(2)); + assert!(!is_supported_audio_channel_count(0)); + assert!(!is_supported_audio_channel_count(u32::MAX)); + } +} + /// Video handler for the [`Client`]. pub struct VideoHandler { decoder: Decoder, diff --git a/src/clipboard.rs b/src/clipboard.rs index 01dc0c9ed..c7c01d6c4 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -36,6 +36,17 @@ const CLIPBOARD_GET_MAX_RETRY: usize = 3; #[cfg(not(target_os = "android"))] const CLIPBOARD_GET_RETRY_INTERVAL_DUR: Duration = Duration::from_millis(33); +#[cfg(not(target_os = "android"))] +fn valid_rgba_dimensions(width: i32, height: i32, data_len: usize) -> Option<(usize, usize)> { + let width = usize::try_from(width).ok()?; + let height = usize::try_from(height).ok()?; + if width == 0 || height == 0 { + return None; + } + let expected_len = width.checked_mul(height)?.checked_mul(4)?; + (data_len == expected_len).then_some((width, height)) +} + #[cfg(not(target_os = "android"))] const SUPPORTED_FORMATS: &[ClipboardFormat] = &[ ClipboardFormat::Text, @@ -722,11 +733,15 @@ mod proto { Ok(ClipboardFormat::Text) => String::from_utf8(data).ok().map(ClipboardData::Text), Ok(ClipboardFormat::Rtf) => String::from_utf8(data).ok().map(ClipboardData::Rtf), Ok(ClipboardFormat::Html) => String::from_utf8(data).ok().map(ClipboardData::Html), - Ok(ClipboardFormat::ImageRgba) => Some(ClipboardData::Image(arboard::ImageData::rgba( - clipboard.width as _, - clipboard.height as _, - data.into(), - ))), + Ok(ClipboardFormat::ImageRgba) => { + let (width, height) = + super::valid_rgba_dimensions(clipboard.width, clipboard.height, data.len())?; + Some(ClipboardData::Image(arboard::ImageData::rgba( + width, + height, + data.into(), + ))) + } Ok(ClipboardFormat::ImagePng) => { Some(ClipboardData::Image(arboard::ImageData::png(data.into()))) } @@ -770,6 +785,22 @@ mod proto { } } +#[cfg(all(test, not(target_os = "android")))] +mod rgba_tests { + use super::valid_rgba_dimensions; + + #[test] + fn validates_dimensions_against_content_length() { + assert_eq!(valid_rgba_dimensions(1, 1, 4), Some((1, 1))); + assert_eq!(valid_rgba_dimensions(1, 1, 3), None); + assert_eq!(valid_rgba_dimensions(-1, 1, 4), None); + assert_eq!(valid_rgba_dimensions(0, 1, 0), None); + assert_eq!(valid_rgba_dimensions(i32::MAX, i32::MAX, 4), None); + #[cfg(target_pointer_width = "32")] + assert_eq!(valid_rgba_dimensions(i32::MAX, 2, 0), None); + } +} + #[cfg(target_os = "android")] pub fn handle_msg_clipboard(mut cb: Clipboard) { use hbb_common::protobuf::Message; diff --git a/src/common.rs b/src/common.rs index b875d548c..cd35433e0 100644 --- a/src/common.rs +++ b/src/common.rs @@ -1024,7 +1024,7 @@ pub fn get_full_name() -> String { } pub fn is_setup(name: &str) -> bool { - name.to_lowercase().ends_with("install.exe") + !config::is_disable_installation() && name.to_lowercase().ends_with("install.exe") } pub fn get_custom_rendezvous_server(custom: String) -> String { @@ -2623,6 +2623,20 @@ pub fn is_direct_ip_access(peer: &str) -> bool { hbb_common::is_ip_str(peer) || hbb_common::is_domain_port_str(peer) } +// Align the maximum length of the peer id to the maximum length of the peer id in the server. +const MAX_UNTRUSTED_PEER_ID_LEN: usize = 253; +const UNTRUSTED_PEER_ID_FORBIDDEN_CHARS: &[char] = &['"', '<', '>', '/', '\\', '|', '?', '*']; + +// Shared validation for peer/connect ids that cross untrusted boundaries before +// they are stored or written into command/script contexts. +pub fn is_valid_untrusted_peer_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= MAX_UNTRUSTED_PEER_ID_LEN + && !id.chars().any(|ch| { + ch.is_control() || ch.is_whitespace() || UNTRUSTED_PEER_ID_FORBIDDEN_CHARS.contains(&ch) + }) +} + #[cfg(test)] mod tests { use super::*; @@ -2653,6 +2667,29 @@ mod tests { ) } + #[test] + fn untrusted_peer_id_validation() { + let cases = [ + ("123456789", true), + ("m\u{00FC}nchen-pc", true), + ("192.168.1.10:21118", true), + ("9123456234@public", true), + ( + r#"1" & oWS.Run("cmd.exe /k whoami /priv",1,False) & ""#, + false, + ), + ("", false), + ("peer id", false), + ("peer\nid", false), + ("peer/id", false), + ("peer?id", false), + ]; + + for (id, expected) in cases { + assert_eq!(is_valid_untrusted_peer_id(id), expected, "{id:?}"); + } + } + // ThrottledInterval tick at the same time as tokio interval, if no sleeps #[allow(non_snake_case)] #[tokio::test] diff --git a/src/core_main.rs b/src/core_main.rs index 6b437a988..b20ecd92b 100644 --- a/src/core_main.rs +++ b/src/core_main.rs @@ -127,6 +127,13 @@ pub fn core_main() -> Option> { if args.contains(&"--noinstall".to_string()) { args.clear(); } + // The portable wrapper injects `--install` when its name ends with `install.exe`, + // including `no-install.exe`. Drop the argument instead of exiting so disabled + // clients can continue running as portable applications. + if config::is_disable_installation() { + args.retain(|arg| arg != "--install"); + flutter_args.retain(|arg| arg != "--install"); + } if args.len() > 0 { if args[0] == "--version" { println!("{}", crate::VERSION); @@ -660,7 +667,8 @@ pub fn core_main() -> Option> { None } }; - let new_id = get_value("--id"); + // An empty --id (e.g. an unset var) would deploy a blank id; the Android flow guards this too (#15146). + let new_id = get_value("--id").filter(|s| !s.is_empty()); match crate::ui_interface::deploy_device(token, new_id) { crate::ui_interface::DeployResult::Ok => { println!("Device deployed."); diff --git a/src/flutter.rs b/src/flutter.rs index e6b325cbe..a07d7c598 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -136,6 +136,12 @@ pub extern "C" fn rustdesk_core_main_args(args_len: *mut c_int) -> *mut *mut c_c return std::ptr::null_mut() as _; } +#[cfg(windows)] +#[no_mangle] +pub extern "C" fn rustdesk_is_disable_installation() -> c_int { + hbb_common::config::is_disable_installation() as c_int +} + // https://gist.github.com/iskakaushik/1c5b8aa75c77479c33c4320913eebef6 #[cfg(windows)] fn rust_args_to_c_args(args: Vec, outlen: *mut c_int) -> *mut *mut c_char { diff --git a/src/ipc.rs b/src/ipc.rs index 68c987f4e..4498ceb5f 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -41,6 +41,8 @@ pub(crate) use ipc_auth::ensure_peer_executable_matches_current_by_pid_opt; pub(crate) use ipc_auth::log_rejected_windows_ipc_connection; #[cfg(any(target_os = "linux", target_os = "macos"))] use ipc_auth::{active_uid, authorize_service_scoped_ipc_connection}; +#[cfg(target_os = "macos")] +use ipc_auth::authorize_user_server_process; #[cfg(windows)] use ipc_auth::{ authorize_windows_main_ipc_connection, portable_service_listener_security_attributes, @@ -472,6 +474,8 @@ pub enum Data { #[cfg(target_os = "windows")] PortForwardSessionCount(Option), SocksWs(Option, String)>>), + #[cfg(target_os = "macos")] + HasNoActiveConns(Option), #[cfg(not(any(target_os = "android", target_os = "ios")))] Whiteboard((String, crate::whiteboard::CustomEvent)), ControlPermissionsRemoteModify(Option), @@ -881,8 +885,14 @@ async fn handle(data: Data, stream: &mut Connection) { Some(value) => { let mut updated = true; if name == "id" { - Config::set_key_confirmed(false); - Config::set_id(&value); + // An empty id would wipe the local id and unconfirm the key (cf. #15626). + if value.is_empty() { + log::warn!("Ignoring empty id write over IPC"); + updated = false; + } else { + Config::set_key_confirmed(false); + Config::set_id(&value); + } } else if name == "temporary-password" { password::update_temporary_password(); } else if name == "permanent-password" { @@ -1000,6 +1010,16 @@ async fn handle(data: Data, stream: &mut Connection) { .await ); } + #[cfg(target_os = "macos")] + Data::HasNoActiveConns(None) => { + allow_err!( + stream + .send(&Data::HasNoActiveConns(Some( + crate::updater::has_no_active_conns() + ))) + .await + ); + } #[cfg(all( feature = "flutter", not(any(target_os = "android", target_os = "ios")) @@ -1334,14 +1354,21 @@ pub async fn connect(ms_timeout: u64, postfix: &str) -> ResultType ResultType> { let path = Config::ipc_path_for_uid(uid, postfix); - connect_with_path(ms_timeout, &path).await + let conn = connect_with_path(ms_timeout, &path).await?; + #[cfg(target_os = "macos")] + if postfix.is_empty() + && !authorize_user_server_process(conn.peer_uid(), conn.peer_pid(), uid) + { + bail!("Rejected user IPC peer for uid {}", uid); + } + Ok(conn) } #[cfg(target_os = "linux")] @@ -1689,19 +1716,24 @@ pub fn clear_trusted_devices() { } pub fn get_id() -> String { + // An empty id may come from a process that took over the main IPC with a + // config scope that has no id yet (e.g. a user GUI that became the server + // while the installed service was restarting). Treat it as no answer, + // otherwise the empty id is adopted below and wipes the local one. if let Ok(Some(v)) = get_config("id") { - // update salt also, so that next time reinstallation not causing first-time auto-login failure - if let Ok(Some(v2)) = get_config("salt") { - Config::set_salt(&v2); + if !v.is_empty() { + // update salt also, so that next time reinstallation not causing first-time auto-login failure + if let Ok(Some(v2)) = get_config("salt") { + Config::set_salt(&v2); + } + if v != Config::get_id() { + Config::set_key_confirmed(false); + Config::set_id(&v); + } + return v; } - if v != Config::get_id() { - Config::set_key_confirmed(false); - Config::set_id(&v); - } - v - } else { - Config::get_id() } + Config::get_id() } pub async fn get_rendezvous_server(ms_timeout: u64) -> (String, Vec) { diff --git a/src/ipc/auth.rs b/src/ipc/auth.rs index 77fd148c6..0dd43855e 100644 --- a/src/ipc/auth.rs +++ b/src/ipc/auth.rs @@ -656,6 +656,32 @@ pub(crate) fn authorize_service_scoped_ipc_connection(stream: &Connection, postf true } +#[cfg(target_os = "macos")] +pub(crate) fn authorize_user_server_process( + peer_uid: Option, + peer_pid: Option, + expected_uid: u32, +) -> bool { + if peer_uid != Some(expected_uid) { + return false; + } + let Some(peer_pid) = peer_pid else { + return false; + }; + let Ok(peer_exe) = peer_exe_canonical_path_by_pid(peer_pid) else { + return false; + }; + let expected_path = PathBuf::from(format!( + "/Applications/{}.app/Contents/MacOS/{}", + crate::get_app_name(), + crate::get_app_name() + )); + let Ok(expected_path) = fs::canonicalize(expected_path) else { + return false; + }; + paths_refer_to_same_file(&peer_exe, &expected_path) +} + #[cfg(windows)] pub(crate) fn authorize_windows_main_ipc_connection(stream: &Connection, postfix: &str) -> bool { let ( diff --git a/src/lan.rs b/src/lan.rs index 38c31adf9..2a648aab0 100644 --- a/src/lan.rs +++ b/src/lan.rs @@ -241,6 +241,14 @@ fn wait_response( Some(rendezvous_message::Union::PeerDiscovery(p)) => { last_recv_time = Instant::now(); if p.cmd == "pong" { + if !crate::common::is_valid_untrusted_peer_id(&p.id) { + log::warn!( + "Ignoring LAN discovery response from {} with invalid peer id", + addr + ); + continue; + } + let local_mac = if try_get_ip_by_peer { if let Some(self_addr) = get_ipaddr_by_peer(&addr) { get_mac(&self_addr) diff --git a/src/lang/cn.rs b/src/lang/cn.rs index 8d931b32d..dff0a2e2d 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -332,7 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "中继连接"), ("Secure Connection", "安全连接"), ("Insecure Connection", "非安全连接"), - ("Continue", ""), + ("Continue", "继续"), ("Scale original", "原始尺寸"), ("Scale adaptive", "适应窗口"), ("General", "常规"), diff --git a/src/lang/pl.rs b/src/lang/pl.rs index b91be06b0..8971a31b6 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -332,7 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Połączenie przez bramkę"), ("Secure Connection", "Połączenie szyfrowane"), ("Insecure Connection", "Połączenie nieszyfrowane"), - ("Continue", ""), + ("Continue", "Kontynuuj"), ("Scale original", "Skalowanie oryginalne"), ("Scale adaptive", "Dopasuj do wyświetlacza"), ("General", "Ogólne"), diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 9a4bb37ec..ab6b1879b 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -646,6 +646,16 @@ fn try_start_server_(desktop: Option<&Desktop>) -> ResultType> { if !desktop.dbus.is_empty() { envs.push(("DBUS_SESSION_BUS_ADDRESS", desktop.dbus.clone())); } + if let Ok(forced_display_server) = + std::env::var("RUSTDESK_FORCED_DISPLAY_SERVER") + { + if !forced_display_server.is_empty() { + envs.push(( + "RUSTDESK_FORCED_DISPLAY_SERVER", + forced_display_server, + )); + } + } envs.push(( "TERM", get_cur_term(&desktop.uid).unwrap_or_else(|| suggest_best_term()), diff --git a/src/platform/macos.mm b/src/platform/macos.mm index 3303855a6..d4038da8e 100644 --- a/src/platform/macos.mm +++ b/src/platform/macos.mm @@ -118,16 +118,18 @@ extern "C" bool MacCheckAdminAuthorization() { // https://gist.github.com/briankc/025415e25900750f402235dbf1b74e42 extern "C" float BackingScaleFactor(uint32_t display) { - NSArray *screens = [NSScreen screens]; - for (NSScreen *screen in screens) { - NSDictionary *deviceDescription = [screen deviceDescription]; - NSNumber *screenNumber = [deviceDescription objectForKey:@"NSScreenNumber"]; - CGDirectDisplayID screenDisplayID = [screenNumber unsignedIntValue]; - if (screenDisplayID == display) { - return [screen backingScaleFactor]; + @autoreleasepool { + NSArray *screens = [NSScreen screens]; + for (NSScreen *screen in screens) { + NSDictionary *deviceDescription = [screen deviceDescription]; + NSNumber *screenNumber = [deviceDescription objectForKey:@"NSScreenNumber"]; + CGDirectDisplayID screenDisplayID = [screenNumber unsignedIntValue]; + if (screenDisplayID == display) { + return [screen backingScaleFactor]; + } } + return 1; } - return 1; } // https://github.com/jhford/screenresolution/blob/master/cg_utils.c diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 2e68cf5d8..4f85a4b0f 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -312,6 +312,55 @@ fn correct_app_name(s: &str) -> String { s } +fn write_plist_atomically(path: &str, body: &str) -> ResultType<()> { + use std::io::Write; + use std::os::unix::fs::PermissionsExt; + + let temporary = format!("{}.tmp.{}", path, std::process::id()); + let result = (|| { + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary)?; + file.set_permissions(std::fs::Permissions::from_mode(0o644))?; + file.write_all(body.as_bytes())?; + file.sync_all()?; + std::fs::rename(&temporary, path)?; + Ok::<(), std::io::Error>(()) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temporary); + } + result.map_err(Into::into) +} + +pub fn write_plists() -> ResultType<()> { + let daemon_plist_path = format!( + "/Library/LaunchDaemons/com.carriez.{}_service.plist", + crate::get_app_name() + ); + let agent_plist_path = format!( + "/Library/LaunchAgents/com.carriez.{}_server.plist", + crate::get_app_name() + ); + let Some(daemon_plist) = PRIVILEGES_SCRIPTS_DIR.get_file("daemon.plist") else { + bail!("daemon.plist not found in embedded resources"); + }; + let Some(daemon_plist_body) = daemon_plist.contents_utf8().map(correct_app_name) else { + bail!("Failed to read daemon.plist"); + }; + let Some(agent_plist) = PRIVILEGES_SCRIPTS_DIR.get_file("agent.plist") else { + bail!("agent.plist not found in embedded resources"); + }; + let Some(agent_plist_body) = agent_plist.contents_utf8().map(correct_app_name) else { + bail!("Failed to read agent.plist"); + }; + write_plist_atomically(&daemon_plist_path, &daemon_plist_body)?; + write_plist_atomically(&agent_plist_path, &agent_plist_body)?; + log::info!("[write-plists] Wrote daemon and agent plists"); + Ok(()) +} + pub fn uninstall_service(show_new_window: bool, sync: bool) -> bool { // to-do: do together with win/linux about refactory start/stop service if !is_installed_daemon(false) { @@ -659,6 +708,61 @@ pub fn get_active_userid() -> String { get_active_user("-n") } +/// Return every UID with a login-window/session entry. Fast user switching +/// can leave several GUI bootstrap domains alive at once, so updating only +/// the console user can leave another user's agent on the old bundle. +pub(crate) fn get_logged_in_uids() -> Vec { + let mut uids = std::collections::BTreeSet::new(); + if let Ok(output) = std::process::Command::new("/usr/bin/who").output() { + for line in String::from_utf8_lossy(&output.stdout).lines() { + let Some(username) = line.split_whitespace().next() else { + continue; + }; + let Ok(output) = std::process::Command::new("/usr/bin/id") + .args(["-u", username]) + .output() + else { + continue; + }; + let Ok(uid) = String::from_utf8_lossy(&output.stdout) + .trim() + .parse::() + else { + continue; + }; + let gui_domain = format!("gui/{}", uid); + if std::process::Command::new("/bin/launchctl") + .args(["print", &gui_domain]) + .output() + .is_ok_and(|output| output.status.success()) + { + uids.insert(uid); + } + } + } + if let Ok(active_uid) = get_active_userid().parse::() { + if active_uid == 0 { + // UID 0 owns /dev/console while the LoginWindow session is active. + // Query that server even when fast-switched GUI domains also exist. + uids.insert(0); + } else { + let gui_domain = format!("gui/{}", active_uid); + if std::process::Command::new("/bin/launchctl") + .args(["print", &gui_domain]) + .output() + .is_ok_and(|output| output.status.success()) + { + uids.insert(active_uid); + } + } + } + if uids.is_empty() { + // The login window has no ordinary gui/0 bootstrap domain. + uids.insert(0); + } + uids.into_iter().collect() +} + pub fn get_active_user_home() -> Option { let username = get_active_username(); if !username.is_empty() { @@ -728,8 +832,12 @@ pub fn lock_screen() { .ok(); } +/// Starts the macOS system service IPC listener and the background +/// silent auto-update thread. pub fn start_os_service() { log::info!("Username: {}", crate::username()); + // Silent auto-update — runs as root via LaunchDaemon, no osascript dialog needed + crate::updater::start_auto_update_macos(); if let Err(err) = crate::ipc::start("_service") { log::error!("Failed to start ipc_service: {}", err); } @@ -912,6 +1020,760 @@ pub fn update_to(_file: &str) -> ResultType<()> { Ok(()) } +fn backup_update_plist(source: &str, backup: &str) -> ResultType<()> { + match std::fs::symlink_metadata(source) { + Ok(metadata) => { + if !metadata.file_type().is_file() { + bail!("[root-update] plist is not a regular file: {}", source); + } + std::fs::copy(source, backup)?; + Ok(()) + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + bail!("[root-update] required installed plist is missing: {}", source) + } + Err(err) => Err(err.into()), + } +} + +fn validate_update_tree(path: &Path, framework_root: Option<&Path>) -> ResultType<()> { + let metadata = std::fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() { + // Frameworks legitimately use internal symlinks (Resources, + // Versions/Current), but never allow a link to leave its framework. + let Some(framework_root) = framework_root else { + bail!("[root-update] symlink outside framework: {}", path.display()); + }; + let target = std::fs::read_link(path)?; + let target = if target.is_absolute() { + target + } else { + path.parent().unwrap_or(Path::new("/")).join(target) + }; + let target = std::fs::canonicalize(target)?; + let framework_root = std::fs::canonicalize(framework_root)?; + if target.starts_with(&framework_root) { + return Ok(()); + } + bail!("[root-update] symlink in update bundle: {}", path.display()); + } + if metadata.file_type().is_dir() { + for entry in std::fs::read_dir(path)? { + let child = entry?.path(); + let child_framework_root = if child + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".framework")) + { + Some(child.as_path()) + } else { + framework_root + }; + validate_update_tree(&child, child_framework_root)?; + } + } else if !metadata.file_type().is_file() { + bail!("[root-update] unsupported file in update bundle: {}", path.display()); + } + Ok(()) +} + +/// Performs a silent update from a DMG file without any osascript dialog. +/// Must be called from a process running as root (e.g. the service binary). +pub fn update_from_dmg_as_root(dmg_path: &str, expected_version: &str) -> ResultType<()> { + let app_name = crate::get_app_name(); + if app_name.is_empty() + || !app_name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + bail!("[root-update] unsafe application name"); + } + let app_bundle = format!("/Applications/{}.app", app_name); + let tmp_dir_output = std::process::Command::new("/usr/bin/mktemp") + .args(&["-d", "/tmp/.rustdeskupdate-root-XXXXXX"]) + .output()?; + let tmp_dir = String::from_utf8(tmp_dir_output.stdout) + .map_err(|e| anyhow!("[root-update] mktemp output error: {}", e))? + .trim() + .to_string(); + if tmp_dir.is_empty() { + bail!("[root-update] Failed to create temp directory"); + } + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&tmp_dir, std::fs::Permissions::from_mode(0o700))?; + } + let agent_plist = format!("/Library/LaunchAgents/com.carriez.{}_server.plist", app_name); + let daemon_plist = format!("/Library/LaunchDaemons/com.carriez.{}_service.plist", app_name); + + log::info!("[root-update] Starting silent root update from {}", dmg_path); + // Check sessions before extracting to avoid unnecessary work + if !crate::updater::has_no_active_conns_ipc() { + bail!("[root-update] Active session detected, deferring update."); + } + // Extract DMG to temp dir + extract_dmg_into_existing_dir(dmg_path, &tmp_dir)?; + let src_app = format!("{}/{}.app", tmp_dir, app_name); + log::info!("[root-update] DMG extracted to {}", tmp_dir); + validate_update_tree(Path::new(&src_app), None)?; + + // Bind the downloaded asset to the version returned by the update + // service before changing plists or executing anything from the staged + // bundle. A release asset with the right filename but the wrong bundle + // must not be allowed to replace the installed application. + let info_plist = format!("{}/Contents/Info.plist", src_app); + let staged_version_result = (|| -> ResultType { + let output = Command::new("/usr/libexec/PlistBuddy") + .args(["-c", "Print :CFBundleShortVersionString", &info_plist]) + .output()?; + if !output.status.success() { + bail!( + "[root-update] failed to read staged bundle version: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + let version = String::from_utf8(output.stdout) + .map_err(|err| anyhow!("[root-update] staged bundle version is not UTF-8: {}", err))?; + if version.trim().is_empty() { + bail!("[root-update] staged bundle version is empty"); + } + Ok(version.trim().to_owned()) + })(); + let staged_version = match staged_version_result { + Ok(version) => version, + Err(err) => { + if let Err(cleanup_err) = std::fs::remove_dir_all(&tmp_dir) { + log::warn!( + "[root-update] Failed to remove temp dir {}: {}", + tmp_dir, + cleanup_err + ); + } + return Err(err); + } + }; + if staged_version != expected_version { + if let Err(err) = std::fs::remove_dir_all(&tmp_dir) { + log::warn!( + "[root-update] Failed to remove temp dir {}: {}", + tmp_dir, + err + ); + } + bail!( + "[root-update] staged bundle version mismatch: expected {:?}, found {:?}", + expected_version, + staged_version + ); + } + + // A leftover backup makes `mv app app.bak` nest the live bundle inside + // the old directory instead of creating a transaction backup. Never + // overwrite or guess at recovery state left by an earlier interrupted + // update; require an administrator to inspect it first. + let app_backup = format!("{}.bak", app_bundle); + let failed_bundle = format!("{}.failed-update", app_bundle); + for recovery_path in [&app_backup, &failed_bundle] { + match std::fs::symlink_metadata(recovery_path) { + Ok(_) => { + let _ = std::fs::remove_dir_all(&tmp_dir); + bail!( + "[root-update] stale application recovery path requires inspection: {}", + recovery_path + ); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + let _ = std::fs::remove_dir_all(&tmp_dir); + return Err(err.into()); + } + } + } + + // Backup current plists before overwriting — needed for restore on reload failure + let daemon_plist_bak = format!("{}/daemon_plist.bak", tmp_dir); + let agent_plist_bak = format!("{}/agent_plist.bak", tmp_dir); + // Backups are part of the update transaction. Do not allow the new + // service binary to overwrite either live plist unless both installed + // definitions have been captured successfully. + backup_update_plist(&daemon_plist, &daemon_plist_bak)?; + backup_update_plist(&agent_plist, &agent_plist_bak)?; + + // Ensure the staged release contains the service executable before we + // proceed. Plist generation itself is done in this already-root process; + // launching a freshly extracted service binary from /tmp is not required. + let new_service = format!("{}/Contents/MacOS/service", src_app); + if !std::path::Path::new(&new_service).is_file() { + bail!("[root-update] staged service binary is missing: {}", new_service); + } + // The new binary writes its own plist definitions after the bundle is + // moved into its final root-owned location. This avoids executing code + // directly from /tmp while ensuring the plist matches the new release. + + // Final session check after extraction — minimize race window + if !crate::updater::has_no_active_conns_ipc() { + let _ = std::fs::remove_dir_all(&tmp_dir); + bail!("[root-update] Active session detected after extraction, deferring update."); + } + + // Let the detached-script launch settle before taking the affected-user + // snapshot. The final IPC check then happens after the delay and as close + // as possible to stopping those exact launchd domains. + std::thread::sleep(std::time::Duration::from_secs(3)); + if !crate::updater::has_no_active_conns_ipc() { + bail!("[root-update] active session started before update launch"); + } + let logged_in_uids = get_logged_in_uids(); + // UIDs are parsed as integers before embedding in the root-run shell + // script, so they cannot alter its command structure. + let uid_list = logged_in_uids + .iter() + .map(u32::to_string) + .collect::>() + .join(" "); + + // Write a shell script that runs detached after this function returns. + // We cannot directly replace /Applications/RustDesk.app while it is running, + // so we spawn a script that waits, kills processes, copies, and restarts. + let daemon_label = format!("com.carriez.{}_service", app_name); + let agent_label = format!("com.carriez.{}_server", app_name); + let script_path = format!("{}/rustdesk_update.sh", tmp_dir); + let script = format!( + r#"#!/bin/sh +rollback_done=0 +bundle_swapped=0 +bootstrap_agent() {{ + agent_uid="$1" + if [ "$agent_uid" != "0" ]; then + launchctl bootstrap gui/"$agent_uid" "{agent_plist}" 2>/dev/null || \ + launchctl bootstrap user/"$agent_uid" "{agent_plist}" 2>/dev/null || \ + launchctl load -w "{agent_plist}" 2>/dev/null + else + # At the login window there is no gui/0 domain. launchctl load uses + # the plist's LoginWindow/Aqua session policy instead. + launchctl load -w -S LoginWindow "{agent_plist}" 2>/dev/null || \ + launchctl load -w "{agent_plist}" 2>/dev/null + fi +}} +bootstrap_agents() {{ + for agent_uid in {uid_list}; do + bootstrap_agent "$agent_uid" || return 1 + done +}} +loginwindow_asid() {{ + root_user_info=$(launchctl print user/0 2>/dev/null || true) + root_login_asid=$(printf '%s\n' "$root_user_info" | \ + awk '/^[[:space:]]*asid = [0-9]+[[:space:]]*$/ {{print $3; exit}}') + case "$root_login_asid" in + ''|*[!0-9]*) return 1 ;; + esac + printf '%s\n' "$root_login_asid" +}} +bootout_agents() {{ + # Legacy launchctl commands can report success despite operational + # failure. Treat these as requests; stop_agents verifies the result. + stopping_loginwindow_asid="" + for agent_uid in {uid_list}; do + if [ "$agent_uid" != "0" ]; then + launchctl bootout gui/"$agent_uid"/{agent_label} 2>/dev/null || true + launchctl bootout user/"$agent_uid"/{agent_label} 2>/dev/null || true + else + # LoginWindow jobs run in a login/ domain even though + # legacy root `launchctl load` is issued from the system context. + # Remove every applicable registration before killing the process + # so KeepAlive cannot immediately respawn it. + launchctl unload -w -S LoginWindow "{agent_plist}" 2>/dev/null || true + stopping_loginwindow_asid=$(loginwindow_asid || true) + if [ -n "$stopping_loginwindow_asid" ]; then + launchctl bootout login/"$stopping_loginwindow_asid"/{agent_label} 2>/dev/null || true + fi + launchctl bootout user/0/{agent_label} 2>/dev/null || true + launchctl bootout system/{agent_label} 2>/dev/null || true + launchctl unload -w "{agent_plist}" 2>/dev/null || true + fi + done +}} +find_agent_pid() {{ + agent_uid="$1" + for candidate_pid in $(pgrep -u "$agent_uid" -x {app_name} 2>/dev/null || true); do + process_args=$(ps -p "$candidate_pid" -o args= 2>/dev/null || true) + if printf '%s\n' "$process_args" | grep -F "/Applications/{app_name}.app/Contents/MacOS/{app_name}" >/dev/null && \ + printf '%s\n' "$process_args" | grep -E '(^|[[:space:]])--server([[:space:]]|$)' >/dev/null; then + printf '%s\n' "$candidate_pid" + return 0 + fi + done + return 1 +}} +launchd_agent_pid() {{ + agent_uid="$1" + agent_info=$(launchctl print gui/"$agent_uid"/{agent_label} 2>/dev/null || \ + launchctl print user/"$agent_uid"/{agent_label} 2>/dev/null || true) + agent_job_pid=$(printf '%s\n' "$agent_info" | awk '/^[[:space:]]*pid = / {{print $3; exit}}') + if [ -n "$agent_job_pid" ] && \ + printf '%s\n' "$agent_info" | grep -E '^[[:space:]]*state = running[[:space:]]*$' >/dev/null; then + printf '%s\n' "$agent_job_pid" + return 0 + fi + return 1 +}} +agent_pid_for_uid() {{ + agent_uid="$1" + if [ "$agent_uid" = "0" ]; then + # LoginWindow agents have no ordinary gui/0 bootstrap domain. Locate + # the root-owned --server process and validate it below instead. + find_agent_pid "$agent_uid" + else + launchd_agent_pid "$agent_uid" + fi +}} +agent_process_matches() {{ + agent_uid="$1" + agent_pid="$2" + process_uid=$(ps -p "$agent_pid" -o uid= 2>/dev/null | tr -d '[:space:]') + process_args=$(ps -p "$agent_pid" -o args= 2>/dev/null || true) + [ "$process_uid" = "$agent_uid" ] && \ + printf '%s\n' "$process_args" | grep -F "/Applications/{app_name}.app/Contents/MacOS/{app_name}" >/dev/null && \ + printf '%s\n' "$process_args" | grep -E '(^|[[:space:]])--server([[:space:]]|$)' >/dev/null +}} +capture_stopping_agent_pids() {{ + stopping_agent_pids="" + for agent_uid in {uid_list}; do + for candidate_pid in $(pgrep -u "$agent_uid" -x {app_name} 2>/dev/null || true); do + if agent_process_matches "$agent_uid" "$candidate_pid"; then + stopping_agent_pids="$stopping_agent_pids $candidate_pid" + fi + done + done +}} +terminate_agent_processes() {{ + for agent_uid in {uid_list}; do + for candidate_pid in $(pgrep -u "$agent_uid" -x {app_name} 2>/dev/null || true); do + if agent_process_matches "$agent_uid" "$candidate_pid"; then + kill -KILL "$candidate_pid" 2>/dev/null || true + fi + done + done +}} +terminate_user_bundle_processes() {{ + for agent_uid in {uid_list}; do + for candidate_pid in $(pgrep -u "$agent_uid" -x {app_name} 2>/dev/null || true); do + process_args=$(ps -p "$candidate_pid" -o args= 2>/dev/null || true) + if printf '%s\n' "$process_args" | grep -F "/Applications/{app_name}.app/" >/dev/null; then + kill -KILL "$candidate_pid" 2>/dev/null || true + fi + done + done +}} +user_bundle_processes_absent() {{ + for agent_uid in {uid_list}; do + for candidate_pid in $(pgrep -u "$agent_uid" -x {app_name} 2>/dev/null || true); do + process_args=$(ps -p "$candidate_pid" -o args= 2>/dev/null || true) + if printf '%s\n' "$process_args" | grep -F "/Applications/{app_name}.app/" >/dev/null; then + return 1 + fi + done + done + return 0 +}} +stop_user_bundle_processes() {{ + terminate_user_bundle_processes + for _ in $(/usr/bin/seq 1 30); do + if user_bundle_processes_absent; then + sleep 2 + user_bundle_processes_absent && return 0 + fi + terminate_user_bundle_processes + sleep 1 + done + return 1 +}} +agent_jobs_absent() {{ + for agent_uid in {uid_list}; do + if [ "$agent_uid" != "0" ]; then + if launchctl print gui/"$agent_uid"/{agent_label} >/dev/null 2>&1 || \ + launchctl print user/"$agent_uid"/{agent_label} >/dev/null 2>&1; then + return 1 + fi + else + if launchctl print system/{agent_label} >/dev/null 2>&1 || \ + launchctl print user/0/{agent_label} >/dev/null 2>&1; then + return 1 + fi + if [ -n "$stopping_loginwindow_asid" ] && \ + launchctl print login/"$stopping_loginwindow_asid"/{agent_label} >/dev/null 2>&1; then + return 1 + fi + fi + find_agent_pid "$agent_uid" >/dev/null 2>&1 && return 1 + done + return 0 +}} +captured_agent_pids_gone() {{ + for stopped_pid in $stopping_agent_pids; do + kill -0 "$stopped_pid" 2>/dev/null && return 1 + done + return 0 +}} +agents_stopped() {{ + captured_agent_pids_gone && agent_jobs_absent +}} +stop_agents() {{ + bootout_agents + terminate_agent_processes + for _ in $(/usr/bin/seq 1 30); do + if agents_stopped; then + sleep 2 + agents_stopped && return 0 + fi + terminate_agent_processes + sleep 1 + done + return 1 +}} +capture_agent_snapshot() {{ + agent_pids="" + for agent_uid in {uid_list}; do + agent_pid=$(agent_pid_for_uid "$agent_uid" || true) + [ -n "$agent_pid" ] || return 1 + [ -S "/tmp/{app_name}-$agent_uid/ipc" ] || return 1 + kill -0 "$agent_pid" 2>/dev/null || return 1 + agent_process_matches "$agent_uid" "$agent_pid" || return 1 + agent_pids="$agent_pids $agent_uid:$agent_pid" + done + return 0 +}} +agent_snapshot_stable() {{ + for agent_entry in $agent_pids; do + agent_uid=$(printf '%s\n' "$agent_entry" | cut -d: -f1) + expected_pid=$(printf '%s\n' "$agent_entry" | cut -d: -f2) + current_pid=$(agent_pid_for_uid "$agent_uid" || true) + [ -n "$current_pid" ] && [ "$current_pid" = "$expected_pid" ] || return 1 + [ -S "/tmp/{app_name}-$agent_uid/ipc" ] || return 1 + kill -0 "$current_pid" 2>/dev/null || return 1 + agent_process_matches "$agent_uid" "$current_pid" || return 1 + done + return 0 +}} +agent_ready() {{ + for _ in $(/usr/bin/seq 1 30); do + if capture_agent_snapshot; then + sleep 2 + agent_snapshot_stable && return 0 + fi + sleep 1 + done + return 1 +}} +daemon_snapshot_stable() {{ + stable_daemon_info=$(launchctl print system/{daemon_label} 2>/dev/null || true) + stable_daemon_pid=$(printf '%s\n' "$stable_daemon_info" | awk '/^[[:space:]]*pid = / {{print $3; exit}}') + [ -n "$daemon_pid" ] && \ + [ "$stable_daemon_pid" = "$daemon_pid" ] && \ + printf '%s\n' "$stable_daemon_info" | grep -E '^[[:space:]]*state = running[[:space:]]*$' >/dev/null && \ + [ -S "/tmp/{app_name}-service/ipc_service" ] && \ + kill -0 "$daemon_pid" 2>/dev/null +}} +daemon_ready() {{ + daemon_pid="" + for _ in $(/usr/bin/seq 1 30); do + daemon_info=$(launchctl print system/{daemon_label} 2>/dev/null || true) + daemon_pid=$(printf '%s\n' "$daemon_info" | awk '/^[[:space:]]*pid = / {{print $3; exit}}') + if [ -n "$daemon_pid" ] && \ + printf '%s\n' "$daemon_info" | grep -E '^[[:space:]]*state = running[[:space:]]*$' >/dev/null && \ + [ -S "/tmp/{app_name}-service/ipc_service" ] && \ + kill -0 "$daemon_pid" 2>/dev/null; then + sleep 2 + daemon_snapshot_stable && return 0 + fi + sleep 1 + done + return 1 +}} +capture_stopping_daemon_pid() {{ + stopping_daemon_info=$(launchctl print system/{daemon_label} 2>/dev/null || true) + stopping_daemon_pid=$(printf '%s\n' "$stopping_daemon_info" | awk '/^[[:space:]]*pid = / {{print $3; exit}}') +}} +daemon_stopped() {{ + if [ -n "$stopping_daemon_pid" ] && kill -0 "$stopping_daemon_pid" 2>/dev/null; then + return 1 + fi + ! launchctl print system/{daemon_label} >/dev/null 2>&1 +}} +stop_daemon() {{ + capture_stopping_daemon_pid + # Command status is advisory. daemon_stopped verifies that both the + # captured process generation and launchd registration are gone. + launchctl bootout system/{daemon_label} 2>/dev/null || \ + launchctl unload -w "{daemon_plist}" 2>/dev/null || true + for _ in $(/usr/bin/seq 1 30); do + if daemon_stopped; then + sleep 2 + daemon_stopped && return 0 + fi + sleep 1 + done + return 1 +}} +write_new_plists() {{ + /Applications/{app_name}.app/Contents/MacOS/service --write-plists \ + >"{tmp_dir}/write-plists.log" 2>&1 & + write_pid=$! + for _ in $(/usr/bin/seq 1 60); do + if ! kill -0 "$write_pid" 2>/dev/null; then + wait "$write_pid" + return $? + fi + sleep 1 + done + kill -TERM "$write_pid" 2>/dev/null || true + sleep 1 + kill -KILL "$write_pid" 2>/dev/null || true + wait "$write_pid" 2>/dev/null || true + return 124 +}} +restore_old_bundle() {{ + [ "$bundle_swapped" -eq 1 ] || return 0 + if [ ! -d "{app_bundle}.bak" ] || [ -L "{app_bundle}.bak" ]; then + echo "[root-update] CRITICAL: valid application backup is unavailable" >> {tmp_dir}/rustdesk_root_update.log + return 1 + fi + if [ -e "{app_bundle}" ] || [ -L "{app_bundle}" ]; then + if [ -e "{app_bundle}.failed-update" ] || [ -L "{app_bundle}.failed-update" ] || \ + ! mv "{app_bundle}" "{app_bundle}.failed-update"; then + echo "[root-update] CRITICAL: could not vacate failed bundle safely" >> {tmp_dir}/rustdesk_root_update.log + return 1 + fi + fi + if ! mv "{app_bundle}.bak" "{app_bundle}"; then + echo "[root-update] CRITICAL: failed to restore application bundle" >> {tmp_dir}/rustdesk_root_update.log + if [ ! -e "{app_bundle}" ] && [ ! -L "{app_bundle}" ]; then + mv "{app_bundle}.failed-update" "{app_bundle}" 2>/dev/null || true + fi + return 1 + fi + rm -rf "{app_bundle}.failed-update" 2>/dev/null || true + bundle_swapped=0 + return 0 +}} +rollback_transaction() {{ + # Rollback restores and verifies unattended service state. It does not + # guarantee relaunching GUI windows that were stopped by the transaction. + [ "$rollback_done" -eq 0 ] || return 0 + rollback_done=1 + restore_failed=0 + stop_daemon || restore_failed=1 + capture_stopping_agent_pids + stop_agents || restore_failed=1 + restore_old_bundle || restore_failed=1 + cp "{daemon_plist_bak}" "{daemon_plist}" || restore_failed=1 + cp "{agent_plist_bak}" "{agent_plist}" || restore_failed=1 + touch /var/root/.rustdeskupdate_failed || restore_failed=1 + if ! launchctl load -w "{daemon_plist}" 2>/dev/null && \ + ! launchctl bootstrap system "{daemon_plist}" 2>/dev/null; then + restore_failed=1 + fi + daemon_ready || restore_failed=1 + bootstrap_agents || restore_failed=1 + agent_ready || restore_failed=1 + if [ "$restore_failed" -eq 0 ] && \ + {{ ! daemon_snapshot_stable || ! agent_snapshot_stable; }}; then + restore_failed=1 + fi + if [ "$restore_failed" -ne 0 ]; then + echo "[root-update] CRITICAL: rollback restoration failed" >> {tmp_dir}/rustdesk_root_update.log + else + echo "[root-update] Rollback daemon and agents verified healthy" >> {tmp_dir}/rustdesk_root_update.log + fi +}} +trap rollback_transaction EXIT +gui_uids="" +for agent_uid in {uid_list}; do + for pid in $(pgrep -u "$agent_uid" -x {app_name} || true); do + process_args=$(ps -p "$pid" -o args= 2>/dev/null || true) + if printf '%s\n' "$process_args" | grep -F "/Applications/{app_name}.app/" >/dev/null && \ + ! printf '%s\n' "$process_args" | grep -E "(^|[[:space:]])(--server|--service|--update)([[:space:]]|$)" >/dev/null; then + gui_uids="$gui_uids $agent_uid" + break + fi + done +done +if ! capture_agent_snapshot; then + echo "[root-update] old LaunchAgent readiness check failed before shutdown" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +capture_stopping_agent_pids +if ! stop_daemon; then + echo "[root-update] daemon did not stop before bundle swap" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +if ! stop_agents; then + echo "[root-update] old LaunchAgent did not stop before bundle swap" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +# Agents have already been verified absent. Stop and verify any remaining GUI +# processes as well so no process keeps the old bundle mapped across the swap. +if ! stop_user_bundle_processes; then + echo "[root-update] RustDesk GUI process did not stop before bundle swap" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +staged_bundle="{tmp_dir}/staged.app" +if [ -e "$staged_bundle" ] || [ -L "$staged_bundle" ]; then + echo "[root-update] staged bundle path already exists, aborting" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +if ! ditto {src_app} "$staged_bundle" 2>/dev/null; then + echo "[root-update] ditto failed, aborting update" >> {tmp_dir}/rustdesk_root_update.log + rm -rf "$staged_bundle" + exit 1 +fi +# Validate staged bundle before atomic swap +if [ ! -d "$staged_bundle/Contents/MacOS" ] || \ + [ ! -f "$staged_bundle/Contents/MacOS/{app_name}" ] || \ + [ ! -f "$staged_bundle/Contents/MacOS/service" ] || \ + [ ! -f "$staged_bundle/Contents/Info.plist" ]; then + echo "[root-update] staged bundle validation failed, aborting" >> {tmp_dir}/rustdesk_root_update.log + rm -rf "$staged_bundle" + exit 1 +fi +if ! mv {app_bundle} {app_bundle}.bak; then + echo "[root-update] backup mv failed, aborting" >> {tmp_dir}/rustdesk_root_update.log + rm -rf "$staged_bundle" + exit 1 +fi +bundle_swapped=1 +if ! mv "$staged_bundle" {app_bundle}; then + echo "[root-update] replacement mv failed, restoring backup" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +# Install the entire bundle as root-owned. The LaunchDaemon executes code +# from this bundle, so no nested framework, helper, or resource may remain +# user-writable. +if ! chown -R root:wheel {app_bundle} || ! chmod -R go-w {app_bundle}; then + echo "[root-update] chown failed, restoring backup" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +xattr -r -d com.apple.quarantine {app_bundle} || true +# Keep root-executed files AND entire ancestor chain root-owned — prevent privilege escalation +if ! chown root:wheel {app_bundle} || \ + ! chmod 755 {app_bundle} || \ + ! chown root:wheel {app_bundle}/Contents || \ + ! chmod 755 {app_bundle}/Contents || \ + ! chown root:wheel {app_bundle}/Contents/MacOS || \ + ! chmod 755 {app_bundle}/Contents/MacOS || \ + ! chown root:wheel {app_bundle}/Contents/MacOS/service || \ + ! chmod 755 {app_bundle}/Contents/MacOS/service || \ + ! chown root:wheel {app_bundle}/Contents/MacOS/{app_name} || \ + ! chmod 755 {app_bundle}/Contents/MacOS/{app_name}; then + echo "[root-update] hardening failed, restoring backup" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +# Generate launchd definitions from the new, final-location binary. The +# subprocess is bounded and its output is retained for diagnosis; failure +# causes the existing bundle/plists to be restored by the EXIT trap. +if ! write_new_plists; then + echo "[root-update] CRITICAL: new binary failed to write plists" >> {tmp_dir}/rustdesk_root_update.log + cat "{tmp_dir}/write-plists.log" >> {tmp_dir}/rustdesk_root_update.log 2>/dev/null || true + exit 1 +fi +echo "[root-update] Plist definitions written by new binary" >> {tmp_dir}/rustdesk_root_update.log +# Check daemon registration and readiness BEFORE removing backup. launchctl +# load/bootstrap only registers the job; the service can still exit immediately. +if ! launchctl load -w {daemon_plist} 2>/dev/null && \ + ! launchctl bootstrap system {daemon_plist} 2>/dev/null; then + echo "[root-update] CRITICAL: daemon reload failed, restoring backup" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +if ! daemon_ready; then + echo "[root-update] CRITICAL: daemon failed readiness check, restoring" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +# Bootstrap agent BEFORE removing backup — needed for rollback on failure. +# This also uses launchctl load for the login-window/no-console-user case. +if ! bootstrap_agents || ! agent_ready; then + echo "[root-update] CRITICAL: agent bootstrap failed, rolling back" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +# Recheck daemon liveness after the agent is restored and immediately before +# deleting the only rollback bundle. +if ! daemon_snapshot_stable || ! agent_snapshot_stable; then + echo "[root-update] CRITICAL: daemon or agent stopped before commit, restoring" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +# Only remove backup after BOTH daemon AND agent confirmed running +rollback_done=1 +bundle_swapped=0 +if ! rm -rf "{app_bundle}.bak"; then + echo "[root-update] WARNING: committed update but could not remove backup" >> {tmp_dir}/rustdesk_root_update.log +fi +for gui_uid in $gui_uids; do + launchctl asuser "$gui_uid" open -a "{app_bundle}" || true +done +echo "[root-update] Done!" >> {tmp_dir}/rustdesk_root_update.log +rm -rf {tmp_dir} +"#, + app_name = app_name, + app_bundle = app_bundle, + src_app = src_app, + uid_list = uid_list, + daemon_plist = daemon_plist, + agent_plist = agent_plist, + tmp_dir = tmp_dir, + daemon_label = daemon_label, + agent_label = agent_label, + daemon_plist_bak = daemon_plist_bak, + agent_plist_bak = agent_plist_bak, + ); + + { + use std::io::Write; + if let Err(err) = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&script_path) + .and_then(|mut f| f.write_all(script.as_bytes())) + { + return Err(err.into()); + } + } + match Command::new("/bin/chmod") + .args(&["+x", &script_path]) + .status() + { + Ok(status) if status.success() => {} + Ok(status) => { + bail!( + "[root-update] failed to make update script executable: {}", + status + ); + } + Err(err) => { + return Err(err.into()); + } + } + // Reject session changes observed before launch, but this snapshot is + // best-effort: it is not atomic with shutdown in the detached script. + if get_logged_in_uids() != logged_in_uids { + bail!("[root-update] GUI session set changed before update launch"); + } + if !crate::updater::has_no_active_conns_ipc() { + bail!("[root-update] active session started before update launch"); + } + if let Err(err) = Command::new("/bin/bash") + .arg(&script_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .process_group(0) + .spawn() + { + return Err(err.into()); + } + + log::info!("[root-update] Update script launched."); + Ok(()) +} + pub fn extract_update_dmg(file: &str) { let update_temp_dir = get_update_temp_dir_string(); let mut evt: HashMap<&str, String> = @@ -931,37 +1793,63 @@ pub fn extract_update_dmg(file: &str) { } fn extract_dmg(dmg_path: &str, target_dir: &str) -> ResultType<()> { - let mount_point = "/Volumes/RustDeskUpdate"; let target_path = Path::new(target_dir); - if target_path.exists() { std::fs::remove_dir_all(target_path)?; } std::fs::create_dir_all(target_path)?; + extract_dmg_inner(dmg_path, target_dir) +} - let status = Command::new("hdiutil") - .args(&["attach", "-nobrowse", "-mountpoint", mount_point, dmg_path]) +fn extract_dmg_into_existing_dir(dmg_path: &str, target_dir: &str) -> ResultType<()> { + let target_path = Path::new(target_dir); + if !target_path.exists() { + bail!("[root-update] Temp directory does not exist: {:?}", target_path); + } + extract_dmg_inner(dmg_path, target_dir) +} + +fn extract_dmg_inner(dmg_path: &str, target_dir: &str) -> ResultType<()> { + let mount_output = Command::new("/usr/bin/mktemp") + .args(["-d", "/tmp/.rustdeskmount-XXXXXX"]) + .output()?; + if !mount_output.status.success() { + bail!("Failed to create a private DMG mount directory"); + } + let mount_point = String::from_utf8(mount_output.stdout) + .map_err(|e| anyhow!("Invalid DMG mount directory: {}", e))? + .trim() + .to_owned(); + if mount_point.is_empty() { + bail!("Failed to create a private DMG mount directory"); + } + let status = Command::new("/usr/bin/hdiutil") + .args(["attach", "-nobrowse", "-mountpoint"]) + .arg(&mount_point) + .arg(dmg_path) .status()?; if !status.success() { + let _ = std::fs::remove_dir(&mount_point); bail!("Failed to attach DMG image at {}: {:?}", dmg_path, status); } - struct DmgGuard(&'static str); + struct DmgGuard(String); impl Drop for DmgGuard { fn drop(&mut self) { - let _ = Command::new("hdiutil") - .args(&["detach", self.0, "-force"]) + let _ = Command::new("/usr/bin/hdiutil") + .args(["detach", self.0.as_str(), "-force"]) .status(); + let _ = std::fs::remove_dir(&self.0); } } - let _guard = DmgGuard(mount_point); + let _guard = DmgGuard(mount_point.clone()); let app_name = format!("{}.app", crate::get_app_name()); let src_path = format!("{}/{}", mount_point, app_name); let dest_path = format!("{}/{}", target_dir, app_name); - let copy_status = Command::new("ditto") + let copy_status = Command::new("/usr/bin/ditto") .args(&[&src_path, &dest_path]) .status()?; diff --git a/src/platform/privileges_scripts/daemon.plist b/src/platform/privileges_scripts/daemon.plist index c003ea2be..dbd1aa70f 100644 --- a/src/platform/privileges_scripts/daemon.plist +++ b/src/platform/privileges_scripts/daemon.plist @@ -23,8 +23,8 @@ WorkingDirectory /Applications/RustDesk.app/Contents/MacOS/ StandardErrorPath - /tmp/rustdesk_service.err + /var/log/rustdesk_service.err StandardOutPath - /tmp/rustdesk_service.out + /var/log/rustdesk_service.out diff --git a/src/platform/privileges_scripts/install.scpt b/src/platform/privileges_scripts/install.scpt index 797d02c9e..acf86ab93 100644 --- a/src/platform/privileges_scripts/install.scpt +++ b/src/platform/privileges_scripts/install.scpt @@ -1,14 +1,18 @@ on run {daemon_file, agent_file, user} + set prefs_dir to "/Users/" & user & "/Library/Preferences/com.carriez.RustDesk/" + set prefs_toml to quoted form of (prefs_dir & "RustDesk.toml") + set prefs2_toml to quoted form of (prefs_dir & "RustDesk2.toml") + set sh1 to "echo " & quoted form of daemon_file & " > /Library/LaunchDaemons/com.carriez.RustDesk_service.plist && chown root:wheel /Library/LaunchDaemons/com.carriez.RustDesk_service.plist;" set sh2 to "echo " & quoted form of agent_file & " > /Library/LaunchAgents/com.carriez.RustDesk_server.plist && chown root:wheel /Library/LaunchAgents/com.carriez.RustDesk_server.plist;" - set sh3 to "cp -rf /Users/" & user & "/Library/Preferences/com.carriez.RustDesk/RustDesk.toml /var/root/Library/Preferences/com.carriez.RustDesk/;" + set sh3 to "cp -rf " & prefs_toml & " /var/root/Library/Preferences/com.carriez.RustDesk/;" - set sh4 to "cp -rf /Users/" & user & "/Library/Preferences/com.carriez.RustDesk/RustDesk2.toml /var/root/Library/Preferences/com.carriez.RustDesk/;" + set sh4 to "cp -rf " & prefs2_toml & " /var/root/Library/Preferences/com.carriez.RustDesk/;" - set sh5 to "launchctl load -w /Library/LaunchDaemons/com.carriez.RustDesk_service.plist;" + set sh5 to "launchctl bootout system/com.carriez.RustDesk_service 2>/dev/null || launchctl unload -w /Library/LaunchDaemons/com.carriez.RustDesk_service.plist 2>/dev/null || true; launchctl bootstrap system /Library/LaunchDaemons/com.carriez.RustDesk_service.plist 2>/dev/null || launchctl load -w /Library/LaunchDaemons/com.carriez.RustDesk_service.plist;" set sh to sh1 & sh2 & sh3 & sh4 & sh5 diff --git a/src/platform/windows.rs b/src/platform/windows.rs index b6b5b39d9..161365cdc 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -2278,6 +2278,10 @@ fn get_shortcut_icon_location(install_dir: &str, exe: &str) -> String { } pub fn create_shortcut(id: &str) -> ResultType<()> { + if !crate::common::is_valid_untrusted_peer_id(id) { + bail!("Invalid peer id for shortcut"); + } + let exe = std::env::current_exe()?.to_str().unwrap_or("").to_owned(); // https://github.com/rustdesk/rustdesk/issues/13735 // Replace ':' with '_' for filename since ':' is not allowed in Windows filenames diff --git a/src/server.rs b/src/server.rs index 89a17a919..f02a15a7f 100644 --- a/src/server.rs +++ b/src/server.rs @@ -357,15 +357,13 @@ impl Server { } } - pub fn try_add_primay_video_service(&mut self) { - let primary_video_service_name = video_service::get_service_name( - VideoSource::Monitor, - *display_service::PRIMARY_DISPLAY_IDX, - ); - if !self.contains(&primary_video_service_name) { + pub fn try_add_monitor_service(&mut self, display_idx: usize) { + let monitor_service_name = + video_service::get_service_name(VideoSource::Monitor, display_idx); + if !self.contains(&monitor_service_name) { self.add_service(Box::new(video_service::new( VideoSource::Monitor, - *display_service::PRIMARY_DISPLAY_IDX, + display_idx, ))); } } @@ -381,14 +379,17 @@ impl Server { self.connections.insert(conn.id(), conn); } - pub fn add_connection(&mut self, conn: ConnInner, noperms: &Vec<&'static str>) { - let primary_video_service_name = video_service::get_service_name( - VideoSource::Monitor, - *display_service::PRIMARY_DISPLAY_IDX, - ); + pub fn add_monitor_connection( + &mut self, + conn: ConnInner, + noperms: &Vec<&'static str>, + display_idx: usize, + ) { + let monitor_service_name = + video_service::get_service_name(VideoSource::Monitor, display_idx); for s in self.services.values() { let name = s.name(); - if Self::is_video_service_name(&name) && name != primary_video_service_name { + if Self::is_video_service_name(&name) && name != monitor_service_name { continue; } if !noperms.contains(&(&name as _)) { @@ -783,8 +784,7 @@ async fn sync_and_watch_config_dir(sync_done_tx: Option { res.set_error(format!("{}", err)); } - Ok(displays) => { + Ok((displays, primary_display_idx)) => { // For compatibility with old versions, we need to send the displays to the peer. // But the displays may be updated later, before creating the video capturer. #[cfg(target_os = "macos")] { self.retina.set_displays(&displays); } + // A separate primary lookup here could race with display hot-plug. + self.display_idx = primary_display_idx; pi.displays = displays; pi.current_display = self.display_idx as _; #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -2038,8 +2042,8 @@ impl Connection { #[cfg(not(any(target_os = "android", target_os = "ios")))] let _h = try_start_record_cursor_pos(); self.auto_disconnect_timer = Self::get_auto_disconenct_timer(); - s.try_add_primay_video_service(); - s.add_connection(self.inner.clone(), &noperms); + s.try_add_monitor_service(self.display_idx); + s.add_monitor_connection(self.inner.clone(), &noperms, self.display_idx); } } } @@ -2842,7 +2846,6 @@ impl Connection { #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] if let Some(lr) = _s.lr.clone().take() { - self.handle_login_request_without_validation(&lr).await; // Switching sides authorizes without a password, so it must not bypass the // whitelist, which can be a locked policy pushed by the server. if !self.check_id_whitelist().await { @@ -2856,6 +2859,15 @@ impl Connection { if let Ok(uuid) = uuid::Uuid::from_slice(_s.uuid.to_vec().as_ref()) { if let Some((_instant, uuid_old)) = uuid_old { if uuid == uuid_old { + if lr.union.is_some() { + log::warn!( + "Rejected switch sides response for non-remote-desktop session; closing connection" + ); + self.send_login_error("Connection not allowed").await; + return false; + } + self.reset_session_scope_for_login(); + self.handle_login_request_without_validation(&lr).await; self.from_switch = true; self.set_conn_audit_primary_auth(ConnAuditPrimaryAuth::SwitchSides); if !self.send_logon_response_and_keep_alive().await { @@ -4182,7 +4194,9 @@ impl Connection { let display_idx = s.display as usize; if self.display_idx != display_idx { if let Some(server) = self.server.upgrade() { - self.switch_display_to(display_idx, server.clone()); + if !self.switch_display_to(display_idx, server.clone()) { + return; + } #[cfg(not(any(target_os = "android", target_os = "ios")))] if !self.view_camera && s.width != 0 && s.height != 0 { @@ -4209,6 +4223,13 @@ impl Connection { } } + fn video_source_count(video_source: VideoSource) -> usize { + match video_source { + VideoSource::Monitor => display_service::get_sync_displays().len(), + VideoSource::Camera => camera::Cameras::get_sync_cameras().len(), + } + } + fn video_source(&self) -> VideoSource { if self.view_camera { VideoSource::Camera @@ -4217,18 +4238,28 @@ impl Connection { } } - fn switch_display_to(&mut self, display_idx: usize, server: Arc>) { + fn switch_display_to(&mut self, display_idx: usize, server: Arc>) -> bool { + let source_count = Self::video_source_count(self.video_source()); + if display_idx >= source_count { + // Do not remap an explicit switch: its resolution belongs to the requested source. + log::warn!( + "Ignore switch to invalid {:?} index {}, available source count: {}", + self.video_source(), + display_idx, + source_count + ); + return false; + } + let new_service_name = video_service::get_service_name(self.video_source(), display_idx); let old_service_name = video_service::get_service_name(self.video_source(), self.display_idx); let mut lock = server.write().unwrap(); - if display_idx != *display_service::PRIMARY_DISPLAY_IDX { - if !lock.contains(&new_service_name) { - lock.add_service(Box::new(video_service::new( - self.video_source(), - display_idx, - ))); - } + if !lock.contains(&new_service_name) { + lock.add_service(Box::new(video_service::new( + self.video_source(), + display_idx, + ))); } // For versions greater than 1.2.4, a `CaptureDisplays` message will be sent immediately. // Unnecessary capturers will be removed then. @@ -4237,6 +4268,7 @@ impl Connection { } lock.subscribe(&new_service_name, self.inner.clone(), true); self.display_idx = display_idx; + true } #[cfg(windows)] @@ -4263,26 +4295,61 @@ impl Connection { async fn capture_displays(&mut self, add: &[usize], sub: &[usize], set: &[usize]) { let video_source = self.video_source(); - if let Some(sever) = self.server.upgrade() { - let mut lock = sever.write().unwrap(); - for display in add.iter() { + let source_count = Self::video_source_count(video_source); + // Only add/set can create services; sub only narrows existing subscriptions. + let valid_add = add + .iter() + .copied() + .filter(|display| *display < source_count) + .collect::>(); + let valid_sub = sub + .iter() + .copied() + .filter(|display| *display < source_count) + .collect::>(); + let valid_set = set + .iter() + .copied() + .filter(|display| *display < source_count) + .collect::>(); + let invalid_count = + add.len() + sub.len() + set.len() - valid_add.len() - valid_sub.len() - valid_set.len(); + if invalid_count != 0 { + log::warn!( + "Ignore {} invalid {:?} indices, available source count: {}", + invalid_count, + video_source, + source_count + ); + } + // Passing an invalid sub request as an empty exclude list would unsubscribe all services. + if (!add.is_empty() && valid_add.is_empty()) + || (add.is_empty() && !sub.is_empty() && valid_sub.is_empty()) + || (add.is_empty() && sub.is_empty() && !set.is_empty() && valid_set.is_empty()) + { + return; + } + + if let Some(server) = self.server.upgrade() { + let mut lock = server.write().unwrap(); + for display in valid_add.iter() { let service_name = video_service::get_service_name(video_source, *display); if !lock.contains(&service_name) { lock.add_service(Box::new(video_service::new(video_source, *display))); } } - for display in set.iter() { + for display in valid_set.iter() { let service_name = video_service::get_service_name(video_source, *display); if !lock.contains(&service_name) { lock.add_service(Box::new(video_service::new(video_source, *display))); } } if !add.is_empty() { - lock.capture_displays(self.inner.clone(), video_source, add, true, false); + lock.capture_displays(self.inner.clone(), video_source, &valid_add, true, false); } else if !sub.is_empty() { - lock.capture_displays(self.inner.clone(), video_source, sub, false, true); + lock.capture_displays(self.inner.clone(), video_source, &valid_sub, false, true); } else { - lock.capture_displays(self.inner.clone(), video_source, set, true, true); + lock.capture_displays(self.inner.clone(), video_source, &valid_set, true, true); } self.multi_ui_session = lock.get_subbed_displays_count(self.inner.id()) > 1; if self.follow_remote_window { @@ -5696,6 +5763,7 @@ impl Connection { Some(misc::Union::ChangeDisplayResolution(_)) => "misc.change_display_resolution", Some(misc::Union::MessageQuery(_)) => "misc.message_query", Some(misc::Union::FollowCurrentDisplay(_)) => "misc.follow_current_display", + Some(misc::Union::SwitchSidesRequest(_)) => "misc.switch_sides_request", Some(_) => "misc.other", None => "misc.empty", } @@ -6947,6 +7015,10 @@ mod test { misc_msg(|m| m.set_capture_displays(CaptureDisplays::new())), Some("misc.capture_displays"), ), + ( + misc_msg(|m| m.set_switch_sides_request(SwitchSidesRequest::new())), + Some("misc.switch_sides_request"), + ), (msg(|m| m.set_clipboard(Clipboard::new())), None), ( msg(|m| m.set_multi_clipboards(MultiClipboards::new())), @@ -6991,6 +7063,10 @@ mod test { misc_msg(|m| m.set_toggle_privacy_mode(TogglePrivacyMode::new())), Some("misc.toggle_privacy_mode"), ), + ( + misc_msg(|m| m.set_switch_sides_request(SwitchSidesRequest::new())), + Some("misc.switch_sides_request"), + ), (misc_msg(|m| m.set_chat_message(ChatMessage::new())), None), (msg(|m| m.set_clipboard(Clipboard::new())), None), ( @@ -7076,6 +7152,10 @@ mod test { msg(|m| m.set_terminal_action(TerminalAction::new())), Some("terminal_action"), ), + ( + misc_msg(|m| m.set_switch_sides_request(SwitchSidesRequest::new())), + Some("misc.switch_sides_request"), + ), ], ), ( @@ -7086,6 +7166,10 @@ mod test { None, ), (msg(|m| m.set_terminal_action(TerminalAction::new())), None), + ( + misc_msg(|m| m.set_switch_sides_request(SwitchSidesRequest::new())), + None, + ), ], ), ( @@ -7105,6 +7189,10 @@ mod test { msg(|m| m.set_screenshot_request(ScreenshotRequest::new())), Some("screenshot_request"), ), + ( + misc_msg(|m| m.set_switch_sides_request(SwitchSidesRequest::new())), + Some("misc.switch_sides_request"), + ), (misc_msg(|m| m.set_refresh_video(true)), None), (misc_msg(|m| m.set_refresh_video_display(0)), None), ( diff --git a/src/server/display_service.rs b/src/server/display_service.rs index fe3621f26..8531076a9 100644 --- a/src/server/display_service.rs +++ b/src/server/display_service.rs @@ -25,12 +25,147 @@ struct ChangedResolution { lazy_static::lazy_static! { static ref IS_CAPTURER_MAGNIFIER_SUPPORTED: bool = is_capturer_mag_supported(); static ref CHANGED_RESOLUTIONS: Arc>> = Default::default(); - // Initial primary display index. - // It should not be updated when displays changed. - pub static ref PRIMARY_DISPLAY_IDX: usize = get_primary(); static ref SYNC_DISPLAYS: Arc> = Default::default(); } +#[cfg(target_os = "linux")] +lazy_static::lazy_static! { + static ref WAYLAND_UINPUT_RECT: Mutex = Default::default(); + static ref WAYLAND_LAYOUT: Mutex = Default::default(); +} + +#[cfg(target_os = "linux")] +const WAYLAND_LAYOUT_CHECK_INTERVAL: Duration = Duration::from_millis(1500); + +#[cfg(target_os = "linux")] +#[derive(Default)] +struct WaylandUinputRect { + rect: Option<(i32, i32, i32, i32)>, + last_check: Option, +} + +// Per-display layout used to correct injected coordinates when the compositor moves a +// monitor mid-session. The client keeps sending coordinates offset by the layout it was +// told at session init (`baseline`); we remap them onto the current layout (`live`). +// https://github.com/rustdesk/rustdesk/issues/15601 +#[cfg(target_os = "linux")] +#[derive(Default)] +struct WaylandLayout { + baseline: Vec, + live: Vec, +} + +// Whether `live` differs from `baseline`. Read on every mouse move, so it is an atomic: +// the common (no-drift) case never touches the layout mutex. +#[cfg(target_os = "linux")] +static WAYLAND_LAYOUT_DRIFTED: AtomicBool = AtomicBool::new(false); + +#[cfg(target_os = "linux")] +pub(super) fn set_wayland_uinput_rect(rect: (i32, i32, i32, i32)) { + WAYLAND_UINPUT_RECT.lock().unwrap().rect = Some(rect); +} + +#[cfg(target_os = "linux")] +pub(super) fn set_wayland_layout_baseline(baseline: Vec) { + WAYLAND_LAYOUT_DRIFTED.store(false, Ordering::Relaxed); + let mut lock = WAYLAND_LAYOUT.lock().unwrap(); + lock.baseline = baseline; + lock.live.clear(); +} + +// Remap an injected coordinate onto the live compositor layout when it has drifted from +// what the client was told at session init. Lock-free no-op otherwise. +#[cfg(target_os = "linux")] +pub(super) fn remap_wayland_uinput_coord(x: i32, y: i32) -> (i32, i32) { + if !WAYLAND_LAYOUT_DRIFTED.load(Ordering::Relaxed) { + return (x, y); + } + let lock = WAYLAND_LAYOUT.lock().unwrap(); + scrap::wayland::display::remap_to_live_layout(x, y, &lock.baseline, &lock.live) +} + +// The uinput absolute range is set when the session inits. If the compositor layout +// changes afterwards (monitor scale/position change, or a portal virtual output +// appearing once the capture starts), injected coordinates get rescaled by the stale +// range and land offset, https://github.com/rustdesk/rustdesk/issues/15601 +#[cfg(target_os = "linux")] +fn refresh_wayland_uinput_rect_if_changed() { + if is_x11() || !crate::input_service::wayland_use_uinput() { + return; + } + { + let mut lock = WAYLAND_UINPUT_RECT.lock().unwrap(); + if let Some(last_check) = lock.last_check { + if last_check.elapsed() < WAYLAND_LAYOUT_CHECK_INTERVAL { + return; + } + } + lock.last_check = Some(std::time::Instant::now()); + } + let Some((rect, live_rects)) = scrap::wayland::display::get_layout_for_uinput_live() else { + return; + }; + // Refresh the per-display layout every poll: monitor origins can shift (e.g. two + // displays swap positions) without changing the overall desktop rect, and the mouse + // path needs the current per-display geometry to correct coordinates. + let drifted = { + let mut layout = WAYLAND_LAYOUT.lock().unwrap(); + let drifted = !layout.baseline.is_empty() + && !live_rects.is_empty() + && layout.baseline != live_rects; + layout.live = live_rects; + drifted + }; + // The remap corrects for per-display origin shifts; the uinput ABS range corrects for + // the overall bounding box. Only enable the remap once the range matches the live + // layout, otherwise moves would be remapped into a range the device is not yet using. + // A drift with no bbox change (origins swapped) needs no range update and enables now. + let mut range_ok = WAYLAND_UINPUT_RECT.lock().unwrap().rect == Some(rect); + if !range_ok { + let (minx, maxx, miny, maxy) = rect; + log::info!( + "desktop layout changed, update mouse resolution: ({}, {}), ({}, {})", + minx, + maxx, + miny, + maxy + ); + match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => { + // Bound the IPC wait, this runs on the display service loop and + // `set_resolution()` has no timeout on the response read. + // timeout must be built inside the runtime, or it panics + // "there is no reactor running". See clipboard_service.rs. + match rt.block_on(async { + timeout( + 3_000, + crate::input_service::update_mouse_resolution(minx, maxx, miny, maxy), + ) + .await + }) { + // Record the rect only after a successful apply, so a transient + // failure is retried on the next check. + Ok(Ok(())) => { + WAYLAND_UINPUT_RECT.lock().unwrap().rect = Some(rect); + range_ok = true; + } + Ok(Err(err)) => log::error!("Failed to update mouse resolution: {}", err), + Err(err) => log::error!("Failed to update mouse resolution: {}", err), + } + } + Err(err) => { + log::error!("Failed to build tokio runtime: {}", err); + } + } + } + // Publish the flag last: a `true` read is always backed by a current `live` and a + // matching uinput range. A failed range apply leaves this false and retries next poll. + WAYLAND_LAYOUT_DRIFTED.store(drifted && range_ok, Ordering::Relaxed); +} + // https://github.com/rustdesk/rustdesk/pull/8537 static TEMP_IGNORE_DISPLAYS_CHANGED: AtomicBool = AtomicBool::new(false); @@ -41,22 +176,14 @@ struct SyncDisplaysInfo { } impl SyncDisplaysInfo { - fn check_changed(&mut self, displays: Vec) { - if self.displays.len() != displays.len() { - self.displays = displays; - if !TEMP_IGNORE_DISPLAYS_CHANGED.load(Ordering::Relaxed) { - self.is_synced = false; - } + fn check_changed(&mut self, displays: &[DisplayInfo]) { + if self.displays.as_slice() == displays { return; } - for (i, d) in displays.iter().enumerate() { - if d != &self.displays[i] { - self.displays = displays; - if !TEMP_IGNORE_DISPLAYS_CHANGED.load(Ordering::Relaxed) { - self.is_synced = false; - } - return; - } + + self.displays = displays.to_vec(); + if !TEMP_IGNORE_DISPLAYS_CHANGED.load(Ordering::Relaxed) { + self.is_synced = false; } } @@ -242,6 +369,12 @@ fn run(sp: EmptyExtraFieldService) -> ResultType<()> { sp.send(msg_out); log::info!("Displays changed"); } + + #[cfg(target_os = "linux")] + if sp.has_subscribes() { + refresh_wayland_uinput_rect_if_changed(); + } + std::thread::sleep(Duration::from_millis(300)); } @@ -304,6 +437,11 @@ pub(super) fn get_display_info(idx: usize) -> Option { // Display to DisplayInfo // The DisplayInfo is be sent to the peer. pub(super) fn check_update_displays(all: &Vec) { + let _ = update_sync_displays(all); +} + +// Return the converted input snapshot while updating the shared display cache. +pub(super) fn update_sync_displays(all: &Vec) -> Vec { // For compatibility: if only one display, scale remains 1.0 and we use the physical size for `uinput`. // If there are multiple displays, we use the logical size for `uinput` by setting scale to d.scale(). #[cfg(target_os = "linux")] @@ -346,7 +484,8 @@ pub(super) fn check_update_displays(all: &Vec) { } }) .collect::>(); - SYNC_DISPLAYS.lock().unwrap().check_changed(displays); + SYNC_DISPLAYS.lock().unwrap().check_changed(&displays); + displays } pub fn is_inited_msg() -> Option { @@ -357,34 +496,38 @@ pub fn is_inited_msg() -> Option { None } -pub async fn update_get_sync_displays_on_login() -> ResultType> { +// Return the primary index with the refreshed list so login cannot mix display snapshots. +pub async fn update_get_sync_displays_on_login() -> ResultType<(Vec, usize)> { #[cfg(target_os = "linux")] { if !is_x11() { - return super::wayland::get_displays().await; + let (displays, primary_display_idx) = + super::wayland::get_displays_and_primary().await?; + let primary_display_idx = + normalize_primary_display_idx(primary_display_idx, displays.len()); + return Ok((displays, primary_display_idx)); } } #[cfg(not(windows))] let displays = display_service::try_get_displays(); #[cfg(windows)] let displays = display_service::try_get_displays_add_amyuni_headless(); - check_update_displays(&displays?); - Ok(SYNC_DISPLAYS.lock().unwrap().displays.clone()) + let displays = displays?; + let primary_display_idx = get_primary_2(&displays); + let sync_displays = update_sync_displays(&displays); + let primary_display_idx = + normalize_primary_display_idx(primary_display_idx, sync_displays.len()); + Ok((sync_displays, primary_display_idx)) } #[inline] -pub fn get_primary() -> usize { - #[cfg(target_os = "linux")] - { - if !is_x11() { - return match super::wayland::get_primary() { - Ok(n) => n, - Err(_) => 0, - }; - } +fn normalize_primary_display_idx(primary_display_idx: usize, display_len: usize) -> usize { + // Zero is the protocol fallback when the list is empty or its primary index is stale. + if primary_display_idx < display_len { + primary_display_idx + } else { + 0 } - - try_get_displays().map(|d| get_primary_2(&d)).unwrap_or(0) } #[inline] @@ -486,3 +629,16 @@ pub fn try_get_displays_(add_amyuni_headless: bool) -> ResultType> } Ok(displays) } + +#[cfg(test)] +mod tests { + use super::normalize_primary_display_idx; + + #[test] + fn normalize_primary_display_idx_bounds() { + assert_eq!(normalize_primary_display_idx(0, 0), 0); + assert_eq!(normalize_primary_display_idx(0, 2), 0); + assert_eq!(normalize_primary_display_idx(1, 2), 1); + assert_eq!(normalize_primary_display_idx(2, 2), 0); + } +} diff --git a/src/server/input_service.rs b/src/server/input_service.rs index 91a2901dc..1d4deeb65 100644 --- a/src/server/input_service.rs +++ b/src/server/input_service.rs @@ -661,20 +661,22 @@ pub async fn setup_rdp_input() -> ResultType<(), Box> { pub async fn update_mouse_resolution(minx: i32, maxx: i32, miny: i32, maxy: i32) -> ResultType<()> { set_uinput_resolution(minx, maxx, miny, maxy).await?; - std::thread::spawn(|| { + // Confirm the device adopted the new range before the caller caches it. + // spawn_blocking because ENIGO is a std Mutex and send_refresh blocks on IPC. + tokio::task::spawn_blocking(move || { if let Some(mouse) = ENIGO.lock().unwrap().get_custom_mouse() { if let Some(mouse) = mouse .as_mut_any() .downcast_mut::() { - allow_err!(mouse.send_refresh()); - } else { - log::error!("failed downcast uinput mouse"); + return mouse.send_refresh(); } + bail!("failed to downcast custom mouse to UInputMouse"); } - }); - - Ok(()) + // No custom mouse: nothing to refresh. + Ok(()) + }) + .await? } #[cfg(target_os = "linux")] @@ -1098,12 +1100,23 @@ pub fn handle_mouse_simulation_(evt: &MouseEvent, conn: i32) { MOUSE_TYPE_MOVE => { // Switching back to absolute movement implicitly disables relative mouse mode. set_relative_mouse_active(conn, false); - en.mouse_move_to(evt.x, evt.y); + // On Wayland with uinput, the client sends coordinates in the layout it was + // told at session init. If the compositor has since moved a monitor, correct + // them onto the current layout. https://github.com/rustdesk/rustdesk/issues/15601 + #[cfg(target_os = "linux")] + let (mx, my) = if wayland_use_uinput() { + super::display_service::remap_wayland_uinput_coord(evt.x, evt.y) + } else { + (evt.x, evt.y) + }; + #[cfg(not(target_os = "linux"))] + let (mx, my) = (evt.x, evt.y); + en.mouse_move_to(mx, my); *LATEST_PEER_INPUT_CURSOR.lock().unwrap() = Input { conn, time: get_time(), - x: evt.x, - y: evt.y, + x: mx, + y: my, }; } // MOUSE_TYPE_MOVE_RELATIVE: Relative mouse movement for gaming/3D applications. diff --git a/src/server/uinput.rs b/src/server/uinput.rs index a1947d79f..496da709f 100644 --- a/src/server/uinput.rs +++ b/src/server/uinput.rs @@ -130,7 +130,16 @@ pub mod client { } pub fn send_refresh(&mut self) -> ResultType<()> { - self.send(Data::Mouse(DataMouse::Refresh)) + self.rt + .block_on(self.conn.send(&Data::Mouse(DataMouse::Refresh)))?; + // Wait for the service to confirm it recreated the device, so a + // failed refresh is distinguishable from a good one. + match self.rt.block_on(self.conn.next_timeout(IPC_REQUEST_TIMEOUT)) { + Ok(Some(Data::Empty)) => Ok(()), + Ok(Some(resp)) => bail!("unexpected uinput mouse refresh response: {:?}", &resp), + Ok(None) => bail!("uinput mouse refresh failed, connection closed"), + Err(e) => bail!("uinput mouse refresh timeout {}, {}", IPC_REQUEST_TIMEOUT, e), + } } } @@ -851,9 +860,10 @@ pub mod service { match data { Data::Mouse(data) => { if let DataMouse::Refresh = data { - let resolution = RESOLUTION.lock().unwrap(); - let rng_x = resolution.0.clone(); - let rng_y = resolution.1.clone(); + let (rng_x, rng_y) = { + let resolution = RESOLUTION.lock().unwrap(); + (resolution.0.clone(), resolution.1.clone()) + }; log::info!( "Refresh uinput mouce with rng_x: ({}, {}), rng_y: ({}, {})", rng_x.0, @@ -861,11 +871,19 @@ pub mod service { rng_y.0, rng_y.1 ); - mouse = match mouce::UInputMouseManager::new(rng_x, rng_y) { - Ok(mouse) => mouse, + match mouce::UInputMouseManager::new(rng_x, rng_y) { + Ok(m) => { + mouse = m; + // Ack: device adopted the new range. + allow_err!(stream.send(&Data::Empty).await); + } Err(e) => { - log::error!("Failed to create mouse, {}", e); - return; + // Keep the current device; withhold the ack + // so the client times out and retries. + log::error!( + "Failed to recreate uinput mouse, keeping current: {}", + e + ); } } } else { diff --git a/src/server/wayland.rs b/src/server/wayland.rs index 1e0efc0f4..dacce9485 100644 --- a/src/server/wayland.rs +++ b/src/server/wayland.rs @@ -137,6 +137,9 @@ pub(super) async fn check_init() -> ResultType<()> { if !is_x11() { if CAP_DISPLAY_INFO.read().unwrap().is_empty() { if crate::input_service::wayland_use_uinput() { + // The cached layout may predate compositor changes made while no session + // was active, https://github.com/rustdesk/rustdesk/issues/15601 + scrap::wayland::display::clear_wayland_displays_cache(); if let Some((minx, maxx, miny, maxy)) = scrap::wayland::display::get_desktop_rect_for_uinput() { @@ -147,9 +150,28 @@ pub(super) async fn check_init() -> ResultType<()> { miny, maxy ); - allow_err!( - input_service::update_mouse_resolution(minx, maxx, miny, maxy).await - ); + // Bound the IPC wait like the periodic refresh does, so a hung + // response can't stall session init. + match timeout( + 3_000, + input_service::update_mouse_resolution(minx, maxx, miny, maxy), + ) + .await + { + Ok(Ok(())) => { + super::display_service::set_wayland_uinput_rect(( + minx, maxx, miny, maxy, + )); + // Snapshot the per-display layout the client's coordinates + // will be based on, so the mouse path can correct them if + // the compositor moves a monitor mid-session. + super::display_service::set_wayland_layout_baseline( + scrap::wayland::display::get_display_rects_for_uinput(), + ); + } + Ok(Err(err)) => log::error!("Failed to update mouse resolution: {}", err), + Err(err) => log::error!("Failed to update mouse resolution: {}", err), + } } else { log::warn!("Failed to get desktop rect for uinput"); } @@ -175,8 +197,7 @@ pub(super) async fn check_init() -> ResultType<()> { *PIPEWIRE_INITIALIZED.write().unwrap() = true; let num = all.len(); let primary = super::display_service::get_primary_2(&all); - super::display_service::check_update_displays(&all); - let mut displays = super::display_service::get_sync_displays(); + let mut displays = super::display_service::update_sync_displays(&all); for display in displays.iter_mut() { display.cursor_embedded = is_cursor_embedded(); } @@ -220,27 +241,15 @@ pub(super) async fn check_init() -> ResultType<()> { Ok(()) } -pub(super) async fn get_displays() -> ResultType> { +pub(super) async fn get_displays_and_primary() -> ResultType<(Vec, usize)> { check_init().await?; + // Keep one read guard so clear/reinitialization cannot split these across cache snapshots. let cap_map = CAP_DISPLAY_INFO.read().unwrap(); if let Some(addr) = cap_map.values().next() { let cap_display_info: *const CapDisplayInfo = *addr as _; unsafe { let cap_display_info = &*cap_display_info; - Ok(cap_display_info.displays.clone()) - } - } else { - bail!("Failed to get capturer display info"); - } -} - -pub(super) fn get_primary() -> ResultType { - let cap_map = CAP_DISPLAY_INFO.read().unwrap(); - if let Some(addr) = cap_map.values().next() { - let cap_display_info: *const CapDisplayInfo = *addr as _; - unsafe { - let cap_display_info = &*cap_display_info; - Ok(cap_display_info.primary) + Ok((cap_display_info.displays.clone(), cap_display_info.primary)) } } else { bail!("Failed to get capturer display info"); diff --git a/src/service.rs b/src/service.rs index ce1855bdb..65be58302 100644 --- a/src/service.rs +++ b/src/service.rs @@ -5,6 +5,14 @@ fn main() {} #[cfg(target_os = "macos")] fn main() { + let args: Vec = std::env::args().collect(); + if args.len() > 1 && args[1] == "--write-plists" { + if let Err(e) = librustdesk::platform::write_plists() { + eprintln!("Failed to write plists: {}", e); + std::process::exit(1); + } + std::process::exit(0); + } crate::common::load_custom_client(); hbb_common::init_log(false, "service"); crate::start_os_service(); diff --git a/src/ui/header.tis b/src/ui/header.tis index 40ccbcbf2..231c71efe 100644 --- a/src/ui/header.tis +++ b/src/ui/header.tis @@ -151,7 +151,7 @@ class Header: Reactor.Component { {svg_action} {svg_display} {svg_keyboard} - {recording_enabled ? {recording ? svg_recording_on : svg_recording_off} : ""} + {recording_enabled && show_recording_button ? {recording ? svg_recording_on : svg_recording_off} : ""} {this.renderKeyboardPop()} {this.renderDisplayPop()} {this.renderActionPop()} diff --git a/src/ui/remote.rs b/src/ui/remote.rs index 1d5ceb139..3a2cca3e0 100644 --- a/src/ui/remote.rs +++ b/src/ui/remote.rs @@ -504,6 +504,7 @@ impl sciter::EventHandler for SciterSession { fn get_id(); fn get_default_pi(); fn get_option(String); + fn get_local_option(String); fn t(String); fn set_option(String, String); fn input_os_password(String, bool); @@ -638,6 +639,10 @@ impl SciterSession { crate::client::translate(name) } + pub fn get_local_option(&self, key: String) -> String { + crate::ui_interface::get_local_option(key) + } + pub fn get_icon(&self) -> String { super::get_icon() } diff --git a/src/ui/remote.tis b/src/ui/remote.tis index 28fbc3763..87c543eb0 100644 --- a/src/ui/remote.tis +++ b/src/ui/remote.tis @@ -17,6 +17,7 @@ var audio_enabled = true; // server side var file_enabled = true; // server side var restart_enabled = true; // server side var recording_enabled = true; // server side +var show_recording_button = handler.get_local_option("hide-recording-button") != "Y"; var privacy_mode_enabled = true; // server side var scroll_body = $(body); var peer_platform = ""; diff --git a/src/ui_interface.rs b/src/ui_interface.rs index 1a8927840..94fde4392 100644 --- a/src/ui_interface.rs +++ b/src/ui_interface.rs @@ -911,6 +911,29 @@ pub fn get_langs() -> String { json!(x).to_string() } +// Preserve relative paths for existing configurations and only remove accidental +// surrounding whitespace. Config values are not shell-expanded (for example, `~`). +fn trim_video_save_directory(value: &str) -> Option<&str> { + let value = value.trim(); + if !value.is_empty() { + Some(value) + } else { + None + } +} + +// A Windows service typically runs with System32 as its working directory, so +// require an absolute path to avoid resolving recordings there unexpectedly. +#[cfg(any(windows, test))] +fn validate_windows_service_video_save_directory(value: &str) -> Option<&str> { + let value = trim_video_save_directory(value)?; + if std::path::Path::new(value).is_absolute() { + Some(value) + } else { + None + } +} + #[inline] pub fn video_save_directory(root: bool) -> String { let appname = crate::get_app_name(); @@ -930,6 +953,15 @@ pub fn video_save_directory(root: bool) -> String { // Currently, only installed windows run as root #[cfg(windows)] { + let dir = Config::get_option(OPTION_WINDOWS_SERVICE_VIDEO_SAVE_DIRECTORY); + if let Some(dir) = validate_windows_service_video_save_directory(&dir) { + return dir.to_owned(); + } + if !dir.trim().is_empty() { + log::warn!( + "Ignoring {OPTION_WINDOWS_SERVICE_VIDEO_SAVE_DIRECTORY}: path must be absolute" + ); + } let drive = std::env::var("SystemDrive").unwrap_or("C:".to_owned()); let dir = std::path::PathBuf::from(format!("{drive}\\ProgramData\\{appname}\\recording",)); @@ -941,8 +973,8 @@ pub fn video_save_directory(root: bool) -> String { let dir = LocalConfig::get_option_from_file(OPTION_VIDEO_SAVE_DIRECTORY); #[cfg(not(any(target_os = "linux", target_os = "macos")))] let dir = LocalConfig::get_option(OPTION_VIDEO_SAVE_DIRECTORY); - if !dir.is_empty() { - return dir; + if let Some(dir) = trim_video_save_directory(&dir) { + return dir.to_owned(); } #[cfg(any(target_os = "android", target_os = "ios"))] if let Ok(home) = config::APP_HOME_DIR.read() { @@ -1705,3 +1737,41 @@ pub fn is_remote_modify_enabled_by_control_permissions() -> Option { .lock() .unwrap() } + +#[cfg(test)] +mod tests { + use super::{trim_video_save_directory, validate_windows_service_video_save_directory}; + + #[test] + fn trim_configured_video_save_directory() { + assert_eq!( + trim_video_save_directory(" relative/recordings "), + Some("relative/recordings") + ); + assert_eq!(trim_video_save_directory(" "), None); + } + + #[test] + fn validate_service_video_save_directory() { + let absolute = if cfg!(windows) { + r"C:\recordings" + } else { + "/recordings" + }; + let padded = format!(" {absolute} "); + + assert_eq!( + validate_windows_service_video_save_directory(&padded), + Some(absolute) + ); + assert_eq!( + validate_windows_service_video_save_directory("recordings"), + None + ); + assert_eq!( + validate_windows_service_video_save_directory(&format!("\"{absolute}\"")), + None + ); + assert_eq!(validate_windows_service_video_save_directory(" "), None); + } +} diff --git a/src/updater.rs b/src/updater.rs index bf923dd56..beab97e53 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -11,6 +11,51 @@ use std::{ time::{Duration, Instant}, }; +#[cfg(target_os = "macos")] +use std::os::{ + fd::AsRawFd, + unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}, +}; + +#[cfg(target_os = "macos")] +struct MacUpdateLock { + _file: std::fs::File, +} + +#[cfg(target_os = "macos")] +fn acquire_mac_update_lock() -> ResultType { + let path = std::path::PathBuf::from("/var/run/rustdesk-update.lock"); + let handle = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .custom_flags(hbb_common::libc::O_NOFOLLOW | hbb_common::libc::O_CLOEXEC) + .open(&path)?; + let metadata = handle.metadata()?; + if !metadata.file_type().is_file() || metadata.uid() != 0 { + bail!("[root-update] update lock is not a root-owned regular file"); + } + handle.set_permissions(std::fs::Permissions::from_mode(0o600))?; + + // Keep the descriptor open through update preparation and detached-script + // launch. O_CLOEXEC means this lock does not cover the detached bundle + // swap; flock is released when this guard is dropped or the process exits. + let lock_result = unsafe { + hbb_common::libc::flock( + handle.as_raw_fd(), + hbb_common::libc::LOCK_EX | hbb_common::libc::LOCK_NB, + ) + }; + if lock_result != 0 { + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::WouldBlock { + bail!("[root-update] another update is already running"); + } + return Err(err.into()); + } + Ok(MacUpdateLock { _file: handle }) +} + enum UpdateMsg { CheckUpdate, Exit, @@ -22,7 +67,17 @@ lazy_static::lazy_static! { static CONTROLLING_SESSION_COUNT: AtomicUsize = AtomicUsize::new(0); -const DUR_ONE_DAY: Duration = Duration::from_secs(60 * 60 * 24); +/// Initial wait after startup before the first update check (30 seconds). +pub const INITIAL_CHECK_DELAY: Duration = Duration::from_secs(30); + +/// One full day — default interval between update checks. +pub const DUR_ONE_DAY: Duration = Duration::from_secs(60 * 60 * 24); + +/// Minimum interval between consecutive update checks (10 minutes). +pub const MIN_INTERVAL: Duration = Duration::from_secs(60 * 10); + +/// Retry interval when an update check fails or a session is active (30 minutes). +pub const RETRY_INTERVAL: Duration = Duration::from_secs(60 * 30); pub fn update_controlling_session_count(count: usize) { CONTROLLING_SESSION_COUNT.store(count, Ordering::SeqCst); @@ -47,7 +102,9 @@ pub fn stop_auto_update() { } #[inline] -fn has_no_active_conns() -> bool { +/// Returns true when there are no active incoming or outgoing connections. +/// Used to avoid updating while a remote session is in progress. +pub fn has_no_active_conns() -> bool { let conns = crate::Connection::alive_conns(); conns.is_empty() && has_no_controlling_conns() } @@ -82,13 +139,11 @@ fn start_auto_update_check() -> Sender { } fn start_auto_update_check_(rx_msg: Receiver) { - std::thread::sleep(Duration::from_secs(30)); + std::thread::sleep(INITIAL_CHECK_DELAY); if let Err(e) = check_update(false) { log::error!("Error checking for updates: {}", e); } - const MIN_INTERVAL: Duration = Duration::from_secs(60 * 10); - const RETRY_INTERVAL: Duration = Duration::from_secs(60 * 30); let mut last_check_time = Instant::now(); let mut check_interval = DUR_ONE_DAY; loop { @@ -118,6 +173,12 @@ fn start_auto_update_check_(rx_msg: Receiver) { } fn check_update(manually: bool) -> ResultType<()> { + // On macOS, auto-update is handled by check_update_as_root() in the service process. + // The shared check_update() path is only used for manual update checks from the GUI. + #[cfg(target_os = "macos")] + if !manually { + return Ok(()); + } #[cfg(target_os = "windows")] let update_msi = crate::platform::is_msi_installed()? && !crate::is_custom_client(); if !(manually || config::Config::get_bool_option(config::keys::OPTION_ALLOW_AUTO_UPDATE)) { @@ -348,6 +409,251 @@ pub fn get_download_file_from_url(url: &str) -> Option { get_update_download_file_from_url(url) } +/// Queries all active connections (remote, file-transfer, port-forward, camera, terminal) +/// from every logged-in user's --server process via IPC. +/// The root service cannot read connection state directly since connections +/// live in user --server processes. Handles fast user switching by querying +/// all GUI users, including the login-window server at UID 0. Falls back to +/// false (assumes sessions active) on any IPC error to avoid updating during +/// an unknown session state. +#[cfg(target_os = "macos")] +pub fn has_no_active_conns_ipc() -> bool { + let rt = match hbb_common::tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(_) => return false, + }; + rt.block_on(async { + // Use the same GUI-domain-filtered UID set as the update script. + // Shell-only SSH/TTY users are excluded, while an empty GUI set maps + // to UID 0 so the LoginWindow server is queried rather than assumed idle. + let uids = crate::platform::get_logged_in_uids(); + // Check each user's server — fail closed if any has active connections + for uid in uids { + if let Ok(mut conn) = crate::ipc::connect_for_uid(1000, uid, "").await { + if conn.send(&crate::ipc::Data::HasNoActiveConns(None)).await.is_ok() { + match conn.next_timeout(1000).await { + Ok(Some(crate::ipc::Data::HasNoActiveConns(Some(true)))) => { + // Explicit no active connections — safe to continue + } + Ok(Some(crate::ipc::Data::HasNoActiveConns(Some(false)))) => { + return false; // Explicit active connections + } + _ => { + return false; // Timeout/error/unexpected — fail closed + } + } + } else { + return false; // Send failed — fail closed + } + } else { + return false; // Connection failed — fail closed + } + } + true // All users explicitly confirmed no active connections + }) +} + +#[cfg(target_os = "macos")] +fn wait_for_failed_update_retry() { + const FAILURE_MARKER: &str = "/var/root/.rustdeskupdate_failed"; + let marker = std::path::Path::new(FAILURE_MARKER); + if !marker.exists() { + return; + } + + // The updater script records failure immediately before launchd restarts + // the old daemon. Preserve the retry deadline across that restart instead + // of consuming the marker and retrying the same broken release in 30 sec. + let remaining = std::fs::metadata(marker) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| { + std::time::SystemTime::now() + .duration_since(modified) + .ok() + }) + .map(|elapsed| RETRY_INTERVAL.saturating_sub(elapsed)) + .unwrap_or(RETRY_INTERVAL); + if !remaining.is_zero() { + log::info!( + "[root-update] Previous update failed; retrying in {} seconds.", + remaining.as_secs() + ); + std::thread::sleep(remaining); + } + match std::fs::remove_file(marker) { + Ok(()) => log::info!("[root-update] Previous update retry interval elapsed."), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => log::warn!("[root-update] Failed to clear failure marker: {}", err), + } +} + +/// Starts the background silent auto-update scheduler for macOS. +/// Called from `start_os_service()` which runs as root via LaunchDaemon. +#[cfg(target_os = "macos")] +pub fn start_auto_update_macos() { + let spawn_result = std::thread::Builder::new() + .name("rustdesk-auto-update".to_owned()) + .spawn(|| { + log::info!("[root-update] Auto-update scheduler thread started."); + std::thread::sleep(INITIAL_CHECK_DELAY); + wait_for_failed_update_retry(); + let mut interval = DUR_ONE_DAY; + loop { + log::info!("[root-update] Running scheduled update check..."); + let no_active_conns = has_no_active_conns_ipc(); + if !no_active_conns { + log::info!("[root-update] Active session in progress, retrying in 10 min."); + interval = MIN_INTERVAL; + } else { + match check_update_as_root() { + Ok(update_started) => { + if update_started { + // The replacement script is detached and may fail + // after this process returns. Always retry at the + // failure interval until the new daemon replaces us. + interval = RETRY_INTERVAL; + } else { + interval = DUR_ONE_DAY; + } + } + Err(e) => { + log::error!("[root-update] Update check failed: {}", e); + interval = RETRY_INTERVAL; + } + } + } + std::thread::sleep(interval); + } + }); + if let Err(err) = spawn_result { + log::error!("[root-update] Failed to start scheduler thread: {}", err); + } +} + +#[cfg(target_os = "macos")] +pub fn check_update_as_root() -> ResultType { + let _update_lock = acquire_mac_update_lock()?; + // Allow-auto-update setting + if !config::Config::get_bool_option(config::keys::OPTION_ALLOW_AUTO_UPDATE) { + log::info!("[root-update] Auto update is disabled, skipping."); + return Ok(false); + } + if crate::is_custom_client() { + log::info!("[root-update] Custom client detected, skipping stock update."); + return Ok(false); + } + // Clean up only old temp dirs from previous failed updates. The detached + // installer keeps using its update directory after this process exits and + // releases the advisory lock, so a newly-started daemon must not remove a + // directory that still belongs to the active transaction. + if let Ok(entries) = std::fs::read_dir("/tmp") { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with(".rustdeskupdate-root-") + || name_str.starts_with(".rustdeskdownload-") + { + let path = entry.path(); + let Ok(metadata) = std::fs::symlink_metadata(&path) else { + continue; + }; + let mode = metadata.mode() & 0o7777; + let is_stale = metadata + .modified() + .ok() + .and_then(|modified| std::time::SystemTime::now().duration_since(modified).ok()) + .is_some_and(|age| age >= RETRY_INTERVAL); + if metadata.file_type().is_dir() && metadata.uid() == 0 && mode == 0o700 && is_stale + { + if let Err(err) = std::fs::remove_dir_all(&path) { + log::warn!( + "[root-update] Failed to remove stale temp dir {}: {}", + path.display(), + err + ); + } + } + } + } + } + if let Err(e) = do_check_software_update() { + bail!("[root-update] Failed to check for software update: {}", e); + } + let update_url = crate::common::SOFTWARE_UPDATE_URL.lock().unwrap().clone(); + if update_url.is_empty() { + log::info!("[root-update] No update available."); + return Ok(false); + } + let download_url = update_url.replace("tag", "download"); + let version = download_url.split('/').last().unwrap_or_default().to_string(); + let arch = if std::env::consts::ARCH == "aarch64" { "aarch64" } else { "x86_64" }; + let dmg_url = format!("{}/rustdesk-{}-{}.dmg", download_url, version, arch); + log::info!("[root-update] New version: {}, downloading from {}", version, dmg_url); + // Validate URL against GitHub release allowlist before downloading as root + let Some(file_path_validated) = get_update_download_file_from_url(&dmg_url) else { + bail!("[root-update] URL failed allowlist check: {}", dmg_url); + }; + drop(file_path_validated); + let client = create_http_client_with_url_strict(&dmg_url)?; + // Use mktemp so a local user cannot pre-create a predictable path and + // permanently deny updates for a reused service PID. + let private_tmp_output = std::process::Command::new("/usr/bin/mktemp") + .args(["-d", "/tmp/.rustdeskdownload-XXXXXX"]) + .output()?; + if !private_tmp_output.status.success() { + bail!( + "[root-update] Failed to create private download directory: {}", + String::from_utf8_lossy(&private_tmp_output.stderr).trim() + ); + } + let private_tmp = String::from_utf8(private_tmp_output.stdout) + .map_err(|err| hbb_common::anyhow::anyhow!("[root-update] mktemp output error: {}", err))? + .trim() + .to_owned(); + if private_tmp.is_empty() { + bail!("[root-update] mktemp returned an empty download directory"); + } + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&private_tmp, std::fs::Permissions::from_mode(0o700))?; + } + let filename = dmg_url.split('/').last().unwrap_or("rustdesk.dmg"); + let file_path = std::path::PathBuf::from(format!("{}/{}", private_tmp, filename)); + let tmp_path = file_path.to_string_lossy().to_string(); + // Download + let mut response = client.get(&dmg_url).send()?; + if !response.status().is_success() { + let _ = std::fs::remove_dir_all(&private_tmp); + bail!("[root-update] Failed to download: {}", response.status()); + } + // Create file exclusively (O_EXCL) and stream response directly into it + { + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&file_path) + .map_err(|e| { let _ = std::fs::remove_dir_all(&private_tmp); e })?; + std::io::copy(&mut response, &mut file) + .map_err(|e| { let _ = std::fs::remove_dir_all(&private_tmp); e })?; + } + log::info!("[root-update] Downloaded to {}", tmp_path); + // Recheck active sessions before installing — download can take minutes + if !has_no_active_conns_ipc() { + if let Err(e) = std::fs::remove_dir_all(&private_tmp) { + log::warn!("[root-update] Failed to remove temp dir {}: {}", private_tmp, e); + } + bail!("[root-update] Active session started during download, deferring update."); + } + // Install silently as root + let result = crate::platform::update_from_dmg_as_root(&tmp_path, &version); + // Clean up download directory + if let Err(e) = std::fs::remove_dir_all(&private_tmp) { + log::warn!("[root-update] Failed to remove temp dir {}: {}", private_tmp, e); + } + result.map(|_| true) +} + #[cfg(test)] mod tests { use super::get_download_file_from_url;