Merge branch 'master' into id-whitelist

This commit is contained in:
21pages
2026-07-27 11:51:04 +08:00
48 changed files with 3585 additions and 235 deletions

34
Cargo.lock generated
View File

@@ -771,6 +771,26 @@ dependencies = [
"syn 2.0.98", "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]] [[package]]
name = "bit_field" name = "bit_field"
version = "0.10.2" version = "0.10.2"
@@ -2329,7 +2349,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
dependencies = [ dependencies = [
"libloading 0.7.4", "libloading 0.8.4",
] ]
[[package]] [[package]]
@@ -2694,7 +2714,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -4494,7 +4514,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d" checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d"
dependencies = [ dependencies = [
"cfg-if 1.0.0", "cfg-if 1.0.0",
"windows-targets 0.48.5", "windows-targets 0.52.6",
] ]
[[package]] [[package]]
@@ -6920,7 +6940,7 @@ dependencies = [
[[package]] [[package]]
name = "rdev" name = "rdev"
version = "0.5.0-2" version = "0.5.0-2"
source = "git+https://github.com/rustdesk-org/rdev#871bf1c856d6a30af2f56ab8848396a025140855" source = "git+https://github.com/rustdesk-org/rdev#23e24dd6b35452a495dae0ae6d99395e9755ab0f"
dependencies = [ dependencies = [
"cocoa 0.24.1", "cocoa 0.24.1",
"core-foundation 0.9.4", "core-foundation 0.9.4",
@@ -7434,7 +7454,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.11.0", "linux-raw-sys 0.11.0",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -7491,7 +7511,7 @@ dependencies = [
"security-framework 3.5.1", "security-framework 3.5.1",
"security-framework-sys", "security-framework-sys",
"webpki-root-certs", "webpki-root-certs",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -7578,7 +7598,7 @@ name = "scrap"
version = "0.5.0" version = "0.5.0"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"bindgen 0.65.1", "bindgen 0.72.1",
"block", "block",
"cfg-if 1.0.0", "cfg-if 1.0.0",
"dbus", "dbus",

View File

@@ -583,6 +583,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
} }
// record // record
if (!(isDesktop || isWeb) && if (!(isDesktop || isWeb) &&
bind.mainGetLocalOption(key: kOptionHideRecordingButton) != 'Y' &&
(ffi.recordingModel.start || (perms["recording"] != false))) { (ffi.recordingModel.start || (perms["recording"] != false))) {
v.add(TTextMenu( v.add(TTextMenu(
child: Row( child: Row(

View File

@@ -105,6 +105,7 @@ const String kOptionAutoDisconnectTimeout = "auto-disconnect-timeout";
const String kOptionEnableHwcodec = "enable-hwcodec"; const String kOptionEnableHwcodec = "enable-hwcodec";
const String kOptionAllowAutoRecordIncoming = "allow-auto-record-incoming"; const String kOptionAllowAutoRecordIncoming = "allow-auto-record-incoming";
const String kOptionAllowAutoRecordOutgoing = "allow-auto-record-outgoing"; const String kOptionAllowAutoRecordOutgoing = "allow-auto-record-outgoing";
const String kOptionHideRecordingButton = "hide-recording-button";
const String kOptionVideoSaveDirectory = "video-save-directory"; const String kOptionVideoSaveDirectory = "video-save-directory";
const String kOptionAccessMode = "access-mode"; const String kOptionAccessMode = "access-mode";
const String kOptionEnableKeyboard = "enable-keyboard"; 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 kOptionAllowMonitorSwitchMainToolbar = "allow-monitor-switch-main-toolbar";
const String kOptionAllowMonitorSwitchMinToolbar = "allow-monitor-switch-min-toolbar"; const String kOptionAllowMonitorSwitchMinToolbar = "allow-monitor-switch-min-toolbar";
const String kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys"; const String kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys";
const String kOptionShowTerminalCtrlKeys = "show-terminal-extra-ctrl-keys";
// network options // network options
const String kOptionAllowWebSocket = "allow-websocket"; const String kOptionAllowWebSocket = "allow-websocket";

View File

@@ -485,7 +485,8 @@ class _GeneralState extends State<_General> {
Widget other() { Widget other() {
final incomingOnly = bind.isIncomingOnly(); final incomingOnly = bind.isIncomingOnly();
final outgoingOnly = bind.isOutgoingOnly(); 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 = <Widget>[ final children = <Widget>[
if (!isWeb && !incomingOnly) if (!isWeb && !incomingOnly)
_OptionCheckBox(context, 'Confirm before closing multiple tabs', _OptionCheckBox(context, 'Confirm before closing multiple tabs',

View File

@@ -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;
}
}

View File

@@ -22,6 +22,7 @@ import '../../utils/image.dart';
import '../widgets/remote_toolbar.dart'; import '../widgets/remote_toolbar.dart';
import '../widgets/kb_layout_type_chooser.dart'; import '../widgets/kb_layout_type_chooser.dart';
import '../widgets/tabbar_widget.dart'; import '../widgets/tabbar_widget.dart';
import 'macos_full_screen_focus_recovery.dart';
import 'package:flutter_hbb/native/custom_cursor.dart' import 'package:flutter_hbb/native/custom_cursor.dart'
if (dart.library.html) 'package:flutter_hbb/web/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; 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 @override
State<RemotePage> createState() { State<RemotePage> createState() {
final state = _RemotePageState(id); final state = _RemotePageState(id);
@@ -76,10 +84,28 @@ class _RemotePageState extends State<RemotePage>
with with
AutomaticKeepAliveClientMixin, AutomaticKeepAliveClientMixin,
MultiWindowListener, MultiWindowListener,
WidgetsBindingObserver,
TickerProviderStateMixin { TickerProviderStateMixin {
Timer? _timer; Timer? _timer;
String keyboardMode = "legacy"; String keyboardMode = "legacy";
bool _isWindowBlur = false; 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<DesktopTabState>? _tabStateSubscription;
final _cursorOverImage = false.obs; final _cursorOverImage = false.obs;
late RxBool _showRemoteCursor; late RxBool _showRemoteCursor;
late RxBool _zoomCursor; late RxBool _zoomCursor;
@@ -122,6 +148,13 @@ class _RemotePageState extends State<RemotePage>
void initState() { void initState() {
super.initState(); super.initState();
_ffi = FFI(widget.sessionId); _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>(_ffi, tag: widget.id); Get.put<FFI>(_ffi, tag: widget.id);
_ffi.imageModel.addCallbackOnFirstImage((String peerId) { _ffi.imageModel.addCallbackOnFirstImage((String peerId) {
_ffi.canvasModel.activateLocalCursor(); _ffi.canvasModel.activateLocalCursor();
@@ -231,19 +264,224 @@ class _RemotePageState extends State<RemotePage>
_pointerLockCenterDebounceTimer = null; _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 @override
void onWindowBlur() { void onWindowBlur() {
super.onWindowBlur(); super.onWindowBlur();
// On windows, we use `focus` way to handle keyboard better. // On windows, we use `focus` way to handle keyboard better.
// Now on Linux, there's some rdev issues which will break the input. // Now on Linux, there's some rdev issues which will break the input.
// We disable the `focus` way for non-Windows temporarily. // We disable the `focus` way for Linux temporarily.
if (isWindows) { if (isWindows || isMacOS) {
_isWindowBlur = true; _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, // unfocus the primary-focus when the whole window is lost focus,
// and let OS to handle events instead. // and let OS to handle events instead.
_rawKeyFocusNode.unfocus(); _rawKeyFocusNode.unfocus();
} }
stateGlobal.isFocused.value = false; stateGlobal.isFocused.value = false;
_syncMacOSKeyboardGrab();
// When window loses focus, temporarily release relative mouse mode constraints // When window loses focus, temporarily release relative mouse mode constraints
// to allow user to interact with other applications normally. // to allow user to interact with other applications normally.
@@ -257,16 +495,41 @@ class _RemotePageState extends State<RemotePage>
void onWindowFocus() { void onWindowFocus() {
super.onWindowFocus(); super.onWindowFocus();
// See [onWindowBlur]. // See [onWindowBlur].
if (isWindows) { if (isWindows || isMacOS) {
_isWindowBlur = false; _isWindowBlur = false;
} }
if (isMacOS) stateGlobal.getInputSource(force: true);
stateGlobal.isFocused.value = 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. // Restore relative mouse mode constraints when window regains focus.
if (_ffi.inputModel.relativeMouseMode.value) { 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(); _ffi.inputModel.onWindowFocus();
} }
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
} }
@override @override
@@ -327,6 +590,13 @@ class _RemotePageState extends State<RemotePage>
void onWindowMinimize() { void onWindowMinimize() {
super.onWindowMinimize(); super.onWindowMinimize();
WakelockManager.disable(_uniqueKey); WakelockManager.disable(_uniqueKey);
if (isMacOS) {
_macOSFullScreenFocusRecovery.cancel();
_isWindowBlur = true;
_cursorOverImage.value = false;
stateGlobal.isFocused.value = false;
_syncMacOSKeyboardGrab();
}
// Release cursor constraints when minimized // Release cursor constraints when minimized
if (_ffi.inputModel.relativeMouseMode.value) { if (_ffi.inputModel.relativeMouseMode.value) {
_ffi.inputModel.onWindowBlur(); _ffi.inputModel.onWindowBlur();
@@ -338,6 +608,7 @@ class _RemotePageState extends State<RemotePage>
super.onWindowEnterFullScreen(); super.onWindowEnterFullScreen();
if (isMacOS) { if (isMacOS) {
stateGlobal.setFullscreen(true); stateGlobal.setFullscreen(true);
_queueMacOSKeyboardAfterFullScreen();
} }
} }
@@ -346,6 +617,7 @@ class _RemotePageState extends State<RemotePage>
super.onWindowLeaveFullScreen(); super.onWindowLeaveFullScreen();
if (isMacOS) { if (isMacOS) {
stateGlobal.setFullscreen(false); stateGlobal.setFullscreen(false);
_queueMacOSKeyboardAfterFullScreen();
} }
} }
@@ -354,6 +626,14 @@ class _RemotePageState extends State<RemotePage>
final closeSession = closeSessionOnDispose.remove(widget.id) ?? true; final closeSession = closeSessionOnDispose.remove(widget.id) ?? true;
// https://github.com/flutter/flutter/issues/64935 // 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(); super.dispose();
debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}"); debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}");
@@ -368,8 +648,9 @@ class _RemotePageState extends State<RemotePage>
_ffi.inputModel.onRelativeMouseModeDisabled = null; _ffi.inputModel.onRelativeMouseModeDisabled = null;
// Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...). // Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...).
_ffi.textureModel.onRemotePageDispose(closeSession); _ffi.textureModel.onRemotePageDispose(closeSession);
if (closeSession) { if (closeSession && !isMacOS) {
// ensure we leave this session, this is a double check // ensure we leave this session, this is a double check
// enterOrLeave() is already called previously in _releaseMacOSRemoteInput() for macOS.
_ffi.inputModel.enterOrLeave(false); _ffi.inputModel.enterOrLeave(false);
} }
DesktopMultiWindow.removeListener(this); DesktopMultiWindow.removeListener(this);
@@ -444,6 +725,8 @@ class _RemotePageState extends State<RemotePage>
} else { } else {
_ffi.inputModel.enterOrLeave(false); _ffi.inputModel.enterOrLeave(false);
} }
} else if (isMacOS) {
_onMacOSFocusChange();
} }
}, },
inputModel: _ffi.inputModel, inputModel: _ffi.inputModel,
@@ -549,7 +832,11 @@ class _RemotePageState extends State<RemotePage>
} }
// See [onWindowBlur]. // See [onWindowBlur].
if (!isWindows) { if (isMacOS) {
_macOSLocalFocusLost = false;
stateGlobal.getInputSource(force: true);
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
} else if (!isWindows) {
if (!_rawKeyFocusNode.hasFocus) { if (!_rawKeyFocusNode.hasFocus) {
_rawKeyFocusNode.requestFocus(); _rawKeyFocusNode.requestFocus();
} }
@@ -575,7 +862,9 @@ class _RemotePageState extends State<RemotePage>
} }
// See [onWindowBlur]. // See [onWindowBlur].
if (!isWindows) { if (isMacOS) {
_syncMacOSKeyboardGrab();
} else if (!isWindows) {
_ffi.inputModel.enterOrLeave(false); _ffi.inputModel.enterOrLeave(false);
} }
} }
@@ -600,17 +889,29 @@ class _RemotePageState extends State<RemotePage>
onEnter: onEnter, onEnter: onEnter,
onExit: onExit, onExit: onExit,
onPointerDown: (event) { 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. // 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 // 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 // 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. // ensure the grab-key thread is running when our users are clicking the remote canvas.
if (_isWindowBlur) { if ((isWindows || isMacOS) && _isWindowBlur) {
debugPrint( debugPrint(
"Unexpected status: onPointerDown is triggered while the remote window is in blur status"); "Unexpected status: onPointerDown is triggered while the remote window is in blur status");
_isWindowBlur = false; _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(); _rawKeyFocusNode.requestFocus();
} }
}, },

View File

@@ -513,15 +513,17 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
final args = jsonDecode(call.arguments); final args = jsonDecode(call.arguments);
final id = args['id']; final id = args['id'];
final close = args['close']; final close = args['close'];
RemotePage? remotePage;
try { try {
final remotePage = tabController.state.value.tabs remotePage = tabController.state.value.tabs
.firstWhere((tab) => tab.key == id) .firstWhere((tab) => tab.key == id)
.page as RemotePage; .page as RemotePage;
returnValue = remotePage.ffi.ffiModel.cachedPeerData.toString(); returnValue = remotePage.ffi.ffiModel.cachedPeerData.toString();
} catch (e) { } catch (e) {
debugPrint('Failed to get cached session data: $e'); debugPrint('Failed to get cached session data: $e');
} }
if (close && returnValue != null) { if (close && returnValue != null && remotePage != null) {
remotePage.releaseMacOSInputForTabTransfer();
closeSessionOnDispose[id] = false; closeSessionOnDispose[id] = false;
tabController.closeBy(id); tabController.closeBy(id);
} }

View File

@@ -2484,6 +2484,8 @@ class _KeyboardMenu extends StatelessWidget {
? (v) async { ? (v) async {
if (v != null) { if (v != null) {
await stateGlobal.setInputSource(ffi.sessionId, v); 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.ffiModel.checkDesktopKeyboardMode();
await ffi.inputModel.updateKeyboardMode(); await ffi.inputModel.updateKeyboardMode();
} }
@@ -2740,7 +2742,9 @@ class _RecordMenu extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
var ffi = Provider.of<FfiModel>(context); var ffi = Provider.of<FfiModel>(context);
var recordingModel = Provider.of<RecordingModel>(context); var recordingModel = Provider.of<RecordingModel>(context);
final visible = final hideRecordingButton =
bind.mainGetLocalOption(key: kOptionHideRecordingButton) == 'Y';
final visible = !hideRecordingButton &&
(recordingModel.start || ffi.permissions['recording'] != false); (recordingModel.start || ffi.permissions['recording'] != false);
if (!visible) return Offstage(); if (!visible) return Offstage();
return _IconMenuButton( return _IconMenuButton(

View File

@@ -5,8 +5,11 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/widgets/dialog.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/model.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/terminal_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:google_fonts/google_fonts.dart';
import 'package:xterm/xterm.dart'; import 'package:xterm/xterm.dart';
import '../../desktop/pages/terminal_connection_manager.dart'; import '../../desktop/pages/terminal_connection_manager.dart';
@@ -42,6 +45,11 @@ class _TerminalPageState extends State<TerminalPage>
final GlobalKey _keyboardKey = GlobalKey(); final GlobalKey _keyboardKey = GlobalKey();
double _keyboardHeight = 0; double _keyboardHeight = 0;
late bool _showTerminalExtraKeys; 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 // For iOS edge swipe gesture
double _swipeStartX = 0; double _swipeStartX = 0;
double _swipeCurrentX = 0; double _swipeCurrentX = 0;
@@ -94,6 +102,18 @@ class _TerminalPageState extends State<TerminalPage>
// terminal extra keys bar is unnecessary and disabled. // terminal extra keys bar is unnecessary and disabled.
_showTerminalExtraKeys = !isWebDesktop && _showTerminalExtraKeys = !isWebDesktop &&
mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys); 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 // Initialize terminal connection
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_ffi.dialogManager _ffi.dialogManager
@@ -148,6 +168,39 @@ class _TerminalPageState extends State<TerminalPage>
return EdgeInsets.only(left: 5.0, right: 5.0, top: topBottom, bottom: topBottom + _sysKeyboardHeight + _keyboardHeight); 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<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
@@ -185,6 +238,7 @@ class _TerminalPageState extends State<TerminalPage>
// //
// Android works fine without this workaround. // Android works fine without this workaround.
deleteDetection: isIOS, deleteDetection: isIOS,
onKeyEvent: _handleTerminalKeyEvent,
padding: _calculatePadding(heightPx), padding: _calculatePadding(heightPx),
onSecondaryTapDown: (details, offset) async { onSecondaryTapDown: (details, offset) async {
final selection = _terminalModel.terminalController.selection; final selection = _terminalModel.terminalController.selection;
@@ -193,11 +247,7 @@ class _TerminalPageState extends State<TerminalPage>
_terminalModel.terminalController.clearSelection(); _terminalModel.terminalController.clearSelection();
await Clipboard.setData(ClipboardData(text: text)); await Clipboard.setData(ClipboardData(text: text));
} else { } else {
final data = await Clipboard.getData('text/plain'); await _pasteClipboardText();
final text = data?.text;
if (text != null) {
_terminalModel.terminal.paste(text);
}
} }
}, },
); );
@@ -324,66 +374,171 @@ class _TerminalPageState extends State<TerminalPage>
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ 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( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
_buildKeyButton('Esc'), ..._buildKeyboardKeyButtons(terminalKeyboardRow2Keys),
const SizedBox(width: 2), const SizedBox(width: terminalKeyboardKeySpacing),
_buildKeyButton('/'), _buildCollapseButton(),
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'),
], ],
), ),
// 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<Widget> _buildKeyboardKeyButtons(List<String> 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) { Widget _buildKeyButton(String label) {
if (label == 'Ctrl') return _buildCtrlKeyButton();
if (label == 'Alt') return _buildAltKeyButton();
return ElevatedButton( return ElevatedButton(
onPressed: () { onPressed: () {
_sendKeyToTerminal(label); _sendKeyToTerminal(label);
}, },
child: Text(label), child: Text(label),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
minimumSize: const Size(48, 32), minimumSize: const Size(terminalKeyboardKeyWidth, 32),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
textStyle: const TextStyle(fontSize: 12), textStyle: const TextStyle(fontSize: 12),
backgroundColor: Theme.of(context).colorScheme.surfaceVariant, backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant, foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
); );
} }
void _sendKeyToTerminal(String key) { void _sendKeyToTerminal(String key) {
String? send; String send;
switch (key) { switch (key) {
case 'Esc': case 'Esc':
@@ -427,9 +582,7 @@ class _TerminalPageState extends State<TerminalPage>
break; break;
} }
if (send != null) { _terminalModel.sendVirtualKey(send);
_terminalModel.sendVirtualKey(send);
}
} }
// https://github.com/TerminalStudio/xterm.dart/issues/42#issuecomment-877495472 // https://github.com/TerminalStudio/xterm.dart/issues/42#issuecomment-877495472

View File

@@ -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;
}

View File

@@ -1,4 +1,12 @@
import 'package:flutter/services.dart'; 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 /// 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. /// by replaying a tracked Shift key-down as a synthesized key-up.
@@ -36,3 +44,147 @@ bool shouldReleaseStaleMobileShift({
} }
return true; 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;
}

View File

@@ -7,6 +7,7 @@ import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/main.dart'; import 'package:flutter_hbb/main.dart';
import 'package:xterm/xterm.dart'; import 'package:xterm/xterm.dart';
import 'input_modifier_utils.dart';
import 'model.dart'; import 'model.dart';
import 'platform_model.dart'; import 'platform_model.dart';
@@ -22,7 +23,25 @@ class TerminalModel with ChangeNotifier {
bool _disposed = false; 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 = <String>[]; final _inputBuffer = <String>[];
/// 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. // Buffer for output data received before terminal view has valid dimensions.
// This prevents NaN errors when writing to terminal before layout is complete. // This prevents NaN errors when writing to terminal before layout is complete.
final _pendingOutputChunks = <String>[]; final _pendingOutputChunks = <String>[];
@@ -42,6 +61,10 @@ class TerminalModel with ChangeNotifier {
VoidCallback? onClosed; VoidCallback? onClosed;
Future<void> _handleInput(String data) async { Future<void> _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 // Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a
// real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'. // real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'.
// - Peer Windows: '\r' works, '\n' is just a newline. // - 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'. // (readline, prompt_toolkit, vim, TUI frameworks) expect '\r'.
// - Peer macOS: same as Linux, raw-mode apps expect '\r' // - Peer macOS: same as Linux, raw-mode apps expect '\r'
// (https://github.com/rustdesk/rustdesk/issues/14907). // (https://github.com/rustdesk/rustdesk/issues/14907).
// So on mobile / web-mobile, always normalize a lone '\n' to '\r'. // So on mobile / web-mobile, normalize the original lone '\n' to '\r'
// We deliberately do not touch multi-character payloads (e.g. pasted text) // before modifier mappings. This keeps Ctrl+J mapped to LF instead of
// so embedded newlines in pasted content are preserved. // having the generated control code rewritten to CR afterward.
final isMobileOrWebMobile = (isMobile || (isWeb && !isWebDesktop)); // Multi-character keyboard payloads, such as terminal escape sequences,
if (isMobileOrWebMobile && data == '\n') { // remain unchanged. Paste input follows a separate preprocessing path.
data = '\r'; 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<void> _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) { if (_terminalOpened) {
// Send user input to remote terminal // Send user input to remote terminal
try { try {
@@ -176,6 +230,18 @@ class TerminalModel with ChangeNotifier {
return _handleInput(data); return _handleInput(data);
} }
Future<void> pasteText(String data) async {
final payload = prepareTerminalInputPayload(
data,
source: TerminalInputSource.paste,
isMobileOrWebMobile: false,
bracketedPasteMode: terminal.bracketedPasteMode,
ctrlLocked: false,
altLocked: false,
);
return _sendInputPayload(payload);
}
Future<void> closeTerminal() async { Future<void> closeTerminal() async {
if (_terminalOpened) { if (_terminalOpened) {
try { try {
@@ -516,6 +582,14 @@ class TerminalModel with ChangeNotifier {
void dispose() { void dispose() {
if (_disposed) return; if (_disposed) return;
_disposed = true; _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 // Clear buffers to free memory
_inputBuffer.clear(); _inputBuffer.clear();
_pendingOutputChunks.clear(); _pendingOutputChunks.clear();

View File

@@ -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,
);
});
});
} }

View File

@@ -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));
});
});
}

View File

@@ -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<String>();
// 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);
});
}

View File

@@ -14,6 +14,7 @@
typedef char** (*FUNC_RUSTDESK_CORE_MAIN)(int*); typedef char** (*FUNC_RUSTDESK_CORE_MAIN)(int*);
typedef void (*FUNC_RUSTDESK_FREE_ARGS)( char**, int); typedef void (*FUNC_RUSTDESK_FREE_ARGS)( char**, int);
typedef int (*FUNC_RUSTDESK_GET_APP_NAME)(wchar_t*, 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]. /// Note: `--server`, `--service` are already handled in [core_main.rs].
const std::vector<std::string> parameters_white_list = {"--install", "--cm"}; const std::vector<std::string> parameters_white_list = {"--install", "--cm"};
@@ -62,6 +63,22 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
} }
std::vector<std::string> rust_args(c_args, c_args + args_len); std::vector<std::string> rust_args(c_args, c_args + args_len);
free_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"; 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"); 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; is_cm_page = true;
} }
bool is_install_page = false; 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) { if (!command_line_arguments.empty() && command_line_arguments.front().compare(0, installParam.size(), installParam.c_str()) == 0) {
is_install_page = true; is_install_page = true;
} }

View File

@@ -48,7 +48,7 @@ quest = "0.3"
[build-dependencies] [build-dependencies]
target_build_utils = "0.3" target_build_utils = "0.3"
bindgen = "0.65" bindgen = "0.72.1"
pkg-config = { version = "0.3.27", optional = true } pkg-config = { version = "0.3.27", optional = true }
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]

View File

@@ -20,6 +20,22 @@ use webm::mux::{self, Segment, Track, VideoTrack, Writer};
const MIN_SECS: u64 = 1; 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)] #[derive(Debug, Clone)]
pub struct RecorderContext { pub struct RecorderContext {
pub server: bool, pub server: bool,
@@ -45,7 +61,7 @@ impl RecorderContext2 {
} }
let file = if ctx.server { "incoming" } else { "outgoing" }.to_string() 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() + &chrono::Local::now().format("_%Y%m%d%H%M%S%3f_").to_string()
+ &format!( + &format!(
"{}{}_", "{}{}_",
@@ -421,3 +437,24 @@ impl Drop for HwRecorder {
self.ctx.tx.as_ref().map(|tx| tx.send(state)); 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"
);
}
}

View File

@@ -14,6 +14,9 @@ lazy_static! {
static ref DISPLAYS: Mutex<Option<Arc<Displays>>> = Mutex::new(None); static ref DISPLAYS: Mutex<Option<Arc<Displays>>> = 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); const COMMAND_TIMEOUT: Duration = Duration::from_millis(1000);
pub struct Displays { pub struct Displays {
@@ -217,7 +220,26 @@ pub fn clear_wayland_displays_cache() {
// Return (min_x, max_x, min_y, max_y) // Return (min_x, max_x, min_y, max_y)
pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> { pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> {
let wayland_displays = get_displays(); 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<DisplayRect>)> {
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() { if displays.is_empty() {
return None; 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, // 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 // 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. // 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 ({}, {}).", "Display at ({}, {}) is missing logical_size; falling back to physical size ({}, {}).",
d.x, d.y, d.width, d.height d.x, d.y, d.width, d.height
); );
}
(d.width, d.height) (d.width, d.height)
}; };
max_x = max_x.max(d.x + size.0); 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)) 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<DisplayRect> {
// 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<DisplayRect> {
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)]
);
}
}

View File

@@ -1401,6 +1401,10 @@ impl AudioHandler {
/// Handle audio format and create an audio decoder. /// Handle audio format and create an audio decoder.
pub fn handle_format(&mut self, f: AudioFormat) { 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 }) { match AudioDecoder::new(f.sample_rate, if f.channels > 1 { Stereo } else { Mono }) {
Ok(d) => { Ok(d) => {
let buffer = vec![0.; f.sample_rate as usize * f.channels as usize]; 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`]. /// Video handler for the [`Client`].
pub struct VideoHandler { pub struct VideoHandler {
decoder: Decoder, decoder: Decoder,

View File

@@ -36,6 +36,17 @@ const CLIPBOARD_GET_MAX_RETRY: usize = 3;
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
const CLIPBOARD_GET_RETRY_INTERVAL_DUR: Duration = Duration::from_millis(33); 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"))] #[cfg(not(target_os = "android"))]
const SUPPORTED_FORMATS: &[ClipboardFormat] = &[ const SUPPORTED_FORMATS: &[ClipboardFormat] = &[
ClipboardFormat::Text, ClipboardFormat::Text,
@@ -722,11 +733,15 @@ mod proto {
Ok(ClipboardFormat::Text) => String::from_utf8(data).ok().map(ClipboardData::Text), Ok(ClipboardFormat::Text) => String::from_utf8(data).ok().map(ClipboardData::Text),
Ok(ClipboardFormat::Rtf) => String::from_utf8(data).ok().map(ClipboardData::Rtf), Ok(ClipboardFormat::Rtf) => String::from_utf8(data).ok().map(ClipboardData::Rtf),
Ok(ClipboardFormat::Html) => String::from_utf8(data).ok().map(ClipboardData::Html), Ok(ClipboardFormat::Html) => String::from_utf8(data).ok().map(ClipboardData::Html),
Ok(ClipboardFormat::ImageRgba) => Some(ClipboardData::Image(arboard::ImageData::rgba( Ok(ClipboardFormat::ImageRgba) => {
clipboard.width as _, let (width, height) =
clipboard.height as _, super::valid_rgba_dimensions(clipboard.width, clipboard.height, data.len())?;
data.into(), Some(ClipboardData::Image(arboard::ImageData::rgba(
))), width,
height,
data.into(),
)))
}
Ok(ClipboardFormat::ImagePng) => { Ok(ClipboardFormat::ImagePng) => {
Some(ClipboardData::Image(arboard::ImageData::png(data.into()))) 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")] #[cfg(target_os = "android")]
pub fn handle_msg_clipboard(mut cb: Clipboard) { pub fn handle_msg_clipboard(mut cb: Clipboard) {
use hbb_common::protobuf::Message; use hbb_common::protobuf::Message;

View File

@@ -1024,7 +1024,7 @@ pub fn get_full_name() -> String {
} }
pub fn is_setup(name: &str) -> bool { 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 { 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) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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 // ThrottledInterval tick at the same time as tokio interval, if no sleeps
#[allow(non_snake_case)] #[allow(non_snake_case)]
#[tokio::test] #[tokio::test]

View File

@@ -127,6 +127,13 @@ pub fn core_main() -> Option<Vec<String>> {
if args.contains(&"--noinstall".to_string()) { if args.contains(&"--noinstall".to_string()) {
args.clear(); 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.len() > 0 {
if args[0] == "--version" { if args[0] == "--version" {
println!("{}", crate::VERSION); println!("{}", crate::VERSION);
@@ -660,7 +667,8 @@ pub fn core_main() -> Option<Vec<String>> {
None 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) { match crate::ui_interface::deploy_device(token, new_id) {
crate::ui_interface::DeployResult::Ok => { crate::ui_interface::DeployResult::Ok => {
println!("Device deployed."); println!("Device deployed.");

View File

@@ -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 _; 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 // https://gist.github.com/iskakaushik/1c5b8aa75c77479c33c4320913eebef6
#[cfg(windows)] #[cfg(windows)]
fn rust_args_to_c_args(args: Vec<String>, outlen: *mut c_int) -> *mut *mut c_char { fn rust_args_to_c_args(args: Vec<String>, outlen: *mut c_int) -> *mut *mut c_char {

View File

@@ -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; pub(crate) use ipc_auth::log_rejected_windows_ipc_connection;
#[cfg(any(target_os = "linux", target_os = "macos"))] #[cfg(any(target_os = "linux", target_os = "macos"))]
use ipc_auth::{active_uid, authorize_service_scoped_ipc_connection}; use ipc_auth::{active_uid, authorize_service_scoped_ipc_connection};
#[cfg(target_os = "macos")]
use ipc_auth::authorize_user_server_process;
#[cfg(windows)] #[cfg(windows)]
use ipc_auth::{ use ipc_auth::{
authorize_windows_main_ipc_connection, portable_service_listener_security_attributes, authorize_windows_main_ipc_connection, portable_service_listener_security_attributes,
@@ -472,6 +474,8 @@ pub enum Data {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
PortForwardSessionCount(Option<usize>), PortForwardSessionCount(Option<usize>),
SocksWs(Option<Box<(Option<config::Socks5Server>, String)>>), SocksWs(Option<Box<(Option<config::Socks5Server>, String)>>),
#[cfg(target_os = "macos")]
HasNoActiveConns(Option<bool>),
#[cfg(not(any(target_os = "android", target_os = "ios")))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
Whiteboard((String, crate::whiteboard::CustomEvent)), Whiteboard((String, crate::whiteboard::CustomEvent)),
ControlPermissionsRemoteModify(Option<bool>), ControlPermissionsRemoteModify(Option<bool>),
@@ -881,8 +885,14 @@ async fn handle(data: Data, stream: &mut Connection) {
Some(value) => { Some(value) => {
let mut updated = true; let mut updated = true;
if name == "id" { if name == "id" {
Config::set_key_confirmed(false); // An empty id would wipe the local id and unconfirm the key (cf. #15626).
Config::set_id(&value); 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" { } else if name == "temporary-password" {
password::update_temporary_password(); password::update_temporary_password();
} else if name == "permanent-password" { } else if name == "permanent-password" {
@@ -1000,6 +1010,16 @@ async fn handle(data: Data, stream: &mut Connection) {
.await .await
); );
} }
#[cfg(target_os = "macos")]
Data::HasNoActiveConns(None) => {
allow_err!(
stream
.send(&Data::HasNoActiveConns(Some(
crate::updater::has_no_active_conns()
)))
.await
);
}
#[cfg(all( #[cfg(all(
feature = "flutter", feature = "flutter",
not(any(target_os = "android", target_os = "ios")) not(any(target_os = "android", target_os = "ios"))
@@ -1334,14 +1354,21 @@ pub async fn connect(ms_timeout: u64, postfix: &str) -> ResultType<ConnectionTmp
} }
} }
#[cfg(target_os = "linux")] #[cfg(any(target_os = "linux", target_os = "macos"))]
pub async fn connect_for_uid( pub async fn connect_for_uid(
ms_timeout: u64, ms_timeout: u64,
uid: u32, uid: u32,
postfix: &str, postfix: &str,
) -> ResultType<ConnectionTmpl<ConnClient>> { ) -> ResultType<ConnectionTmpl<ConnClient>> {
let path = Config::ipc_path_for_uid(uid, postfix); 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")] #[cfg(target_os = "linux")]
@@ -1689,19 +1716,24 @@ pub fn clear_trusted_devices() {
} }
pub fn get_id() -> String { 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") { if let Ok(Some(v)) = get_config("id") {
// update salt also, so that next time reinstallation not causing first-time auto-login failure if !v.is_empty() {
if let Ok(Some(v2)) = get_config("salt") { // update salt also, so that next time reinstallation not causing first-time auto-login failure
Config::set_salt(&v2); 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<String>) { pub async fn get_rendezvous_server(ms_timeout: u64) -> (String, Vec<String>) {

View File

@@ -656,6 +656,32 @@ pub(crate) fn authorize_service_scoped_ipc_connection(stream: &Connection, postf
true true
} }
#[cfg(target_os = "macos")]
pub(crate) fn authorize_user_server_process(
peer_uid: Option<u32>,
peer_pid: Option<u32>,
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)] #[cfg(windows)]
pub(crate) fn authorize_windows_main_ipc_connection(stream: &Connection, postfix: &str) -> bool { pub(crate) fn authorize_windows_main_ipc_connection(stream: &Connection, postfix: &str) -> bool {
let ( let (

View File

@@ -241,6 +241,14 @@ fn wait_response(
Some(rendezvous_message::Union::PeerDiscovery(p)) => { Some(rendezvous_message::Union::PeerDiscovery(p)) => {
last_recv_time = Instant::now(); last_recv_time = Instant::now();
if p.cmd == "pong" { 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 { let local_mac = if try_get_ip_by_peer {
if let Some(self_addr) = get_ipaddr_by_peer(&addr) { if let Some(self_addr) = get_ipaddr_by_peer(&addr) {
get_mac(&self_addr) get_mac(&self_addr)

View File

@@ -332,7 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Relay Connection", "中继连接"), ("Relay Connection", "中继连接"),
("Secure Connection", "安全连接"), ("Secure Connection", "安全连接"),
("Insecure Connection", "非安全连接"), ("Insecure Connection", "非安全连接"),
("Continue", ""), ("Continue", "继续"),
("Scale original", "原始尺寸"), ("Scale original", "原始尺寸"),
("Scale adaptive", "适应窗口"), ("Scale adaptive", "适应窗口"),
("General", "常规"), ("General", "常规"),

View File

@@ -332,7 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Relay Connection", "Połączenie przez bramkę"), ("Relay Connection", "Połączenie przez bramkę"),
("Secure Connection", "Połączenie szyfrowane"), ("Secure Connection", "Połączenie szyfrowane"),
("Insecure Connection", "Połączenie nieszyfrowane"), ("Insecure Connection", "Połączenie nieszyfrowane"),
("Continue", ""), ("Continue", "Kontynuuj"),
("Scale original", "Skalowanie oryginalne"), ("Scale original", "Skalowanie oryginalne"),
("Scale adaptive", "Dopasuj do wyświetlacza"), ("Scale adaptive", "Dopasuj do wyświetlacza"),
("General", "Ogólne"), ("General", "Ogólne"),

View File

@@ -646,6 +646,16 @@ fn try_start_server_(desktop: Option<&Desktop>) -> ResultType<Option<Child>> {
if !desktop.dbus.is_empty() { if !desktop.dbus.is_empty() {
envs.push(("DBUS_SESSION_BUS_ADDRESS", desktop.dbus.clone())); 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(( envs.push((
"TERM", "TERM",
get_cur_term(&desktop.uid).unwrap_or_else(|| suggest_best_term()), get_cur_term(&desktop.uid).unwrap_or_else(|| suggest_best_term()),

View File

@@ -118,16 +118,18 @@ extern "C" bool MacCheckAdminAuthorization() {
// https://gist.github.com/briankc/025415e25900750f402235dbf1b74e42 // https://gist.github.com/briankc/025415e25900750f402235dbf1b74e42
extern "C" float BackingScaleFactor(uint32_t display) { extern "C" float BackingScaleFactor(uint32_t display) {
NSArray<NSScreen *> *screens = [NSScreen screens]; @autoreleasepool {
for (NSScreen *screen in screens) { NSArray<NSScreen *> *screens = [NSScreen screens];
NSDictionary *deviceDescription = [screen deviceDescription]; for (NSScreen *screen in screens) {
NSNumber *screenNumber = [deviceDescription objectForKey:@"NSScreenNumber"]; NSDictionary *deviceDescription = [screen deviceDescription];
CGDirectDisplayID screenDisplayID = [screenNumber unsignedIntValue]; NSNumber *screenNumber = [deviceDescription objectForKey:@"NSScreenNumber"];
if (screenDisplayID == display) { CGDirectDisplayID screenDisplayID = [screenNumber unsignedIntValue];
return [screen backingScaleFactor]; if (screenDisplayID == display) {
return [screen backingScaleFactor];
}
} }
return 1;
} }
return 1;
} }
// https://github.com/jhford/screenresolution/blob/master/cg_utils.c // https://github.com/jhford/screenresolution/blob/master/cg_utils.c

View File

@@ -312,6 +312,55 @@ fn correct_app_name(s: &str) -> String {
s 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 { pub fn uninstall_service(show_new_window: bool, sync: bool) -> bool {
// to-do: do together with win/linux about refactory start/stop service // to-do: do together with win/linux about refactory start/stop service
if !is_installed_daemon(false) { if !is_installed_daemon(false) {
@@ -659,6 +708,61 @@ pub fn get_active_userid() -> String {
get_active_user("-n") 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<u32> {
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::<u32>()
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::<u32>() {
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<PathBuf> { pub fn get_active_user_home() -> Option<PathBuf> {
let username = get_active_username(); let username = get_active_username();
if !username.is_empty() { if !username.is_empty() {
@@ -728,8 +832,12 @@ pub fn lock_screen() {
.ok(); .ok();
} }
/// Starts the macOS system service IPC listener and the background
/// silent auto-update thread.
pub fn start_os_service() { pub fn start_os_service() {
log::info!("Username: {}", crate::username()); 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") { if let Err(err) = crate::ipc::start("_service") {
log::error!("Failed to start ipc_service: {}", err); log::error!("Failed to start ipc_service: {}", err);
} }
@@ -912,6 +1020,760 @@ pub fn update_to(_file: &str) -> ResultType<()> {
Ok(()) 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<String> {
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::<Vec<_>>()
.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/<asid> 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) { pub fn extract_update_dmg(file: &str) {
let update_temp_dir = get_update_temp_dir_string(); let update_temp_dir = get_update_temp_dir_string();
let mut evt: HashMap<&str, 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<()> { fn extract_dmg(dmg_path: &str, target_dir: &str) -> ResultType<()> {
let mount_point = "/Volumes/RustDeskUpdate";
let target_path = Path::new(target_dir); let target_path = Path::new(target_dir);
if target_path.exists() { if target_path.exists() {
std::fs::remove_dir_all(target_path)?; std::fs::remove_dir_all(target_path)?;
} }
std::fs::create_dir_all(target_path)?; std::fs::create_dir_all(target_path)?;
extract_dmg_inner(dmg_path, target_dir)
}
let status = Command::new("hdiutil") fn extract_dmg_into_existing_dir(dmg_path: &str, target_dir: &str) -> ResultType<()> {
.args(&["attach", "-nobrowse", "-mountpoint", mount_point, dmg_path]) 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()?; .status()?;
if !status.success() { if !status.success() {
let _ = std::fs::remove_dir(&mount_point);
bail!("Failed to attach DMG image at {}: {:?}", dmg_path, status); bail!("Failed to attach DMG image at {}: {:?}", dmg_path, status);
} }
struct DmgGuard(&'static str); struct DmgGuard(String);
impl Drop for DmgGuard { impl Drop for DmgGuard {
fn drop(&mut self) { fn drop(&mut self) {
let _ = Command::new("hdiutil") let _ = Command::new("/usr/bin/hdiutil")
.args(&["detach", self.0, "-force"]) .args(["detach", self.0.as_str(), "-force"])
.status(); .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 app_name = format!("{}.app", crate::get_app_name());
let src_path = format!("{}/{}", mount_point, app_name); let src_path = format!("{}/{}", mount_point, app_name);
let dest_path = format!("{}/{}", target_dir, 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]) .args(&[&src_path, &dest_path])
.status()?; .status()?;

View File

@@ -23,8 +23,8 @@
<key>WorkingDirectory</key> <key>WorkingDirectory</key>
<string>/Applications/RustDesk.app/Contents/MacOS/</string> <string>/Applications/RustDesk.app/Contents/MacOS/</string>
<key>StandardErrorPath</key> <key>StandardErrorPath</key>
<string>/tmp/rustdesk_service.err</string> <string>/var/log/rustdesk_service.err</string>
<key>StandardOutPath</key> <key>StandardOutPath</key>
<string>/tmp/rustdesk_service.out</string> <string>/var/log/rustdesk_service.out</string>
</dict> </dict>
</plist> </plist>

View File

@@ -1,14 +1,18 @@
on run {daemon_file, agent_file, user} 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 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 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 set sh to sh1 & sh2 & sh3 & sh4 & sh5

View File

@@ -2278,6 +2278,10 @@ fn get_shortcut_icon_location(install_dir: &str, exe: &str) -> String {
} }
pub fn create_shortcut(id: &str) -> ResultType<()> { 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(); let exe = std::env::current_exe()?.to_str().unwrap_or("").to_owned();
// https://github.com/rustdesk/rustdesk/issues/13735 // https://github.com/rustdesk/rustdesk/issues/13735
// Replace ':' with '_' for filename since ':' is not allowed in Windows filenames // Replace ':' with '_' for filename since ':' is not allowed in Windows filenames

View File

@@ -357,15 +357,13 @@ impl Server {
} }
} }
pub fn try_add_primay_video_service(&mut self) { pub fn try_add_monitor_service(&mut self, display_idx: usize) {
let primary_video_service_name = video_service::get_service_name( let monitor_service_name =
VideoSource::Monitor, video_service::get_service_name(VideoSource::Monitor, display_idx);
*display_service::PRIMARY_DISPLAY_IDX, if !self.contains(&monitor_service_name) {
);
if !self.contains(&primary_video_service_name) {
self.add_service(Box::new(video_service::new( self.add_service(Box::new(video_service::new(
VideoSource::Monitor, VideoSource::Monitor,
*display_service::PRIMARY_DISPLAY_IDX, display_idx,
))); )));
} }
} }
@@ -381,14 +379,17 @@ impl Server {
self.connections.insert(conn.id(), conn); self.connections.insert(conn.id(), conn);
} }
pub fn add_connection(&mut self, conn: ConnInner, noperms: &Vec<&'static str>) { pub fn add_monitor_connection(
let primary_video_service_name = video_service::get_service_name( &mut self,
VideoSource::Monitor, conn: ConnInner,
*display_service::PRIMARY_DISPLAY_IDX, 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() { for s in self.services.values() {
let name = s.name(); 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; continue;
} }
if !noperms.contains(&(&name as _)) { if !noperms.contains(&(&name as _)) {
@@ -783,8 +784,7 @@ async fn sync_and_watch_config_dir(sync_done_tx: Option<tokio::sync::oneshot::Se
loop { loop {
sleep(CONFIG_SYNC_INTERVAL_SECS).await; sleep(CONFIG_SYNC_INTERVAL_SECS).await;
let cfg = (Config::get(), Config2::get()); let cfg = (Config::get(), Config2::get());
let should_sync = let should_sync = cfg != cfg0 || (is_root_config_empty && !cfg.0.is_empty());
cfg != cfg0 || (is_root_config_empty && !cfg.0.is_empty());
if should_sync { if should_sync {
if is_root_config_empty { if is_root_config_empty {
log::info!("root config is empty, sync our config to root"); log::info!("root config is empty, sync our config to root");

View File

@@ -509,7 +509,9 @@ impl Connection {
tx_video: Some(tx_video), tx_video: Some(tx_video),
}, },
require_2fa: crate::auth_2fa::get_2fa(None), require_2fa: crate::auth_2fa::get_2fa(None),
display_idx: *display_service::PRIMARY_DISPLAY_IDX, // Defer display enumeration until login succeeds. Monitor login replaces this
// with the primary index returned with the refreshed display snapshot.
display_idx: 0,
stream, stream,
server, server,
hash, hash,
@@ -1923,13 +1925,15 @@ impl Connection {
Err(err) => { Err(err) => {
res.set_error(format!("{}", err)); 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. // 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. // But the displays may be updated later, before creating the video capturer.
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
self.retina.set_displays(&displays); 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.displays = displays;
pi.current_display = self.display_idx as _; pi.current_display = self.display_idx as _;
#[cfg(not(any(target_os = "android", target_os = "ios")))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
@@ -2038,8 +2042,8 @@ impl Connection {
#[cfg(not(any(target_os = "android", target_os = "ios")))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
let _h = try_start_record_cursor_pos(); let _h = try_start_record_cursor_pos();
self.auto_disconnect_timer = Self::get_auto_disconenct_timer(); self.auto_disconnect_timer = Self::get_auto_disconenct_timer();
s.try_add_primay_video_service(); s.try_add_monitor_service(self.display_idx);
s.add_connection(self.inner.clone(), &noperms); s.add_monitor_connection(self.inner.clone(), &noperms, self.display_idx);
} }
} }
} }
@@ -2842,7 +2846,6 @@ impl Connection {
#[cfg(feature = "flutter")] #[cfg(feature = "flutter")]
#[cfg(not(any(target_os = "android", target_os = "ios")))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
if let Some(lr) = _s.lr.clone().take() { 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 // Switching sides authorizes without a password, so it must not bypass the
// whitelist, which can be a locked policy pushed by the server. // whitelist, which can be a locked policy pushed by the server.
if !self.check_id_whitelist().await { 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 Ok(uuid) = uuid::Uuid::from_slice(_s.uuid.to_vec().as_ref()) {
if let Some((_instant, uuid_old)) = uuid_old { if let Some((_instant, uuid_old)) = uuid_old {
if uuid == 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.from_switch = true;
self.set_conn_audit_primary_auth(ConnAuditPrimaryAuth::SwitchSides); self.set_conn_audit_primary_auth(ConnAuditPrimaryAuth::SwitchSides);
if !self.send_logon_response_and_keep_alive().await { if !self.send_logon_response_and_keep_alive().await {
@@ -4182,7 +4194,9 @@ impl Connection {
let display_idx = s.display as usize; let display_idx = s.display as usize;
if self.display_idx != display_idx { if self.display_idx != display_idx {
if let Some(server) = self.server.upgrade() { 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")))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
if !self.view_camera && s.width != 0 && s.height != 0 { 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 { fn video_source(&self) -> VideoSource {
if self.view_camera { if self.view_camera {
VideoSource::Camera VideoSource::Camera
@@ -4217,18 +4238,28 @@ impl Connection {
} }
} }
fn switch_display_to(&mut self, display_idx: usize, server: Arc<RwLock<Server>>) { fn switch_display_to(&mut self, display_idx: usize, server: Arc<RwLock<Server>>) -> 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 new_service_name = video_service::get_service_name(self.video_source(), display_idx);
let old_service_name = let old_service_name =
video_service::get_service_name(self.video_source(), self.display_idx); video_service::get_service_name(self.video_source(), self.display_idx);
let mut lock = server.write().unwrap(); let mut lock = server.write().unwrap();
if display_idx != *display_service::PRIMARY_DISPLAY_IDX { if !lock.contains(&new_service_name) {
if !lock.contains(&new_service_name) { lock.add_service(Box::new(video_service::new(
lock.add_service(Box::new(video_service::new( self.video_source(),
self.video_source(), display_idx,
display_idx, )));
)));
}
} }
// For versions greater than 1.2.4, a `CaptureDisplays` message will be sent immediately. // For versions greater than 1.2.4, a `CaptureDisplays` message will be sent immediately.
// Unnecessary capturers will be removed then. // Unnecessary capturers will be removed then.
@@ -4237,6 +4268,7 @@ impl Connection {
} }
lock.subscribe(&new_service_name, self.inner.clone(), true); lock.subscribe(&new_service_name, self.inner.clone(), true);
self.display_idx = display_idx; self.display_idx = display_idx;
true
} }
#[cfg(windows)] #[cfg(windows)]
@@ -4263,26 +4295,61 @@ impl Connection {
async fn capture_displays(&mut self, add: &[usize], sub: &[usize], set: &[usize]) { async fn capture_displays(&mut self, add: &[usize], sub: &[usize], set: &[usize]) {
let video_source = self.video_source(); let video_source = self.video_source();
if let Some(sever) = self.server.upgrade() { let source_count = Self::video_source_count(video_source);
let mut lock = sever.write().unwrap(); // Only add/set can create services; sub only narrows existing subscriptions.
for display in add.iter() { let valid_add = add
.iter()
.copied()
.filter(|display| *display < source_count)
.collect::<Vec<_>>();
let valid_sub = sub
.iter()
.copied()
.filter(|display| *display < source_count)
.collect::<Vec<_>>();
let valid_set = set
.iter()
.copied()
.filter(|display| *display < source_count)
.collect::<Vec<_>>();
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); let service_name = video_service::get_service_name(video_source, *display);
if !lock.contains(&service_name) { if !lock.contains(&service_name) {
lock.add_service(Box::new(video_service::new(video_source, *display))); 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); let service_name = video_service::get_service_name(video_source, *display);
if !lock.contains(&service_name) { if !lock.contains(&service_name) {
lock.add_service(Box::new(video_service::new(video_source, *display))); lock.add_service(Box::new(video_service::new(video_source, *display)));
} }
} }
if !add.is_empty() { 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() { } 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 { } 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; self.multi_ui_session = lock.get_subbed_displays_count(self.inner.id()) > 1;
if self.follow_remote_window { if self.follow_remote_window {
@@ -5696,6 +5763,7 @@ impl Connection {
Some(misc::Union::ChangeDisplayResolution(_)) => "misc.change_display_resolution", Some(misc::Union::ChangeDisplayResolution(_)) => "misc.change_display_resolution",
Some(misc::Union::MessageQuery(_)) => "misc.message_query", Some(misc::Union::MessageQuery(_)) => "misc.message_query",
Some(misc::Union::FollowCurrentDisplay(_)) => "misc.follow_current_display", Some(misc::Union::FollowCurrentDisplay(_)) => "misc.follow_current_display",
Some(misc::Union::SwitchSidesRequest(_)) => "misc.switch_sides_request",
Some(_) => "misc.other", Some(_) => "misc.other",
None => "misc.empty", None => "misc.empty",
} }
@@ -6947,6 +7015,10 @@ mod test {
misc_msg(|m| m.set_capture_displays(CaptureDisplays::new())), misc_msg(|m| m.set_capture_displays(CaptureDisplays::new())),
Some("misc.capture_displays"), 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_clipboard(Clipboard::new())), None),
( (
msg(|m| m.set_multi_clipboards(MultiClipboards::new())), msg(|m| m.set_multi_clipboards(MultiClipboards::new())),
@@ -6991,6 +7063,10 @@ mod test {
misc_msg(|m| m.set_toggle_privacy_mode(TogglePrivacyMode::new())), misc_msg(|m| m.set_toggle_privacy_mode(TogglePrivacyMode::new())),
Some("misc.toggle_privacy_mode"), 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), (misc_msg(|m| m.set_chat_message(ChatMessage::new())), None),
(msg(|m| m.set_clipboard(Clipboard::new())), None), (msg(|m| m.set_clipboard(Clipboard::new())), None),
( (
@@ -7076,6 +7152,10 @@ mod test {
msg(|m| m.set_terminal_action(TerminalAction::new())), msg(|m| m.set_terminal_action(TerminalAction::new())),
Some("terminal_action"), 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, None,
), ),
(msg(|m| m.set_terminal_action(TerminalAction::new())), 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())), msg(|m| m.set_screenshot_request(ScreenshotRequest::new())),
Some("screenshot_request"), 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(true)), None),
(misc_msg(|m| m.set_refresh_video_display(0)), None), (misc_msg(|m| m.set_refresh_video_display(0)), None),
( (

View File

@@ -25,12 +25,147 @@ struct ChangedResolution {
lazy_static::lazy_static! { lazy_static::lazy_static! {
static ref IS_CAPTURER_MAGNIFIER_SUPPORTED: bool = is_capturer_mag_supported(); static ref IS_CAPTURER_MAGNIFIER_SUPPORTED: bool = is_capturer_mag_supported();
static ref CHANGED_RESOLUTIONS: Arc<RwLock<HashMap<String, ChangedResolution>>> = Default::default(); static ref CHANGED_RESOLUTIONS: Arc<RwLock<HashMap<String, ChangedResolution>>> = 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<Mutex<SyncDisplaysInfo>> = Default::default(); static ref SYNC_DISPLAYS: Arc<Mutex<SyncDisplaysInfo>> = Default::default();
} }
#[cfg(target_os = "linux")]
lazy_static::lazy_static! {
static ref WAYLAND_UINPUT_RECT: Mutex<WaylandUinputRect> = Default::default();
static ref WAYLAND_LAYOUT: Mutex<WaylandLayout> = 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<std::time::Instant>,
}
// 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<scrap::wayland::display::DisplayRect>,
live: Vec<scrap::wayland::display::DisplayRect>,
}
// 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<scrap::wayland::display::DisplayRect>) {
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 // https://github.com/rustdesk/rustdesk/pull/8537
static TEMP_IGNORE_DISPLAYS_CHANGED: AtomicBool = AtomicBool::new(false); static TEMP_IGNORE_DISPLAYS_CHANGED: AtomicBool = AtomicBool::new(false);
@@ -41,22 +176,14 @@ struct SyncDisplaysInfo {
} }
impl SyncDisplaysInfo { impl SyncDisplaysInfo {
fn check_changed(&mut self, displays: Vec<DisplayInfo>) { fn check_changed(&mut self, displays: &[DisplayInfo]) {
if self.displays.len() != displays.len() { if self.displays.as_slice() == displays {
self.displays = displays;
if !TEMP_IGNORE_DISPLAYS_CHANGED.load(Ordering::Relaxed) {
self.is_synced = false;
}
return; return;
} }
for (i, d) in displays.iter().enumerate() {
if d != &self.displays[i] { self.displays = displays.to_vec();
self.displays = displays; if !TEMP_IGNORE_DISPLAYS_CHANGED.load(Ordering::Relaxed) {
if !TEMP_IGNORE_DISPLAYS_CHANGED.load(Ordering::Relaxed) { self.is_synced = false;
self.is_synced = false;
}
return;
}
} }
} }
@@ -242,6 +369,12 @@ fn run(sp: EmptyExtraFieldService) -> ResultType<()> {
sp.send(msg_out); sp.send(msg_out);
log::info!("Displays changed"); 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)); std::thread::sleep(Duration::from_millis(300));
} }
@@ -304,6 +437,11 @@ pub(super) fn get_display_info(idx: usize) -> Option<DisplayInfo> {
// Display to DisplayInfo // Display to DisplayInfo
// The DisplayInfo is be sent to the peer. // The DisplayInfo is be sent to the peer.
pub(super) fn check_update_displays(all: &Vec<Display>) { pub(super) fn check_update_displays(all: &Vec<Display>) {
let _ = update_sync_displays(all);
}
// Return the converted input snapshot while updating the shared display cache.
pub(super) fn update_sync_displays(all: &Vec<Display>) -> Vec<DisplayInfo> {
// For compatibility: if only one display, scale remains 1.0 and we use the physical size for `uinput`. // 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(). // If there are multiple displays, we use the logical size for `uinput` by setting scale to d.scale().
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -346,7 +484,8 @@ pub(super) fn check_update_displays(all: &Vec<Display>) {
} }
}) })
.collect::<Vec<DisplayInfo>>(); .collect::<Vec<DisplayInfo>>();
SYNC_DISPLAYS.lock().unwrap().check_changed(displays); SYNC_DISPLAYS.lock().unwrap().check_changed(&displays);
displays
} }
pub fn is_inited_msg() -> Option<Message> { pub fn is_inited_msg() -> Option<Message> {
@@ -357,34 +496,38 @@ pub fn is_inited_msg() -> Option<Message> {
None None
} }
pub async fn update_get_sync_displays_on_login() -> ResultType<Vec<DisplayInfo>> { // 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<DisplayInfo>, usize)> {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
if !is_x11() { 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))] #[cfg(not(windows))]
let displays = display_service::try_get_displays(); let displays = display_service::try_get_displays();
#[cfg(windows)] #[cfg(windows)]
let displays = display_service::try_get_displays_add_amyuni_headless(); let displays = display_service::try_get_displays_add_amyuni_headless();
check_update_displays(&displays?); let displays = displays?;
Ok(SYNC_DISPLAYS.lock().unwrap().displays.clone()) 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] #[inline]
pub fn get_primary() -> usize { fn normalize_primary_display_idx(primary_display_idx: usize, display_len: usize) -> usize {
#[cfg(target_os = "linux")] // Zero is the protocol fallback when the list is empty or its primary index is stale.
{ if primary_display_idx < display_len {
if !is_x11() { primary_display_idx
return match super::wayland::get_primary() { } else {
Ok(n) => n, 0
Err(_) => 0,
};
}
} }
try_get_displays().map(|d| get_primary_2(&d)).unwrap_or(0)
} }
#[inline] #[inline]
@@ -486,3 +629,16 @@ pub fn try_get_displays_(add_amyuni_headless: bool) -> ResultType<Vec<Display>>
} }
Ok(displays) 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);
}
}

View File

@@ -661,20 +661,22 @@ pub async fn setup_rdp_input() -> ResultType<(), Box<dyn std::error::Error>> {
pub async fn update_mouse_resolution(minx: i32, maxx: i32, miny: i32, maxy: i32) -> ResultType<()> { pub async fn update_mouse_resolution(minx: i32, maxx: i32, miny: i32, maxy: i32) -> ResultType<()> {
set_uinput_resolution(minx, maxx, miny, maxy).await?; 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) = ENIGO.lock().unwrap().get_custom_mouse() {
if let Some(mouse) = mouse if let Some(mouse) = mouse
.as_mut_any() .as_mut_any()
.downcast_mut::<super::uinput::client::UInputMouse>() .downcast_mut::<super::uinput::client::UInputMouse>()
{ {
allow_err!(mouse.send_refresh()); return mouse.send_refresh();
} else {
log::error!("failed downcast uinput mouse");
} }
bail!("failed to downcast custom mouse to UInputMouse");
} }
}); // No custom mouse: nothing to refresh.
Ok(())
Ok(()) })
.await?
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -1098,12 +1100,23 @@ pub fn handle_mouse_simulation_(evt: &MouseEvent, conn: i32) {
MOUSE_TYPE_MOVE => { MOUSE_TYPE_MOVE => {
// Switching back to absolute movement implicitly disables relative mouse mode. // Switching back to absolute movement implicitly disables relative mouse mode.
set_relative_mouse_active(conn, false); 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 { *LATEST_PEER_INPUT_CURSOR.lock().unwrap() = Input {
conn, conn,
time: get_time(), time: get_time(),
x: evt.x, x: mx,
y: evt.y, y: my,
}; };
} }
// MOUSE_TYPE_MOVE_RELATIVE: Relative mouse movement for gaming/3D applications. // MOUSE_TYPE_MOVE_RELATIVE: Relative mouse movement for gaming/3D applications.

View File

@@ -130,7 +130,16 @@ pub mod client {
} }
pub fn send_refresh(&mut self) -> ResultType<()> { 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 { match data {
Data::Mouse(data) => { Data::Mouse(data) => {
if let DataMouse::Refresh = data { if let DataMouse::Refresh = data {
let resolution = RESOLUTION.lock().unwrap(); let (rng_x, rng_y) = {
let rng_x = resolution.0.clone(); let resolution = RESOLUTION.lock().unwrap();
let rng_y = resolution.1.clone(); (resolution.0.clone(), resolution.1.clone())
};
log::info!( log::info!(
"Refresh uinput mouce with rng_x: ({}, {}), rng_y: ({}, {})", "Refresh uinput mouce with rng_x: ({}, {}), rng_y: ({}, {})",
rng_x.0, rng_x.0,
@@ -861,11 +871,19 @@ pub mod service {
rng_y.0, rng_y.0,
rng_y.1 rng_y.1
); );
mouse = match mouce::UInputMouseManager::new(rng_x, rng_y) { match mouce::UInputMouseManager::new(rng_x, rng_y) {
Ok(mouse) => mouse, Ok(m) => {
mouse = m;
// Ack: device adopted the new range.
allow_err!(stream.send(&Data::Empty).await);
}
Err(e) => { Err(e) => {
log::error!("Failed to create mouse, {}", e); // Keep the current device; withhold the ack
return; // so the client times out and retries.
log::error!(
"Failed to recreate uinput mouse, keeping current: {}",
e
);
} }
} }
} else { } else {

View File

@@ -137,6 +137,9 @@ pub(super) async fn check_init() -> ResultType<()> {
if !is_x11() { if !is_x11() {
if CAP_DISPLAY_INFO.read().unwrap().is_empty() { if CAP_DISPLAY_INFO.read().unwrap().is_empty() {
if crate::input_service::wayland_use_uinput() { 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)) = if let Some((minx, maxx, miny, maxy)) =
scrap::wayland::display::get_desktop_rect_for_uinput() scrap::wayland::display::get_desktop_rect_for_uinput()
{ {
@@ -147,9 +150,28 @@ pub(super) async fn check_init() -> ResultType<()> {
miny, miny,
maxy maxy
); );
allow_err!( // Bound the IPC wait like the periodic refresh does, so a hung
input_service::update_mouse_resolution(minx, maxx, miny, maxy).await // 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 { } else {
log::warn!("Failed to get desktop rect for uinput"); 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; *PIPEWIRE_INITIALIZED.write().unwrap() = true;
let num = all.len(); let num = all.len();
let primary = super::display_service::get_primary_2(&all); let primary = super::display_service::get_primary_2(&all);
super::display_service::check_update_displays(&all); let mut displays = super::display_service::update_sync_displays(&all);
let mut displays = super::display_service::get_sync_displays();
for display in displays.iter_mut() { for display in displays.iter_mut() {
display.cursor_embedded = is_cursor_embedded(); display.cursor_embedded = is_cursor_embedded();
} }
@@ -220,27 +241,15 @@ pub(super) async fn check_init() -> ResultType<()> {
Ok(()) Ok(())
} }
pub(super) async fn get_displays() -> ResultType<Vec<DisplayInfo>> { pub(super) async fn get_displays_and_primary() -> ResultType<(Vec<DisplayInfo>, usize)> {
check_init().await?; check_init().await?;
// Keep one read guard so clear/reinitialization cannot split these across cache snapshots.
let cap_map = CAP_DISPLAY_INFO.read().unwrap(); let cap_map = CAP_DISPLAY_INFO.read().unwrap();
if let Some(addr) = cap_map.values().next() { if let Some(addr) = cap_map.values().next() {
let cap_display_info: *const CapDisplayInfo = *addr as _; let cap_display_info: *const CapDisplayInfo = *addr as _;
unsafe { unsafe {
let cap_display_info = &*cap_display_info; let cap_display_info = &*cap_display_info;
Ok(cap_display_info.displays.clone()) Ok((cap_display_info.displays.clone(), cap_display_info.primary))
}
} else {
bail!("Failed to get capturer display info");
}
}
pub(super) fn get_primary() -> ResultType<usize> {
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)
} }
} else { } else {
bail!("Failed to get capturer display info"); bail!("Failed to get capturer display info");

View File

@@ -5,6 +5,14 @@ fn main() {}
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
fn main() { fn main() {
let args: Vec<String> = 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(); crate::common::load_custom_client();
hbb_common::init_log(false, "service"); hbb_common::init_log(false, "service");
crate::start_os_service(); crate::start_os_service();

View File

@@ -151,7 +151,7 @@ class Header: Reactor.Component {
<span #action>{svg_action}</span> <span #action>{svg_action}</span>
<span #display>{svg_display}</span> <span #display>{svg_display}</span>
<span #keyboard>{svg_keyboard}</span> <span #keyboard>{svg_keyboard}</span>
{recording_enabled ? <span #recording>{recording ? svg_recording_on : svg_recording_off}</span> : ""} {recording_enabled && show_recording_button ? <span #recording>{recording ? svg_recording_on : svg_recording_off}</span> : ""}
{this.renderKeyboardPop()} {this.renderKeyboardPop()}
{this.renderDisplayPop()} {this.renderDisplayPop()}
{this.renderActionPop()} {this.renderActionPop()}

View File

@@ -504,6 +504,7 @@ impl sciter::EventHandler for SciterSession {
fn get_id(); fn get_id();
fn get_default_pi(); fn get_default_pi();
fn get_option(String); fn get_option(String);
fn get_local_option(String);
fn t(String); fn t(String);
fn set_option(String, String); fn set_option(String, String);
fn input_os_password(String, bool); fn input_os_password(String, bool);
@@ -638,6 +639,10 @@ impl SciterSession {
crate::client::translate(name) 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 { pub fn get_icon(&self) -> String {
super::get_icon() super::get_icon()
} }

View File

@@ -17,6 +17,7 @@ var audio_enabled = true; // server side
var file_enabled = true; // server side var file_enabled = true; // server side
var restart_enabled = true; // server side var restart_enabled = true; // server side
var recording_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 privacy_mode_enabled = true; // server side
var scroll_body = $(body); var scroll_body = $(body);
var peer_platform = ""; var peer_platform = "";

View File

@@ -911,6 +911,29 @@ pub fn get_langs() -> String {
json!(x).to_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] #[inline]
pub fn video_save_directory(root: bool) -> String { pub fn video_save_directory(root: bool) -> String {
let appname = crate::get_app_name(); 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 // Currently, only installed windows run as root
#[cfg(windows)] #[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 drive = std::env::var("SystemDrive").unwrap_or("C:".to_owned());
let dir = let dir =
std::path::PathBuf::from(format!("{drive}\\ProgramData\\{appname}\\recording",)); 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); let dir = LocalConfig::get_option_from_file(OPTION_VIDEO_SAVE_DIRECTORY);
#[cfg(not(any(target_os = "linux", target_os = "macos")))] #[cfg(not(any(target_os = "linux", target_os = "macos")))]
let dir = LocalConfig::get_option(OPTION_VIDEO_SAVE_DIRECTORY); let dir = LocalConfig::get_option(OPTION_VIDEO_SAVE_DIRECTORY);
if !dir.is_empty() { if let Some(dir) = trim_video_save_directory(&dir) {
return dir; return dir.to_owned();
} }
#[cfg(any(target_os = "android", target_os = "ios"))] #[cfg(any(target_os = "android", target_os = "ios"))]
if let Ok(home) = config::APP_HOME_DIR.read() { if let Ok(home) = config::APP_HOME_DIR.read() {
@@ -1705,3 +1737,41 @@ pub fn is_remote_modify_enabled_by_control_permissions() -> Option<bool> {
.lock() .lock()
.unwrap() .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);
}
}

View File

@@ -11,6 +11,51 @@ use std::{
time::{Duration, Instant}, 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<MacUpdateLock> {
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 { enum UpdateMsg {
CheckUpdate, CheckUpdate,
Exit, Exit,
@@ -22,7 +67,17 @@ lazy_static::lazy_static! {
static CONTROLLING_SESSION_COUNT: AtomicUsize = AtomicUsize::new(0); 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) { pub fn update_controlling_session_count(count: usize) {
CONTROLLING_SESSION_COUNT.store(count, Ordering::SeqCst); CONTROLLING_SESSION_COUNT.store(count, Ordering::SeqCst);
@@ -47,7 +102,9 @@ pub fn stop_auto_update() {
} }
#[inline] #[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(); let conns = crate::Connection::alive_conns();
conns.is_empty() && has_no_controlling_conns() conns.is_empty() && has_no_controlling_conns()
} }
@@ -82,13 +139,11 @@ fn start_auto_update_check() -> Sender<UpdateMsg> {
} }
fn start_auto_update_check_(rx_msg: Receiver<UpdateMsg>) { fn start_auto_update_check_(rx_msg: Receiver<UpdateMsg>) {
std::thread::sleep(Duration::from_secs(30)); std::thread::sleep(INITIAL_CHECK_DELAY);
if let Err(e) = check_update(false) { if let Err(e) = check_update(false) {
log::error!("Error checking for updates: {}", e); 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 last_check_time = Instant::now();
let mut check_interval = DUR_ONE_DAY; let mut check_interval = DUR_ONE_DAY;
loop { loop {
@@ -118,6 +173,12 @@ fn start_auto_update_check_(rx_msg: Receiver<UpdateMsg>) {
} }
fn check_update(manually: bool) -> ResultType<()> { 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")] #[cfg(target_os = "windows")]
let update_msi = crate::platform::is_msi_installed()? && !crate::is_custom_client(); 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)) { 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<PathBuf> {
get_update_download_file_from_url(url) 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<bool> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::get_download_file_from_url; use super::get_download_file_from_url;