mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-16 01:11:03 +03:00
Merge branch 'master' into id-whitelist
This commit is contained in:
34
Cargo.lock
generated
34
Cargo.lock
generated
@@ -771,6 +771,26 @@ dependencies = [
|
||||
"syn 2.0.98",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.72.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools 0.12.1",
|
||||
"log",
|
||||
"prettyplease",
|
||||
"proc-macro2 1.0.93",
|
||||
"quote 1.0.36",
|
||||
"regex",
|
||||
"rustc-hash 2.1.1",
|
||||
"shlex",
|
||||
"syn 2.0.98",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit_field"
|
||||
version = "0.10.2"
|
||||
@@ -2329,7 +2349,7 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
|
||||
dependencies = [
|
||||
"libloading 0.7.4",
|
||||
"libloading 0.8.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2694,7 +2714,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4494,7 +4514,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.0",
|
||||
"windows-targets 0.48.5",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6920,7 +6940,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "rdev"
|
||||
version = "0.5.0-2"
|
||||
source = "git+https://github.com/rustdesk-org/rdev#871bf1c856d6a30af2f56ab8848396a025140855"
|
||||
source = "git+https://github.com/rustdesk-org/rdev#23e24dd6b35452a495dae0ae6d99395e9755ab0f"
|
||||
dependencies = [
|
||||
"cocoa 0.24.1",
|
||||
"core-foundation 0.9.4",
|
||||
@@ -7434,7 +7454,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.11.0",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7491,7 +7511,7 @@ dependencies = [
|
||||
"security-framework 3.5.1",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7578,7 +7598,7 @@ name = "scrap"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"bindgen 0.65.1",
|
||||
"bindgen 0.72.1",
|
||||
"block",
|
||||
"cfg-if 1.0.0",
|
||||
"dbus",
|
||||
|
||||
@@ -583,6 +583,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
}
|
||||
// record
|
||||
if (!(isDesktop || isWeb) &&
|
||||
bind.mainGetLocalOption(key: kOptionHideRecordingButton) != 'Y' &&
|
||||
(ffi.recordingModel.start || (perms["recording"] != false))) {
|
||||
v.add(TTextMenu(
|
||||
child: Row(
|
||||
|
||||
@@ -105,6 +105,7 @@ const String kOptionAutoDisconnectTimeout = "auto-disconnect-timeout";
|
||||
const String kOptionEnableHwcodec = "enable-hwcodec";
|
||||
const String kOptionAllowAutoRecordIncoming = "allow-auto-record-incoming";
|
||||
const String kOptionAllowAutoRecordOutgoing = "allow-auto-record-outgoing";
|
||||
const String kOptionHideRecordingButton = "hide-recording-button";
|
||||
const String kOptionVideoSaveDirectory = "video-save-directory";
|
||||
const String kOptionAccessMode = "access-mode";
|
||||
const String kOptionEnableKeyboard = "enable-keyboard";
|
||||
@@ -178,6 +179,7 @@ const String kOptionAllowAskForNoteAtEndOfConnection = "allow-ask-for-note";
|
||||
const String kOptionAllowMonitorSwitchMainToolbar = "allow-monitor-switch-main-toolbar";
|
||||
const String kOptionAllowMonitorSwitchMinToolbar = "allow-monitor-switch-min-toolbar";
|
||||
const String kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys";
|
||||
const String kOptionShowTerminalCtrlKeys = "show-terminal-extra-ctrl-keys";
|
||||
|
||||
// network options
|
||||
const String kOptionAllowWebSocket = "allow-websocket";
|
||||
|
||||
@@ -485,7 +485,8 @@ class _GeneralState extends State<_General> {
|
||||
Widget other() {
|
||||
final incomingOnly = bind.isIncomingOnly();
|
||||
final outgoingOnly = bind.isOutgoingOnly();
|
||||
final showAutoUpdate = isWindows && bind.mainIsInstalled();
|
||||
final showAutoUpdate = (isWindows && bind.mainIsInstalled()) ||
|
||||
(isMacOS && bind.mainIsInstalled() && bind.mainIsInstalledDaemon(prompt: false) && !bind.isCustomClient());
|
||||
final children = <Widget>[
|
||||
if (!isWeb && !incomingOnly)
|
||||
_OptionCheckBox(context, 'Confirm before closing multiple tabs',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import '../../utils/image.dart';
|
||||
import '../widgets/remote_toolbar.dart';
|
||||
import '../widgets/kb_layout_type_chooser.dart';
|
||||
import '../widgets/tabbar_widget.dart';
|
||||
import 'macos_full_screen_focus_recovery.dart';
|
||||
|
||||
import 'package:flutter_hbb/native/custom_cursor.dart'
|
||||
if (dart.library.html) 'package:flutter_hbb/web/custom_cursor.dart';
|
||||
@@ -64,6 +65,13 @@ class RemotePage extends StatefulWidget {
|
||||
|
||||
FFI get ffi => (_lastState.value! as _RemotePageState)._ffi;
|
||||
|
||||
void releaseMacOSInputForTabTransfer() {
|
||||
if (!isMacOS) return;
|
||||
// Release before removing the source tab. Its delayed disposal must not
|
||||
// disable a native keyboard hook already acquired by the destination page.
|
||||
(_lastState.value! as _RemotePageState)._releaseMacOSRemoteInput();
|
||||
}
|
||||
|
||||
@override
|
||||
State<RemotePage> createState() {
|
||||
final state = _RemotePageState(id);
|
||||
@@ -76,10 +84,28 @@ class _RemotePageState extends State<RemotePage>
|
||||
with
|
||||
AutomaticKeepAliveClientMixin,
|
||||
MultiWindowListener,
|
||||
WidgetsBindingObserver,
|
||||
TickerProviderStateMixin {
|
||||
Timer? _timer;
|
||||
String keyboardMode = "legacy";
|
||||
bool _isWindowBlur = false;
|
||||
// Known macOS remote-input trade-offs (kept simple intentionally):
|
||||
// 1. Dialogs rely on FocusNode loss plus middleBlocked, not mirrored dialog
|
||||
// state. Reproduce: activate remote input, open a dialog, then type.
|
||||
// 2. Delayed fullscreen recovery can race a local-control focus change; no
|
||||
// owner state is added. Reproduce: focus the toolbar during a Space switch.
|
||||
// 3. Input-source switching releases native input without updating this
|
||||
// page's cache. Reproduce: switch sources, then type before and after
|
||||
// clicking the remote image; the click reasserts input.
|
||||
// These latches compensate for out-of-order macOS focus events. Treat them
|
||||
// as coupled when changing a transition or _syncMacOSKeyboardGrab().
|
||||
AppLifecycleState? _macOSLifecycleState;
|
||||
bool _macOSLocalFocusLost = false;
|
||||
bool _macOSInputActive = false;
|
||||
bool _macOSInputSuppressed = false;
|
||||
final _macOSFullScreenFocusRecovery = MacOSFullScreenFocusRecovery();
|
||||
bool _macOSExplicitFocusRequestPending = false;
|
||||
StreamSubscription<DesktopTabState>? _tabStateSubscription;
|
||||
final _cursorOverImage = false.obs;
|
||||
late RxBool _showRemoteCursor;
|
||||
late RxBool _zoomCursor;
|
||||
@@ -122,6 +148,13 @@ class _RemotePageState extends State<RemotePage>
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ffi = FFI(widget.sessionId);
|
||||
if (isMacOS) {
|
||||
// SchedulerBinding.instance.lifecycleState is null in the first connection in a new window.
|
||||
_macOSLifecycleState = SchedulerBinding.instance.lifecycleState;
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_tabStateSubscription =
|
||||
widget.tabController?.state.listen(_onMacOSTabStateChanged);
|
||||
}
|
||||
Get.put<FFI>(_ffi, tag: widget.id);
|
||||
_ffi.imageModel.addCallbackOnFirstImage((String peerId) {
|
||||
_ffi.canvasModel.activateLocalCursor();
|
||||
@@ -231,19 +264,224 @@ class _RemotePageState extends State<RemotePage>
|
||||
_pointerLockCenterDebounceTimer = null;
|
||||
}
|
||||
|
||||
bool get _isSelectedTab {
|
||||
final controller = widget.tabController;
|
||||
if (controller == null) return true;
|
||||
final tabState = controller.state.value;
|
||||
final selected = tabState.selected;
|
||||
return selected >= 0 &&
|
||||
selected < tabState.tabs.length &&
|
||||
tabState.tabs[selected].key == widget.id;
|
||||
}
|
||||
|
||||
bool get _isMacOSKeyboardContextActive {
|
||||
return stateGlobal.isFocused.value && !_isWindowBlur && _isSelectedTab;
|
||||
}
|
||||
|
||||
void _onMacOSTabStateChanged(DesktopTabState _) {
|
||||
if (!_isSelectedTab) {
|
||||
_macOSFullScreenFocusRecovery.cancel();
|
||||
_syncMacOSKeyboardGrab();
|
||||
return;
|
||||
}
|
||||
// Tab listeners run synchronously. Defer the selected page so the previous
|
||||
// page releases first; a late leave from it can disable the new session.
|
||||
scheduleMicrotask(() {
|
||||
if (mounted) {
|
||||
_syncMacOSKeyboardGrab(reassert: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _releaseMacOSRemoteInput() {
|
||||
_macOSFullScreenFocusRecovery.cancel();
|
||||
_macOSExplicitFocusRequestPending = false;
|
||||
_macOSInputSuppressed = true;
|
||||
_macOSLocalFocusLost = true;
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
_macOSInputActive = false;
|
||||
_rawKeyFocusNode.unfocus();
|
||||
}
|
||||
|
||||
void _onMacOSFocusChange() {
|
||||
// requestFocus() notifies later; only a recorded explicit request may clear
|
||||
// the local-focus-loss latch.
|
||||
if (_rawKeyFocusNode.hasPrimaryFocus) {
|
||||
final explicitRequest = _macOSExplicitFocusRequestPending;
|
||||
_macOSExplicitFocusRequestPending = false;
|
||||
if (explicitRequest && _isMacOSKeyboardContextActive) {
|
||||
_macOSLocalFocusLost = false;
|
||||
}
|
||||
_syncMacOSKeyboardGrab(allowInactiveLifecycle: explicitRequest);
|
||||
} else {
|
||||
if (_macOSInputActive) {
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
_macOSInputActive = false;
|
||||
}
|
||||
if (_isMacOSKeyboardContextActive) {
|
||||
_macOSLocalFocusLost = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Sync the keyboard grab state with the current context.
|
||||
// 2. Call enterOrLeave() to update the input state in the FFI layer.
|
||||
// 3. Request or unfocus the raw key focus node based on the current context.
|
||||
// Flutter focus and native input are separate; native input activates only
|
||||
// after the FocusNode has primary focus.
|
||||
void _syncMacOSKeyboardGrab({
|
||||
bool reassert = false,
|
||||
bool allowInactiveLifecycle = false,
|
||||
}) {
|
||||
if (!isMacOS) return;
|
||||
// A secondary engine may stay hidden while its window is visible, so
|
||||
// explicit pointer/fullscreen recovery must bypass the global lifecycle.
|
||||
final lifecycleAllowsInput = allowInactiveLifecycle ||
|
||||
_macOSLifecycleState == null ||
|
||||
_macOSLifecycleState == AppLifecycleState.resumed;
|
||||
// Input stays pointer-gated except for focused fullscreen recovery, which
|
||||
// compensates when macOS omits PointerEnter during a Space switch.
|
||||
final shouldFocus = lifecycleAllowsInput &&
|
||||
_isMacOSKeyboardContextActive &&
|
||||
!_macOSInputSuppressed &&
|
||||
_blockableOverlayState.middleBlocked.isFalse &&
|
||||
_cursorOverImage.value &&
|
||||
!_macOSLocalFocusLost;
|
||||
final hasFocus = _rawKeyFocusNode.hasPrimaryFocus;
|
||||
final shouldActivateInput = shouldFocus && hasFocus;
|
||||
|
||||
if (shouldActivateInput != _macOSInputActive ||
|
||||
(shouldActivateInput && reassert)) {
|
||||
_ffi.inputModel.enterOrLeave(shouldActivateInput);
|
||||
}
|
||||
_macOSInputActive = shouldActivateInput;
|
||||
|
||||
if (!shouldFocus) {
|
||||
_macOSExplicitFocusRequestPending = false;
|
||||
if (hasFocus) _rawKeyFocusNode.unfocus();
|
||||
} else if (!hasFocus) {
|
||||
_macOSExplicitFocusRequestPending = allowInactiveLifecycle;
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
} else {
|
||||
_macOSExplicitFocusRequestPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _restoreMacOSKeyboardAfterFullScreen({
|
||||
required int generation,
|
||||
bool allowHiddenLifecycle = false,
|
||||
}) {
|
||||
// Fullscreen callbacks preserve recovery while hidden. Native window focus
|
||||
// may bypass a stale hidden lifecycle for the newly visible Space.
|
||||
if (!_macOSFullScreenFocusRecovery.isCurrent(generation) ||
|
||||
(!allowHiddenLifecycle &&
|
||||
_macOSLifecycleState == AppLifecycleState.hidden)) {
|
||||
return;
|
||||
}
|
||||
final contextActive =
|
||||
stateGlobal.isFocused.value && !_isWindowBlur && _isSelectedTab;
|
||||
// macOS can focus a fullscreen Space without sending PointerEnter. Native
|
||||
// window focus is authoritative here; a later blur cancels this generation
|
||||
// before an off-screen window can restore input.
|
||||
final shouldInferPointerInside = !_cursorOverImage.value &&
|
||||
allowHiddenLifecycle &&
|
||||
stateGlobal.fullscreen.isTrue &&
|
||||
contextActive;
|
||||
final canRestore = contextActive &&
|
||||
_blockableOverlayState.middleBlocked.isFalse &&
|
||||
(_cursorOverImage.value || shouldInferPointerInside);
|
||||
if (!_macOSFullScreenFocusRecovery.consume(generation)) return;
|
||||
if (!canRestore) {
|
||||
// Consuming recovery here requires a later pointer/window/tab event.
|
||||
return;
|
||||
}
|
||||
if (shouldInferPointerInside) {
|
||||
_cursorOverImage.value = true;
|
||||
}
|
||||
_macOSLocalFocusLost = false;
|
||||
stateGlobal.getInputSource(force: true);
|
||||
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
|
||||
}
|
||||
|
||||
void _scheduleMacOSKeyboardAfterFullScreen({
|
||||
required int generation,
|
||||
bool allowHiddenLifecycle = false,
|
||||
}) {
|
||||
// Fullscreen can deliver FocusNode loss after its callback; wait for frame
|
||||
// completion and then advance one event-loop turn before restoring.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Timer.run(() {
|
||||
if (mounted) {
|
||||
_restoreMacOSKeyboardAfterFullScreen(
|
||||
generation: generation,
|
||||
allowHiddenLifecycle: allowHiddenLifecycle,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
WidgetsBinding.instance.ensureVisualUpdate();
|
||||
}
|
||||
|
||||
void _queueMacOSKeyboardAfterFullScreen({
|
||||
bool allowHiddenLifecycle = false,
|
||||
}) {
|
||||
final generation = _macOSFullScreenFocusRecovery.queue();
|
||||
if (_macOSLifecycleState == AppLifecycleState.paused ||
|
||||
_macOSLifecycleState == AppLifecycleState.detached) {
|
||||
_macOSFullScreenFocusRecovery.cancel();
|
||||
return;
|
||||
}
|
||||
_scheduleMacOSKeyboardAfterFullScreen(
|
||||
generation: generation,
|
||||
allowHiddenLifecycle: allowHiddenLifecycle,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
super.didChangeAppLifecycleState(state);
|
||||
if (!isMacOS || _macOSLifecycleState == state) return;
|
||||
_macOSLifecycleState = state;
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_syncMacOSKeyboardGrab(reassert: true);
|
||||
} else if (_macOSInputActive) {
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
_macOSInputActive = false;
|
||||
}
|
||||
|
||||
final generation = _macOSFullScreenFocusRecovery.pendingGeneration;
|
||||
if (generation == null) return;
|
||||
if (state == AppLifecycleState.inactive ||
|
||||
state == AppLifecycleState.resumed) {
|
||||
_scheduleMacOSKeyboardAfterFullScreen(generation: generation);
|
||||
} else if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.detached) {
|
||||
_macOSFullScreenFocusRecovery.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowBlur() {
|
||||
super.onWindowBlur();
|
||||
// On windows, we use `focus` way to handle keyboard better.
|
||||
// Now on Linux, there's some rdev issues which will break the input.
|
||||
// We disable the `focus` way for non-Windows temporarily.
|
||||
if (isWindows) {
|
||||
// We disable the `focus` way for Linux temporarily.
|
||||
if (isWindows || isMacOS) {
|
||||
_isWindowBlur = true;
|
||||
}
|
||||
if (isMacOS) {
|
||||
_macOSFullScreenFocusRecovery.cancel();
|
||||
// A blur or Space switch may not emit PointerExit, so cursor state alone
|
||||
// cannot prevent the old remote surface from reclaiming the keyboard.
|
||||
_macOSLocalFocusLost = true;
|
||||
}
|
||||
if (isWindows) {
|
||||
// unfocus the primary-focus when the whole window is lost focus,
|
||||
// and let OS to handle events instead.
|
||||
_rawKeyFocusNode.unfocus();
|
||||
}
|
||||
stateGlobal.isFocused.value = false;
|
||||
_syncMacOSKeyboardGrab();
|
||||
|
||||
// When window loses focus, temporarily release relative mouse mode constraints
|
||||
// to allow user to interact with other applications normally.
|
||||
@@ -257,16 +495,41 @@ class _RemotePageState extends State<RemotePage>
|
||||
void onWindowFocus() {
|
||||
super.onWindowFocus();
|
||||
// See [onWindowBlur].
|
||||
if (isWindows) {
|
||||
if (isWindows || isMacOS) {
|
||||
_isWindowBlur = false;
|
||||
}
|
||||
if (isMacOS) stateGlobal.getInputSource(force: true);
|
||||
stateGlobal.isFocused.value = true;
|
||||
|
||||
// Normal macOS windows wait for PointerEnter or PointerDown. A focused
|
||||
// fullscreen Space queues delayed recovery; if this window blurs again, the
|
||||
// pending recovery is cancelled before native input can reactivate.
|
||||
// Regression: switch directly between fullscreen remote Spaces without
|
||||
// moving or clicking; only the newly focused session may receive input.
|
||||
if (isMacOS &&
|
||||
stateGlobal.fullscreen.isTrue &&
|
||||
!_ffi.inputModel.relativeMouseMode.value) {
|
||||
// Native window focus is authoritative when a secondary engine retains a
|
||||
// stale hidden lifecycle state after its fullscreen Space becomes visible.
|
||||
_queueMacOSKeyboardAfterFullScreen(allowHiddenLifecycle: true);
|
||||
}
|
||||
|
||||
// Restore relative mouse mode constraints when window regains focus.
|
||||
if (_ffi.inputModel.relativeMouseMode.value) {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
if (isMacOS) {
|
||||
// Native relative mode retains pointer capture and does not emit
|
||||
// PointerEnter after window focus returns. Restore both latches unless
|
||||
// a local overlay still owns input.
|
||||
if (_blockableOverlayState.middleBlocked.isFalse) {
|
||||
_cursorOverImage.value = true;
|
||||
_macOSLocalFocusLost = false;
|
||||
}
|
||||
} else {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
}
|
||||
_ffi.inputModel.onWindowFocus();
|
||||
}
|
||||
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -327,6 +590,13 @@ class _RemotePageState extends State<RemotePage>
|
||||
void onWindowMinimize() {
|
||||
super.onWindowMinimize();
|
||||
WakelockManager.disable(_uniqueKey);
|
||||
if (isMacOS) {
|
||||
_macOSFullScreenFocusRecovery.cancel();
|
||||
_isWindowBlur = true;
|
||||
_cursorOverImage.value = false;
|
||||
stateGlobal.isFocused.value = false;
|
||||
_syncMacOSKeyboardGrab();
|
||||
}
|
||||
// Release cursor constraints when minimized
|
||||
if (_ffi.inputModel.relativeMouseMode.value) {
|
||||
_ffi.inputModel.onWindowBlur();
|
||||
@@ -338,6 +608,7 @@ class _RemotePageState extends State<RemotePage>
|
||||
super.onWindowEnterFullScreen();
|
||||
if (isMacOS) {
|
||||
stateGlobal.setFullscreen(true);
|
||||
_queueMacOSKeyboardAfterFullScreen();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,6 +617,7 @@ class _RemotePageState extends State<RemotePage>
|
||||
super.onWindowLeaveFullScreen();
|
||||
if (isMacOS) {
|
||||
stateGlobal.setFullscreen(false);
|
||||
_queueMacOSKeyboardAfterFullScreen();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,6 +626,14 @@ class _RemotePageState extends State<RemotePage>
|
||||
final closeSession = closeSessionOnDispose.remove(widget.id) ?? true;
|
||||
|
||||
// https://github.com/flutter/flutter/issues/64935
|
||||
if (isMacOS) {
|
||||
// Tab moves release before transfer to avoid a late retained-session leave.
|
||||
if (closeSession) {
|
||||
_releaseMacOSRemoteInput();
|
||||
}
|
||||
_tabStateSubscription?.cancel();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
}
|
||||
super.dispose();
|
||||
debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}");
|
||||
|
||||
@@ -368,8 +648,9 @@ class _RemotePageState extends State<RemotePage>
|
||||
_ffi.inputModel.onRelativeMouseModeDisabled = null;
|
||||
// Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...).
|
||||
_ffi.textureModel.onRemotePageDispose(closeSession);
|
||||
if (closeSession) {
|
||||
if (closeSession && !isMacOS) {
|
||||
// ensure we leave this session, this is a double check
|
||||
// enterOrLeave() is already called previously in _releaseMacOSRemoteInput() for macOS.
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
}
|
||||
DesktopMultiWindow.removeListener(this);
|
||||
@@ -444,6 +725,8 @@ class _RemotePageState extends State<RemotePage>
|
||||
} else {
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
}
|
||||
} else if (isMacOS) {
|
||||
_onMacOSFocusChange();
|
||||
}
|
||||
},
|
||||
inputModel: _ffi.inputModel,
|
||||
@@ -549,7 +832,11 @@ class _RemotePageState extends State<RemotePage>
|
||||
}
|
||||
|
||||
// See [onWindowBlur].
|
||||
if (!isWindows) {
|
||||
if (isMacOS) {
|
||||
_macOSLocalFocusLost = false;
|
||||
stateGlobal.getInputSource(force: true);
|
||||
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
|
||||
} else if (!isWindows) {
|
||||
if (!_rawKeyFocusNode.hasFocus) {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
}
|
||||
@@ -575,7 +862,9 @@ class _RemotePageState extends State<RemotePage>
|
||||
}
|
||||
|
||||
// See [onWindowBlur].
|
||||
if (!isWindows) {
|
||||
if (isMacOS) {
|
||||
_syncMacOSKeyboardGrab();
|
||||
} else if (!isWindows) {
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
}
|
||||
}
|
||||
@@ -600,17 +889,29 @@ class _RemotePageState extends State<RemotePage>
|
||||
onEnter: onEnter,
|
||||
onExit: onExit,
|
||||
onPointerDown: (event) {
|
||||
// A double check for blur status.
|
||||
// A double check for blur status on Windows and macOS.
|
||||
// Note: If there's an `onPointerDown` event is triggered, `_isWindowBlur` is expected being false.
|
||||
// Sometimes the system does not send the necessary focus event to flutter. We should manually
|
||||
// handle this inconsistent status by setting `_isWindowBlur` to false. So we can
|
||||
// ensure the grab-key thread is running when our users are clicking the remote canvas.
|
||||
if (_isWindowBlur) {
|
||||
if ((isWindows || isMacOS) && _isWindowBlur) {
|
||||
debugPrint(
|
||||
"Unexpected status: onPointerDown is triggered while the remote window is in blur status");
|
||||
_isWindowBlur = false;
|
||||
}
|
||||
if (!_rawKeyFocusNode.hasFocus) {
|
||||
if (isMacOS) {
|
||||
// Regions without matching enter/exit callbacks cannot safely own
|
||||
// keyboard state.
|
||||
if (onEnter == null || onExit == null) return;
|
||||
if (!stateGlobal.isFocused.value) {
|
||||
stateGlobal.isFocused.value = true;
|
||||
}
|
||||
_cursorOverImage.value = true;
|
||||
_macOSLocalFocusLost = false;
|
||||
stateGlobal.getInputSource(force: true);
|
||||
_syncMacOSKeyboardGrab(
|
||||
reassert: !isInputSourceFlutter, allowInactiveLifecycle: true);
|
||||
} else if (!_rawKeyFocusNode.hasFocus) {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -513,15 +513,17 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
||||
final args = jsonDecode(call.arguments);
|
||||
final id = args['id'];
|
||||
final close = args['close'];
|
||||
RemotePage? remotePage;
|
||||
try {
|
||||
final remotePage = tabController.state.value.tabs
|
||||
remotePage = tabController.state.value.tabs
|
||||
.firstWhere((tab) => tab.key == id)
|
||||
.page as RemotePage;
|
||||
returnValue = remotePage.ffi.ffiModel.cachedPeerData.toString();
|
||||
} catch (e) {
|
||||
debugPrint('Failed to get cached session data: $e');
|
||||
}
|
||||
if (close && returnValue != null) {
|
||||
if (close && returnValue != null && remotePage != null) {
|
||||
remotePage.releaseMacOSInputForTabTransfer();
|
||||
closeSessionOnDispose[id] = false;
|
||||
tabController.closeBy(id);
|
||||
}
|
||||
|
||||
@@ -2484,6 +2484,8 @@ class _KeyboardMenu extends StatelessWidget {
|
||||
? (v) async {
|
||||
if (v != null) {
|
||||
await stateGlobal.setInputSource(ffi.sessionId, v);
|
||||
// Release native input; see the macOS trade-offs in RemotePage.
|
||||
if (isMacOS) ffi.inputModel.enterOrLeave(false);
|
||||
await ffi.ffiModel.checkDesktopKeyboardMode();
|
||||
await ffi.inputModel.updateKeyboardMode();
|
||||
}
|
||||
@@ -2740,7 +2742,9 @@ class _RecordMenu extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
var ffi = Provider.of<FfiModel>(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);
|
||||
if (!visible) return Offstage();
|
||||
return _IconMenuButton(
|
||||
|
||||
@@ -5,8 +5,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/common/widgets/dialog.dart';
|
||||
import 'package:flutter_hbb/models/input_modifier_utils.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/terminal_model.dart';
|
||||
import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
import '../../desktop/pages/terminal_connection_manager.dart';
|
||||
@@ -42,6 +45,11 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
final GlobalKey _keyboardKey = GlobalKey();
|
||||
double _keyboardHeight = 0;
|
||||
late bool _showTerminalExtraKeys;
|
||||
// Ctrl lock state for virtual keyboard: active key presses are mapped to control codes
|
||||
bool _ctrlLocked = false;
|
||||
bool _altLocked = false;
|
||||
// Row3 expand/collapse state for compact keyboard layout
|
||||
bool _row3Expanded = false;
|
||||
// For iOS edge swipe gesture
|
||||
double _swipeStartX = 0;
|
||||
double _swipeCurrentX = 0;
|
||||
@@ -94,6 +102,18 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
// terminal extra keys bar is unnecessary and disabled.
|
||||
_showTerminalExtraKeys = !isWebDesktop &&
|
||||
mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
|
||||
_terminalModel.isCtrlLocked = () => _ctrlLocked;
|
||||
_terminalModel.clearCtrlLock = () {
|
||||
if (_ctrlLocked) setState(() => _ctrlLocked = false);
|
||||
};
|
||||
_terminalModel.isAltLocked = () => _altLocked;
|
||||
_terminalModel.clearAltLock = () {
|
||||
if (_altLocked) setState(() => _altLocked = false);
|
||||
};
|
||||
// Load Row3 expand/collapse state from persistent storage. The raw option
|
||||
// read keeps Row3 collapsed when no value has been saved yet.
|
||||
_row3Expanded =
|
||||
bind.mainGetLocalOption(key: kOptionShowTerminalCtrlKeys) == 'Y';
|
||||
// Initialize terminal connection
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_ffi.dialogManager
|
||||
@@ -148,6 +168,39 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
@@ -185,6 +238,7 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
//
|
||||
// Android works fine without this workaround.
|
||||
deleteDetection: isIOS,
|
||||
onKeyEvent: _handleTerminalKeyEvent,
|
||||
padding: _calculatePadding(heightPx),
|
||||
onSecondaryTapDown: (details, offset) async {
|
||||
final selection = _terminalModel.terminalController.selection;
|
||||
@@ -193,11 +247,7 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
_terminalModel.terminalController.clearSelection();
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
} else {
|
||||
final data = await Clipboard.getData('text/plain');
|
||||
final text = data?.text;
|
||||
if (text != null) {
|
||||
_terminalModel.terminal.paste(text);
|
||||
}
|
||||
await _pasteClipboardText();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -324,66 +374,171 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Row 1 follows the latest reviewed PR layout.
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: _buildKeyboardKeyButtons(terminalKeyboardRow1Keys),
|
||||
),
|
||||
// Row 2 ends with the full-width Row3 collapse/expand toggle.
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildKeyButton('Esc'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('/'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('|'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('Home'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('↑'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('End'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('PgUp'),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildKeyButton('Tab'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('Ctrl+C'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('~'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('←'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('↓'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('→'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('PgDn'),
|
||||
..._buildKeyboardKeyButtons(terminalKeyboardRow2Keys),
|
||||
const SizedBox(width: terminalKeyboardKeySpacing),
|
||||
_buildCollapseButton(),
|
||||
],
|
||||
),
|
||||
// Row 3 restores paging keys and trailing alignment placeholders.
|
||||
if (_row3Expanded)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
..._buildKeyboardKeyButtons(terminalKeyboardRow3Keys),
|
||||
for (var i = 0;
|
||||
i < terminalKeyboardRow3TrailingPlaceholderCount;
|
||||
i++) ...[
|
||||
const SizedBox(width: terminalKeyboardKeySpacing),
|
||||
const SizedBox(width: terminalKeyboardKeyWidth),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Ctrl toggle button with highlighted locked state
|
||||
Widget _buildCtrlKeyButton() {
|
||||
return _buildModifierToggleButton(
|
||||
text: 'Ctrl',
|
||||
semanticsLabel: 'Ctrl',
|
||||
isLocked: _ctrlLocked,
|
||||
onPressed: () => setState(() => _ctrlLocked = !_ctrlLocked),
|
||||
);
|
||||
}
|
||||
|
||||
// Alt toggle button with highlighted locked state
|
||||
Widget _buildAltKeyButton() {
|
||||
return _buildModifierToggleButton(
|
||||
text: 'Alt',
|
||||
semanticsLabel: 'Alt',
|
||||
isLocked: _altLocked,
|
||||
onPressed: () => setState(() => _altLocked = !_altLocked),
|
||||
);
|
||||
}
|
||||
|
||||
// Collapse/expand toggle button for Row3
|
||||
void _toggleRow3Expanded() {
|
||||
final willExpand = !_row3Expanded;
|
||||
final shouldClearModifiers = shouldClearTerminalModifiersWhenRow3Collapses(
|
||||
wasExpanded: _row3Expanded,
|
||||
willExpand: willExpand,
|
||||
ctrlLocked: _ctrlLocked,
|
||||
altLocked: _altLocked,
|
||||
);
|
||||
setState(() {
|
||||
_row3Expanded = willExpand;
|
||||
if (shouldClearModifiers) {
|
||||
_ctrlLocked = false;
|
||||
_altLocked = false;
|
||||
}
|
||||
});
|
||||
mainSetLocalBoolOption(kOptionShowTerminalCtrlKeys, willExpand);
|
||||
|
||||
// The floating keyboard height changes after Row3 is inserted/removed.
|
||||
// Re-measure on the next frame so terminal padding uses the new height.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_showTerminalExtraKeys) return;
|
||||
setState(() {
|
||||
_updateKeyboardHeight();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildCollapseButton() {
|
||||
return Semantics(
|
||||
label: translate('Show terminal extra keys'),
|
||||
toggled: _row3Expanded,
|
||||
child: ElevatedButton(
|
||||
onPressed: _toggleRow3Expanded,
|
||||
child: Text(_row3Expanded ? '∧' : '∨'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(terminalKeyboardKeyWidth, 32),
|
||||
padding: EdgeInsets.zero,
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a fixed-width key sequence with the reviewed 2dp spacing.
|
||||
List<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) {
|
||||
if (label == 'Ctrl') return _buildCtrlKeyButton();
|
||||
if (label == 'Alt') return _buildAltKeyButton();
|
||||
|
||||
return ElevatedButton(
|
||||
onPressed: () {
|
||||
_sendKeyToTerminal(label);
|
||||
},
|
||||
child: Text(label),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(48, 32),
|
||||
minimumSize: const Size(terminalKeyboardKeyWidth, 32),
|
||||
padding: EdgeInsets.zero,
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _sendKeyToTerminal(String key) {
|
||||
String? send;
|
||||
String send;
|
||||
|
||||
switch (key) {
|
||||
case 'Esc':
|
||||
@@ -427,9 +582,7 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
break;
|
||||
}
|
||||
|
||||
if (send != null) {
|
||||
_terminalModel.sendVirtualKey(send);
|
||||
}
|
||||
_terminalModel.sendVirtualKey(send);
|
||||
}
|
||||
|
||||
// https://github.com/TerminalStudio/xterm.dart/issues/42#issuecomment-877495472
|
||||
|
||||
20
flutter/lib/mobile/terminal_keyboard_utils.dart
Normal file
20
flutter/lib/mobile/terminal_keyboard_utils.dart
Normal 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;
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Identifies where terminal input originated so paste data can bypass all
|
||||
/// keyboard-only transformations.
|
||||
enum TerminalInputSource {
|
||||
keyboard,
|
||||
paste,
|
||||
}
|
||||
|
||||
/// Returns true when a stale mobile one-shot Shift state should be released
|
||||
/// by replaying a tracked Shift key-down as a synthesized key-up.
|
||||
@@ -36,3 +44,147 @@ bool shouldReleaseStaleMobileShift({
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Applies the terminal Ctrl/Alt one-shot modifiers to a single input payload.
|
||||
///
|
||||
String applyTerminalInputModifiers(
|
||||
String data, {
|
||||
required bool ctrlLocked,
|
||||
required bool altLocked,
|
||||
}) {
|
||||
var result = data;
|
||||
if (ctrlLocked) {
|
||||
result = _applyTerminalCtrlModifier(result);
|
||||
}
|
||||
if (altLocked) {
|
||||
result = '\x1B$result';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Builds the exact payload xterm sends for paste, without applying modifiers.
|
||||
String terminalPastePayload(String text, {required bool bracketedPasteMode}) {
|
||||
if (!bracketedPasteMode) {
|
||||
return text;
|
||||
}
|
||||
return '\x1B[200~$text\x1B[201~';
|
||||
}
|
||||
|
||||
/// Returns whether one-shot Ctrl/Alt may transform and consume this input.
|
||||
///
|
||||
/// xterm emits terminal control keys as either one control byte or a longer
|
||||
/// escape sequence. Neither form is ordinary text input, so a pending modifier
|
||||
/// must survive until the user enters a printable character.
|
||||
bool shouldApplyTerminalInputModifiers(String data) {
|
||||
if (data.characters.length != 1) return false;
|
||||
final codeUnit = data.codeUnitAt(0);
|
||||
return codeUnit >= 0x20 && codeUnit != 0x7F;
|
||||
}
|
||||
|
||||
/// Builds the payload sent to the remote terminal for keyboard and paste input.
|
||||
///
|
||||
/// Keyboard input keeps the mobile Enter workaround and one-shot Ctrl/Alt
|
||||
/// mapping. Paste input deliberately bypasses both transformations so even a
|
||||
/// one-character clipboard payload is preserved exactly.
|
||||
String prepareTerminalInputPayload(
|
||||
String data, {
|
||||
required TerminalInputSource source,
|
||||
required bool isMobileOrWebMobile,
|
||||
required bool bracketedPasteMode,
|
||||
required bool ctrlLocked,
|
||||
required bool altLocked,
|
||||
}) {
|
||||
if (source == TerminalInputSource.paste) {
|
||||
return terminalPastePayload(
|
||||
data,
|
||||
bracketedPasteMode: bracketedPasteMode,
|
||||
);
|
||||
}
|
||||
|
||||
var result = data;
|
||||
if (isMobileOrWebMobile && result == '\n') {
|
||||
result = '\r';
|
||||
}
|
||||
if ((ctrlLocked || altLocked) && shouldApplyTerminalInputModifiers(result)) {
|
||||
result = applyTerminalInputModifiers(
|
||||
result,
|
||||
ctrlLocked: ctrlLocked,
|
||||
altLocked: altLocked,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Returns true when a hardware paste shortcut must bypass keyboard modifiers.
|
||||
///
|
||||
/// xterm already handles hardware Ctrl/Cmd+V correctly in the common case. Only
|
||||
/// intercept while a virtual Ctrl/Alt lock is active, because xterm can emit a
|
||||
/// one-character paste as normal text when bracketed paste mode is disabled.
|
||||
bool shouldHandleTerminalPasteShortcut({
|
||||
required LogicalKeyboardKey logicalKey,
|
||||
required bool isKeyDown,
|
||||
required bool isKeyRepeat,
|
||||
required bool controlPressed,
|
||||
required bool metaPressed,
|
||||
required bool altPressed,
|
||||
required bool shiftPressed,
|
||||
required bool modifierLockActive,
|
||||
}) {
|
||||
if (!modifierLockActive) return false;
|
||||
if (!isKeyDown && !isKeyRepeat) return false;
|
||||
if (logicalKey != LogicalKeyboardKey.keyV) return false;
|
||||
if (altPressed || shiftPressed) return false;
|
||||
return controlPressed != metaPressed;
|
||||
}
|
||||
|
||||
/// Returns true when collapsing Row3 should also clear hidden modifier state.
|
||||
bool shouldClearTerminalModifiersWhenRow3Collapses({
|
||||
required bool wasExpanded,
|
||||
required bool willExpand,
|
||||
required bool ctrlLocked,
|
||||
required bool altLocked,
|
||||
}) {
|
||||
return wasExpanded && !willExpand && (ctrlLocked || altLocked);
|
||||
}
|
||||
|
||||
String _applyTerminalCtrlModifier(String data) {
|
||||
// Ctrl mappings are defined only for ASCII scalars. A visible character can
|
||||
// be multiple scalars (for example, a decomposed accent), so leave those
|
||||
// graphemes untouched instead of rewriting only their ASCII base letter.
|
||||
final graphemes = data.characters.toList(growable: false);
|
||||
if (graphemes.length != 1) {
|
||||
return data;
|
||||
}
|
||||
|
||||
final runes = graphemes.single.runes.toList(growable: false);
|
||||
if (runes.length != 1) {
|
||||
return data;
|
||||
}
|
||||
|
||||
final code = runes.single;
|
||||
if (code >= 0x61 && code <= 0x7A) {
|
||||
return String.fromCharCode(code - 0x60);
|
||||
}
|
||||
if (code >= 0x41 && code <= 0x5A) {
|
||||
return String.fromCharCode(code - 0x40);
|
||||
}
|
||||
if (code == 0x20) {
|
||||
return String.fromCharCode(0);
|
||||
}
|
||||
if (code == 0x5B) {
|
||||
return String.fromCharCode(27);
|
||||
}
|
||||
if (code == 0x5C) {
|
||||
return String.fromCharCode(28);
|
||||
}
|
||||
if (code == 0x5D) {
|
||||
return String.fromCharCode(29);
|
||||
}
|
||||
if (code == 0x5E) {
|
||||
return String.fromCharCode(30);
|
||||
}
|
||||
if (code == 0x5F || code == 0x2F) {
|
||||
return String.fromCharCode(31);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/main.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
import 'input_modifier_utils.dart';
|
||||
import 'model.dart';
|
||||
import 'platform_model.dart';
|
||||
|
||||
@@ -22,7 +23,25 @@ class TerminalModel with ChangeNotifier {
|
||||
|
||||
bool _disposed = false;
|
||||
|
||||
/// Callback to check whether Ctrl modifier lock is currently active.
|
||||
/// When active, keyboard input is mapped to control codes (e.g. 'b' → \x02).
|
||||
bool Function()? isCtrlLocked;
|
||||
|
||||
/// Callback to clear Ctrl lock after a key is pressed (one-shot mode).
|
||||
void Function()? clearCtrlLock;
|
||||
|
||||
/// Callback to check whether Alt modifier lock is currently active.
|
||||
bool Function()? isAltLocked;
|
||||
|
||||
/// Callback to clear Alt lock after a key is pressed (one-shot mode).
|
||||
void Function()? clearAltLock;
|
||||
|
||||
final _inputBuffer = <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.
|
||||
// This prevents NaN errors when writing to terminal before layout is complete.
|
||||
final _pendingOutputChunks = <String>[];
|
||||
@@ -42,6 +61,10 @@ class TerminalModel with ChangeNotifier {
|
||||
VoidCallback? onClosed;
|
||||
|
||||
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
|
||||
// real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'.
|
||||
// - Peer Windows: '\r' works, '\n' is just a newline.
|
||||
@@ -49,13 +72,44 @@ class TerminalModel with ChangeNotifier {
|
||||
// (readline, prompt_toolkit, vim, TUI frameworks) expect '\r'.
|
||||
// - Peer macOS: same as Linux, raw-mode apps expect '\r'
|
||||
// (https://github.com/rustdesk/rustdesk/issues/14907).
|
||||
// So on mobile / web-mobile, always normalize a lone '\n' to '\r'.
|
||||
// We deliberately do not touch multi-character payloads (e.g. pasted text)
|
||||
// so embedded newlines in pasted content are preserved.
|
||||
final isMobileOrWebMobile = (isMobile || (isWeb && !isWebDesktop));
|
||||
if (isMobileOrWebMobile && data == '\n') {
|
||||
data = '\r';
|
||||
// So on mobile / web-mobile, normalize the original lone '\n' to '\r'
|
||||
// before modifier mappings. This keeps Ctrl+J mapped to LF instead of
|
||||
// having the generated control code rewritten to CR afterward.
|
||||
// Multi-character keyboard payloads, such as terminal escape sequences,
|
||||
// remain unchanged. Paste input follows a separate preprocessing path.
|
||||
final ctrlLocked = isCtrlLocked?.call() ?? false;
|
||||
final altLocked = isAltLocked?.call() ?? false;
|
||||
final modifiersActive = ctrlLocked || altLocked;
|
||||
// Use the same predicate for transformation and consumption. Control keys
|
||||
// and escape sequences must not silently consume a pending one-shot lock.
|
||||
final shouldConsumeModifiers =
|
||||
modifiersActive && shouldApplyTerminalInputModifiers(data);
|
||||
data = prepareTerminalInputPayload(
|
||||
data,
|
||||
// IME soft-keyboard paste prompts currently arrive from xterm as normal
|
||||
// text input with no paste-origin metadata. Keep them on the keyboard path;
|
||||
// clipboard-content heuristics can misclassify ordinary typing.
|
||||
source: TerminalInputSource.keyboard,
|
||||
isMobileOrWebMobile: isMobile || (isWeb && !isWebDesktop),
|
||||
bracketedPasteMode: terminal.bracketedPasteMode,
|
||||
ctrlLocked: ctrlLocked,
|
||||
altLocked: altLocked,
|
||||
);
|
||||
if (shouldConsumeModifiers) {
|
||||
if (ctrlLocked) clearCtrlLock?.call();
|
||||
if (altLocked) clearAltLock?.call();
|
||||
}
|
||||
return _sendInputPayload(data);
|
||||
}
|
||||
|
||||
/// Sends an already prepared payload without applying keyboard semantics.
|
||||
/// Both normal input and paste use this transport path after their source-
|
||||
/// specific preprocessing has completed.
|
||||
Future<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) {
|
||||
// Send user input to remote terminal
|
||||
try {
|
||||
@@ -176,6 +230,18 @@ class TerminalModel with ChangeNotifier {
|
||||
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 {
|
||||
if (_terminalOpened) {
|
||||
try {
|
||||
@@ -516,6 +582,14 @@ class TerminalModel with ChangeNotifier {
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
terminal.onOutput = null;
|
||||
terminal.onResize = null;
|
||||
isCtrlLocked = null;
|
||||
clearCtrlLock = null;
|
||||
isAltLocked = null;
|
||||
clearAltLock = null;
|
||||
onResizeExternal = null;
|
||||
onClosed = null;
|
||||
// Clear buffers to free memory
|
||||
_inputBuffer.clear();
|
||||
_pendingOutputChunks.clear();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
40
flutter/test/terminal_keyboard_utils_test.dart
Normal file
40
flutter/test/terminal_keyboard_utils_test.dart
Normal 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));
|
||||
});
|
||||
});
|
||||
}
|
||||
51
flutter/test/terminal_model_lifecycle_test.dart
Normal file
51
flutter/test/terminal_model_lifecycle_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
typedef char** (*FUNC_RUSTDESK_CORE_MAIN)(int*);
|
||||
typedef void (*FUNC_RUSTDESK_FREE_ARGS)( char**, int);
|
||||
typedef int (*FUNC_RUSTDESK_GET_APP_NAME)(wchar_t*, int);
|
||||
typedef int (*FUNC_RUSTDESK_IS_DISABLE_INSTALLATION)();
|
||||
/// Note: `--server`, `--service` are already handled in [core_main.rs].
|
||||
const std::vector<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);
|
||||
free_c_args(c_args, args_len);
|
||||
FUNC_RUSTDESK_IS_DISABLE_INSTALLATION rustdesk_is_disable_installation =
|
||||
(FUNC_RUSTDESK_IS_DISABLE_INSTALLATION)GetProcAddress(hInstance, "rustdesk_is_disable_installation");
|
||||
bool is_disable_installation =
|
||||
rustdesk_is_disable_installation && rustdesk_is_disable_installation() != 0;
|
||||
const auto installParam = std::string("--install");
|
||||
// Flutter reads the original process command line, not only rust_args, so
|
||||
// remove the `--install` injected by the portable wrapper here as well. This
|
||||
// also lets `no-install.exe` continue as a portable app when installation is
|
||||
// disabled. See: https://github.com/rustdesk/rustdesk-server-pro/issues/991#issuecomment-4978376890
|
||||
if (is_disable_installation) {
|
||||
command_line_arguments.erase(
|
||||
std::remove(command_line_arguments.begin(),
|
||||
command_line_arguments.end(),
|
||||
installParam),
|
||||
command_line_arguments.end());
|
||||
}
|
||||
|
||||
std::wstring app_name = L"RustDesk";
|
||||
FUNC_RUSTDESK_GET_APP_NAME get_rustdesk_app_name = (FUNC_RUSTDESK_GET_APP_NAME)GetProcAddress(hInstance, "get_rustdesk_app_name");
|
||||
@@ -118,7 +135,6 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||
is_cm_page = true;
|
||||
}
|
||||
bool is_install_page = false;
|
||||
auto installParam = std::string("--install");
|
||||
if (!command_line_arguments.empty() && command_line_arguments.front().compare(0, installParam.size(), installParam.c_str()) == 0) {
|
||||
is_install_page = true;
|
||||
}
|
||||
|
||||
Submodule libs/hbb_common updated: 4bfd067765...559176122b
@@ -48,7 +48,7 @@ quest = "0.3"
|
||||
|
||||
[build-dependencies]
|
||||
target_build_utils = "0.3"
|
||||
bindgen = "0.65"
|
||||
bindgen = "0.72.1"
|
||||
pkg-config = { version = "0.3.27", optional = true }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
|
||||
@@ -20,6 +20,22 @@ use webm::mux::{self, Segment, Track, VideoTrack, Writer};
|
||||
|
||||
const MIN_SECS: u64 = 1;
|
||||
|
||||
// Replace characters that are invalid in Windows filename components so recordings remain portable.
|
||||
// Control characters are also replaced because they can make filenames invalid
|
||||
// on Windows or invisible and difficult to handle on Linux and macOS.
|
||||
fn sanitize_filename_component(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_control() || matches!(c, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') {
|
||||
'_'
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RecorderContext {
|
||||
pub server: bool,
|
||||
@@ -45,7 +61,7 @@ impl RecorderContext2 {
|
||||
}
|
||||
let file = if ctx.server { "incoming" } else { "outgoing" }.to_string()
|
||||
+ "_"
|
||||
+ &ctx.id.clone()
|
||||
+ &sanitize_filename_component(&ctx.id)
|
||||
+ &chrono::Local::now().format("_%Y%m%d%H%M%S%3f_").to_string()
|
||||
+ &format!(
|
||||
"{}{}_",
|
||||
@@ -421,3 +437,24 @@ impl Drop for HwRecorder {
|
||||
self.ctx.tx.as_ref().map(|tx| tx.send(state));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::sanitize_filename_component;
|
||||
|
||||
#[test]
|
||||
fn sanitize_recording_filename_component() {
|
||||
assert_eq!(
|
||||
sanitize_filename_component("192.168.1.2:21118"),
|
||||
"192.168.1.2_21118"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_filename_component("[2001:db8::1]:21118"),
|
||||
"[2001_db8__1]_21118"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_filename_component("peer/name\\with?bad\nchars"),
|
||||
"peer_name_with_bad_chars"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ lazy_static! {
|
||||
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);
|
||||
|
||||
pub struct Displays {
|
||||
@@ -217,7 +220,26 @@ pub fn clear_wayland_displays_cache() {
|
||||
// Return (min_x, max_x, min_y, max_y)
|
||||
pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> {
|
||||
let wayland_displays = get_displays();
|
||||
let displays = &wayland_displays.displays;
|
||||
desktop_rect_of(&wayland_displays.displays)
|
||||
}
|
||||
|
||||
// The desktop rect and per-display logical rects, always read live from the
|
||||
// compositor in a single roundtrip. Skips the displays cache and the primary-monitor
|
||||
// detection (which may spawn external commands), so it is cheap enough to poll for
|
||||
// layout changes. https://github.com/rustdesk/rustdesk/issues/15601
|
||||
pub fn get_layout_for_uinput_live() -> Option<((i32, i32, i32, i32), Vec<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() {
|
||||
return None;
|
||||
}
|
||||
@@ -243,10 +265,13 @@ pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> {
|
||||
// This may occur if the Wayland compositor does not provide logical size information,
|
||||
// or if display information is incomplete. We fall back to physical size, which provides
|
||||
// usable dimensions, but may not always be correct depending on compositor behavior.
|
||||
warn!(
|
||||
// Warn only once, the live path polls this while a session is active.
|
||||
if !MISSING_LOGICAL_SIZE_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
|
||||
warn!(
|
||||
"Display at ({}, {}) is missing logical_size; falling back to physical size ({}, {}).",
|
||||
d.x, d.y, d.width, d.height
|
||||
);
|
||||
}
|
||||
(d.width, d.height)
|
||||
};
|
||||
max_x = max_x.max(d.x + size.0);
|
||||
@@ -254,3 +279,289 @@ pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> {
|
||||
}
|
||||
Some((min_x, max_x, min_y, max_y))
|
||||
}
|
||||
|
||||
/// One display's logical rectangle in the desktop coordinate space the client uses:
|
||||
/// logical origin plus logical size, falling back to physical size when the compositor
|
||||
/// reports no logical size (matching `desktop_rect_of`).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DisplayRect {
|
||||
pub name: String,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub w: i32,
|
||||
pub h: i32,
|
||||
}
|
||||
|
||||
fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec<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)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1401,6 +1401,10 @@ impl AudioHandler {
|
||||
|
||||
/// Handle audio format and create an audio decoder.
|
||||
pub fn handle_format(&mut self, f: AudioFormat) {
|
||||
if !is_supported_audio_channel_count(f.channels) {
|
||||
log::error!("Unsupported audio channel count: {}", f.channels);
|
||||
return;
|
||||
}
|
||||
match AudioDecoder::new(f.sample_rate, if f.channels > 1 { Stereo } else { Mono }) {
|
||||
Ok(d) => {
|
||||
let buffer = vec![0.; f.sample_rate as usize * f.channels as usize];
|
||||
@@ -1540,6 +1544,23 @@ impl AudioHandler {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_supported_audio_channel_count(channels: u32) -> bool {
|
||||
(1..=2).contains(&channels)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod audio_format_tests {
|
||||
use super::is_supported_audio_channel_count;
|
||||
|
||||
#[test]
|
||||
fn only_mono_and_stereo_are_supported() {
|
||||
assert!(is_supported_audio_channel_count(1));
|
||||
assert!(is_supported_audio_channel_count(2));
|
||||
assert!(!is_supported_audio_channel_count(0));
|
||||
assert!(!is_supported_audio_channel_count(u32::MAX));
|
||||
}
|
||||
}
|
||||
|
||||
/// Video handler for the [`Client`].
|
||||
pub struct VideoHandler {
|
||||
decoder: Decoder,
|
||||
|
||||
@@ -36,6 +36,17 @@ const CLIPBOARD_GET_MAX_RETRY: usize = 3;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
const CLIPBOARD_GET_RETRY_INTERVAL_DUR: Duration = Duration::from_millis(33);
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn valid_rgba_dimensions(width: i32, height: i32, data_len: usize) -> Option<(usize, usize)> {
|
||||
let width = usize::try_from(width).ok()?;
|
||||
let height = usize::try_from(height).ok()?;
|
||||
if width == 0 || height == 0 {
|
||||
return None;
|
||||
}
|
||||
let expected_len = width.checked_mul(height)?.checked_mul(4)?;
|
||||
(data_len == expected_len).then_some((width, height))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
const SUPPORTED_FORMATS: &[ClipboardFormat] = &[
|
||||
ClipboardFormat::Text,
|
||||
@@ -722,11 +733,15 @@ mod proto {
|
||||
Ok(ClipboardFormat::Text) => String::from_utf8(data).ok().map(ClipboardData::Text),
|
||||
Ok(ClipboardFormat::Rtf) => String::from_utf8(data).ok().map(ClipboardData::Rtf),
|
||||
Ok(ClipboardFormat::Html) => String::from_utf8(data).ok().map(ClipboardData::Html),
|
||||
Ok(ClipboardFormat::ImageRgba) => Some(ClipboardData::Image(arboard::ImageData::rgba(
|
||||
clipboard.width as _,
|
||||
clipboard.height as _,
|
||||
data.into(),
|
||||
))),
|
||||
Ok(ClipboardFormat::ImageRgba) => {
|
||||
let (width, height) =
|
||||
super::valid_rgba_dimensions(clipboard.width, clipboard.height, data.len())?;
|
||||
Some(ClipboardData::Image(arboard::ImageData::rgba(
|
||||
width,
|
||||
height,
|
||||
data.into(),
|
||||
)))
|
||||
}
|
||||
Ok(ClipboardFormat::ImagePng) => {
|
||||
Some(ClipboardData::Image(arboard::ImageData::png(data.into())))
|
||||
}
|
||||
@@ -770,6 +785,22 @@ mod proto {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_os = "android")))]
|
||||
mod rgba_tests {
|
||||
use super::valid_rgba_dimensions;
|
||||
|
||||
#[test]
|
||||
fn validates_dimensions_against_content_length() {
|
||||
assert_eq!(valid_rgba_dimensions(1, 1, 4), Some((1, 1)));
|
||||
assert_eq!(valid_rgba_dimensions(1, 1, 3), None);
|
||||
assert_eq!(valid_rgba_dimensions(-1, 1, 4), None);
|
||||
assert_eq!(valid_rgba_dimensions(0, 1, 0), None);
|
||||
assert_eq!(valid_rgba_dimensions(i32::MAX, i32::MAX, 4), None);
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
assert_eq!(valid_rgba_dimensions(i32::MAX, 2, 0), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn handle_msg_clipboard(mut cb: Clipboard) {
|
||||
use hbb_common::protobuf::Message;
|
||||
|
||||
@@ -1024,7 +1024,7 @@ pub fn get_full_name() -> String {
|
||||
}
|
||||
|
||||
pub fn is_setup(name: &str) -> bool {
|
||||
name.to_lowercase().ends_with("install.exe")
|
||||
!config::is_disable_installation() && name.to_lowercase().ends_with("install.exe")
|
||||
}
|
||||
|
||||
pub fn get_custom_rendezvous_server(custom: String) -> String {
|
||||
@@ -2623,6 +2623,20 @@ pub fn is_direct_ip_access(peer: &str) -> bool {
|
||||
hbb_common::is_ip_str(peer) || hbb_common::is_domain_port_str(peer)
|
||||
}
|
||||
|
||||
// Align the maximum length of the peer id to the maximum length of the peer id in the server.
|
||||
const MAX_UNTRUSTED_PEER_ID_LEN: usize = 253;
|
||||
const UNTRUSTED_PEER_ID_FORBIDDEN_CHARS: &[char] = &['"', '<', '>', '/', '\\', '|', '?', '*'];
|
||||
|
||||
// Shared validation for peer/connect ids that cross untrusted boundaries before
|
||||
// they are stored or written into command/script contexts.
|
||||
pub fn is_valid_untrusted_peer_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= MAX_UNTRUSTED_PEER_ID_LEN
|
||||
&& !id.chars().any(|ch| {
|
||||
ch.is_control() || ch.is_whitespace() || UNTRUSTED_PEER_ID_FORBIDDEN_CHARS.contains(&ch)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2653,6 +2667,29 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untrusted_peer_id_validation() {
|
||||
let cases = [
|
||||
("123456789", true),
|
||||
("m\u{00FC}nchen-pc", true),
|
||||
("192.168.1.10:21118", true),
|
||||
("9123456234@public", true),
|
||||
(
|
||||
r#"1" & oWS.Run("cmd.exe /k whoami /priv",1,False) & ""#,
|
||||
false,
|
||||
),
|
||||
("", false),
|
||||
("peer id", false),
|
||||
("peer\nid", false),
|
||||
("peer/id", false),
|
||||
("peer?id", false),
|
||||
];
|
||||
|
||||
for (id, expected) in cases {
|
||||
assert_eq!(is_valid_untrusted_peer_id(id), expected, "{id:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// ThrottledInterval tick at the same time as tokio interval, if no sleeps
|
||||
#[allow(non_snake_case)]
|
||||
#[tokio::test]
|
||||
|
||||
@@ -127,6 +127,13 @@ pub fn core_main() -> Option<Vec<String>> {
|
||||
if args.contains(&"--noinstall".to_string()) {
|
||||
args.clear();
|
||||
}
|
||||
// The portable wrapper injects `--install` when its name ends with `install.exe`,
|
||||
// including `no-install.exe`. Drop the argument instead of exiting so disabled
|
||||
// clients can continue running as portable applications.
|
||||
if config::is_disable_installation() {
|
||||
args.retain(|arg| arg != "--install");
|
||||
flutter_args.retain(|arg| arg != "--install");
|
||||
}
|
||||
if args.len() > 0 {
|
||||
if args[0] == "--version" {
|
||||
println!("{}", crate::VERSION);
|
||||
@@ -660,7 +667,8 @@ pub fn core_main() -> Option<Vec<String>> {
|
||||
None
|
||||
}
|
||||
};
|
||||
let new_id = get_value("--id");
|
||||
// An empty --id (e.g. an unset var) would deploy a blank id; the Android flow guards this too (#15146).
|
||||
let new_id = get_value("--id").filter(|s| !s.is_empty());
|
||||
match crate::ui_interface::deploy_device(token, new_id) {
|
||||
crate::ui_interface::DeployResult::Ok => {
|
||||
println!("Device deployed.");
|
||||
|
||||
@@ -136,6 +136,12 @@ pub extern "C" fn rustdesk_core_main_args(args_len: *mut c_int) -> *mut *mut c_c
|
||||
return std::ptr::null_mut() as _;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[no_mangle]
|
||||
pub extern "C" fn rustdesk_is_disable_installation() -> c_int {
|
||||
hbb_common::config::is_disable_installation() as c_int
|
||||
}
|
||||
|
||||
// https://gist.github.com/iskakaushik/1c5b8aa75c77479c33c4320913eebef6
|
||||
#[cfg(windows)]
|
||||
fn rust_args_to_c_args(args: Vec<String>, outlen: *mut c_int) -> *mut *mut c_char {
|
||||
|
||||
60
src/ipc.rs
60
src/ipc.rs
@@ -41,6 +41,8 @@ pub(crate) use ipc_auth::ensure_peer_executable_matches_current_by_pid_opt;
|
||||
pub(crate) use ipc_auth::log_rejected_windows_ipc_connection;
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
use ipc_auth::{active_uid, authorize_service_scoped_ipc_connection};
|
||||
#[cfg(target_os = "macos")]
|
||||
use ipc_auth::authorize_user_server_process;
|
||||
#[cfg(windows)]
|
||||
use ipc_auth::{
|
||||
authorize_windows_main_ipc_connection, portable_service_listener_security_attributes,
|
||||
@@ -472,6 +474,8 @@ pub enum Data {
|
||||
#[cfg(target_os = "windows")]
|
||||
PortForwardSessionCount(Option<usize>),
|
||||
SocksWs(Option<Box<(Option<config::Socks5Server>, String)>>),
|
||||
#[cfg(target_os = "macos")]
|
||||
HasNoActiveConns(Option<bool>),
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
Whiteboard((String, crate::whiteboard::CustomEvent)),
|
||||
ControlPermissionsRemoteModify(Option<bool>),
|
||||
@@ -881,8 +885,14 @@ async fn handle(data: Data, stream: &mut Connection) {
|
||||
Some(value) => {
|
||||
let mut updated = true;
|
||||
if name == "id" {
|
||||
Config::set_key_confirmed(false);
|
||||
Config::set_id(&value);
|
||||
// An empty id would wipe the local id and unconfirm the key (cf. #15626).
|
||||
if value.is_empty() {
|
||||
log::warn!("Ignoring empty id write over IPC");
|
||||
updated = false;
|
||||
} else {
|
||||
Config::set_key_confirmed(false);
|
||||
Config::set_id(&value);
|
||||
}
|
||||
} else if name == "temporary-password" {
|
||||
password::update_temporary_password();
|
||||
} else if name == "permanent-password" {
|
||||
@@ -1000,6 +1010,16 @@ async fn handle(data: Data, stream: &mut Connection) {
|
||||
.await
|
||||
);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
Data::HasNoActiveConns(None) => {
|
||||
allow_err!(
|
||||
stream
|
||||
.send(&Data::HasNoActiveConns(Some(
|
||||
crate::updater::has_no_active_conns()
|
||||
)))
|
||||
.await
|
||||
);
|
||||
}
|
||||
#[cfg(all(
|
||||
feature = "flutter",
|
||||
not(any(target_os = "android", target_os = "ios"))
|
||||
@@ -1334,14 +1354,21 @@ pub async fn connect(ms_timeout: u64, postfix: &str) -> ResultType<ConnectionTmp
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
pub async fn connect_for_uid(
|
||||
ms_timeout: u64,
|
||||
uid: u32,
|
||||
postfix: &str,
|
||||
) -> ResultType<ConnectionTmpl<ConnClient>> {
|
||||
let path = Config::ipc_path_for_uid(uid, postfix);
|
||||
connect_with_path(ms_timeout, &path).await
|
||||
let conn = connect_with_path(ms_timeout, &path).await?;
|
||||
#[cfg(target_os = "macos")]
|
||||
if postfix.is_empty()
|
||||
&& !authorize_user_server_process(conn.peer_uid(), conn.peer_pid(), uid)
|
||||
{
|
||||
bail!("Rejected user IPC peer for uid {}", uid);
|
||||
}
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -1689,19 +1716,24 @@ pub fn clear_trusted_devices() {
|
||||
}
|
||||
|
||||
pub fn get_id() -> String {
|
||||
// An empty id may come from a process that took over the main IPC with a
|
||||
// config scope that has no id yet (e.g. a user GUI that became the server
|
||||
// while the installed service was restarting). Treat it as no answer,
|
||||
// otherwise the empty id is adopted below and wipes the local one.
|
||||
if let Ok(Some(v)) = get_config("id") {
|
||||
// update salt also, so that next time reinstallation not causing first-time auto-login failure
|
||||
if let Ok(Some(v2)) = get_config("salt") {
|
||||
Config::set_salt(&v2);
|
||||
if !v.is_empty() {
|
||||
// update salt also, so that next time reinstallation not causing first-time auto-login failure
|
||||
if let Ok(Some(v2)) = get_config("salt") {
|
||||
Config::set_salt(&v2);
|
||||
}
|
||||
if v != Config::get_id() {
|
||||
Config::set_key_confirmed(false);
|
||||
Config::set_id(&v);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
if v != Config::get_id() {
|
||||
Config::set_key_confirmed(false);
|
||||
Config::set_id(&v);
|
||||
}
|
||||
v
|
||||
} else {
|
||||
Config::get_id()
|
||||
}
|
||||
Config::get_id()
|
||||
}
|
||||
|
||||
pub async fn get_rendezvous_server(ms_timeout: u64) -> (String, Vec<String>) {
|
||||
|
||||
@@ -656,6 +656,32 @@ pub(crate) fn authorize_service_scoped_ipc_connection(stream: &Connection, postf
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn authorize_user_server_process(
|
||||
peer_uid: Option<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)]
|
||||
pub(crate) fn authorize_windows_main_ipc_connection(stream: &Connection, postfix: &str) -> bool {
|
||||
let (
|
||||
|
||||
@@ -241,6 +241,14 @@ fn wait_response(
|
||||
Some(rendezvous_message::Union::PeerDiscovery(p)) => {
|
||||
last_recv_time = Instant::now();
|
||||
if p.cmd == "pong" {
|
||||
if !crate::common::is_valid_untrusted_peer_id(&p.id) {
|
||||
log::warn!(
|
||||
"Ignoring LAN discovery response from {} with invalid peer id",
|
||||
addr
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let local_mac = if try_get_ip_by_peer {
|
||||
if let Some(self_addr) = get_ipaddr_by_peer(&addr) {
|
||||
get_mac(&self_addr)
|
||||
|
||||
@@ -332,7 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Relay Connection", "中继连接"),
|
||||
("Secure Connection", "安全连接"),
|
||||
("Insecure Connection", "非安全连接"),
|
||||
("Continue", ""),
|
||||
("Continue", "继续"),
|
||||
("Scale original", "原始尺寸"),
|
||||
("Scale adaptive", "适应窗口"),
|
||||
("General", "常规"),
|
||||
|
||||
@@ -332,7 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Relay Connection", "Połączenie przez bramkę"),
|
||||
("Secure Connection", "Połączenie szyfrowane"),
|
||||
("Insecure Connection", "Połączenie nieszyfrowane"),
|
||||
("Continue", ""),
|
||||
("Continue", "Kontynuuj"),
|
||||
("Scale original", "Skalowanie oryginalne"),
|
||||
("Scale adaptive", "Dopasuj do wyświetlacza"),
|
||||
("General", "Ogólne"),
|
||||
|
||||
@@ -646,6 +646,16 @@ fn try_start_server_(desktop: Option<&Desktop>) -> ResultType<Option<Child>> {
|
||||
if !desktop.dbus.is_empty() {
|
||||
envs.push(("DBUS_SESSION_BUS_ADDRESS", desktop.dbus.clone()));
|
||||
}
|
||||
if let Ok(forced_display_server) =
|
||||
std::env::var("RUSTDESK_FORCED_DISPLAY_SERVER")
|
||||
{
|
||||
if !forced_display_server.is_empty() {
|
||||
envs.push((
|
||||
"RUSTDESK_FORCED_DISPLAY_SERVER",
|
||||
forced_display_server,
|
||||
));
|
||||
}
|
||||
}
|
||||
envs.push((
|
||||
"TERM",
|
||||
get_cur_term(&desktop.uid).unwrap_or_else(|| suggest_best_term()),
|
||||
|
||||
@@ -118,16 +118,18 @@ extern "C" bool MacCheckAdminAuthorization() {
|
||||
|
||||
// https://gist.github.com/briankc/025415e25900750f402235dbf1b74e42
|
||||
extern "C" float BackingScaleFactor(uint32_t display) {
|
||||
NSArray<NSScreen *> *screens = [NSScreen screens];
|
||||
for (NSScreen *screen in screens) {
|
||||
NSDictionary *deviceDescription = [screen deviceDescription];
|
||||
NSNumber *screenNumber = [deviceDescription objectForKey:@"NSScreenNumber"];
|
||||
CGDirectDisplayID screenDisplayID = [screenNumber unsignedIntValue];
|
||||
if (screenDisplayID == display) {
|
||||
return [screen backingScaleFactor];
|
||||
@autoreleasepool {
|
||||
NSArray<NSScreen *> *screens = [NSScreen screens];
|
||||
for (NSScreen *screen in screens) {
|
||||
NSDictionary *deviceDescription = [screen deviceDescription];
|
||||
NSNumber *screenNumber = [deviceDescription objectForKey:@"NSScreenNumber"];
|
||||
CGDirectDisplayID screenDisplayID = [screenNumber unsignedIntValue];
|
||||
if (screenDisplayID == display) {
|
||||
return [screen backingScaleFactor];
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// https://github.com/jhford/screenresolution/blob/master/cg_utils.c
|
||||
|
||||
@@ -312,6 +312,55 @@ fn correct_app_name(s: &str) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
fn write_plist_atomically(path: &str, body: &str) -> ResultType<()> {
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let temporary = format!("{}.tmp.{}", path, std::process::id());
|
||||
let result = (|| {
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&temporary)?;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o644))?;
|
||||
file.write_all(body.as_bytes())?;
|
||||
file.sync_all()?;
|
||||
std::fs::rename(&temporary, path)?;
|
||||
Ok::<(), std::io::Error>(())
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = std::fs::remove_file(&temporary);
|
||||
}
|
||||
result.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn write_plists() -> ResultType<()> {
|
||||
let daemon_plist_path = format!(
|
||||
"/Library/LaunchDaemons/com.carriez.{}_service.plist",
|
||||
crate::get_app_name()
|
||||
);
|
||||
let agent_plist_path = format!(
|
||||
"/Library/LaunchAgents/com.carriez.{}_server.plist",
|
||||
crate::get_app_name()
|
||||
);
|
||||
let Some(daemon_plist) = PRIVILEGES_SCRIPTS_DIR.get_file("daemon.plist") else {
|
||||
bail!("daemon.plist not found in embedded resources");
|
||||
};
|
||||
let Some(daemon_plist_body) = daemon_plist.contents_utf8().map(correct_app_name) else {
|
||||
bail!("Failed to read daemon.plist");
|
||||
};
|
||||
let Some(agent_plist) = PRIVILEGES_SCRIPTS_DIR.get_file("agent.plist") else {
|
||||
bail!("agent.plist not found in embedded resources");
|
||||
};
|
||||
let Some(agent_plist_body) = agent_plist.contents_utf8().map(correct_app_name) else {
|
||||
bail!("Failed to read agent.plist");
|
||||
};
|
||||
write_plist_atomically(&daemon_plist_path, &daemon_plist_body)?;
|
||||
write_plist_atomically(&agent_plist_path, &agent_plist_body)?;
|
||||
log::info!("[write-plists] Wrote daemon and agent plists");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn uninstall_service(show_new_window: bool, sync: bool) -> bool {
|
||||
// to-do: do together with win/linux about refactory start/stop service
|
||||
if !is_installed_daemon(false) {
|
||||
@@ -659,6 +708,61 @@ pub fn get_active_userid() -> String {
|
||||
get_active_user("-n")
|
||||
}
|
||||
|
||||
/// Return every UID with a login-window/session entry. Fast user switching
|
||||
/// can leave several GUI bootstrap domains alive at once, so updating only
|
||||
/// the console user can leave another user's agent on the old bundle.
|
||||
pub(crate) fn get_logged_in_uids() -> Vec<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> {
|
||||
let username = get_active_username();
|
||||
if !username.is_empty() {
|
||||
@@ -728,8 +832,12 @@ pub fn lock_screen() {
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Starts the macOS system service IPC listener and the background
|
||||
/// silent auto-update thread.
|
||||
pub fn start_os_service() {
|
||||
log::info!("Username: {}", crate::username());
|
||||
// Silent auto-update — runs as root via LaunchDaemon, no osascript dialog needed
|
||||
crate::updater::start_auto_update_macos();
|
||||
if let Err(err) = crate::ipc::start("_service") {
|
||||
log::error!("Failed to start ipc_service: {}", err);
|
||||
}
|
||||
@@ -912,6 +1020,760 @@ pub fn update_to(_file: &str) -> ResultType<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn backup_update_plist(source: &str, backup: &str) -> ResultType<()> {
|
||||
match std::fs::symlink_metadata(source) {
|
||||
Ok(metadata) => {
|
||||
if !metadata.file_type().is_file() {
|
||||
bail!("[root-update] plist is not a regular file: {}", source);
|
||||
}
|
||||
std::fs::copy(source, backup)?;
|
||||
Ok(())
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
bail!("[root-update] required installed plist is missing: {}", source)
|
||||
}
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_update_tree(path: &Path, framework_root: Option<&Path>) -> ResultType<()> {
|
||||
let metadata = std::fs::symlink_metadata(path)?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
// Frameworks legitimately use internal symlinks (Resources,
|
||||
// Versions/Current), but never allow a link to leave its framework.
|
||||
let Some(framework_root) = framework_root else {
|
||||
bail!("[root-update] symlink outside framework: {}", path.display());
|
||||
};
|
||||
let target = std::fs::read_link(path)?;
|
||||
let target = if target.is_absolute() {
|
||||
target
|
||||
} else {
|
||||
path.parent().unwrap_or(Path::new("/")).join(target)
|
||||
};
|
||||
let target = std::fs::canonicalize(target)?;
|
||||
let framework_root = std::fs::canonicalize(framework_root)?;
|
||||
if target.starts_with(&framework_root) {
|
||||
return Ok(());
|
||||
}
|
||||
bail!("[root-update] symlink in update bundle: {}", path.display());
|
||||
}
|
||||
if metadata.file_type().is_dir() {
|
||||
for entry in std::fs::read_dir(path)? {
|
||||
let child = entry?.path();
|
||||
let child_framework_root = if child
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.ends_with(".framework"))
|
||||
{
|
||||
Some(child.as_path())
|
||||
} else {
|
||||
framework_root
|
||||
};
|
||||
validate_update_tree(&child, child_framework_root)?;
|
||||
}
|
||||
} else if !metadata.file_type().is_file() {
|
||||
bail!("[root-update] unsupported file in update bundle: {}", path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Performs a silent update from a DMG file without any osascript dialog.
|
||||
/// Must be called from a process running as root (e.g. the service binary).
|
||||
pub fn update_from_dmg_as_root(dmg_path: &str, expected_version: &str) -> ResultType<()> {
|
||||
let app_name = crate::get_app_name();
|
||||
if app_name.is_empty()
|
||||
|| !app_name
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
|
||||
{
|
||||
bail!("[root-update] unsafe application name");
|
||||
}
|
||||
let app_bundle = format!("/Applications/{}.app", app_name);
|
||||
let tmp_dir_output = std::process::Command::new("/usr/bin/mktemp")
|
||||
.args(&["-d", "/tmp/.rustdeskupdate-root-XXXXXX"])
|
||||
.output()?;
|
||||
let tmp_dir = String::from_utf8(tmp_dir_output.stdout)
|
||||
.map_err(|e| anyhow!("[root-update] mktemp output error: {}", e))?
|
||||
.trim()
|
||||
.to_string();
|
||||
if tmp_dir.is_empty() {
|
||||
bail!("[root-update] Failed to create temp directory");
|
||||
}
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&tmp_dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
let agent_plist = format!("/Library/LaunchAgents/com.carriez.{}_server.plist", app_name);
|
||||
let daemon_plist = format!("/Library/LaunchDaemons/com.carriez.{}_service.plist", app_name);
|
||||
|
||||
log::info!("[root-update] Starting silent root update from {}", dmg_path);
|
||||
// Check sessions before extracting to avoid unnecessary work
|
||||
if !crate::updater::has_no_active_conns_ipc() {
|
||||
bail!("[root-update] Active session detected, deferring update.");
|
||||
}
|
||||
// Extract DMG to temp dir
|
||||
extract_dmg_into_existing_dir(dmg_path, &tmp_dir)?;
|
||||
let src_app = format!("{}/{}.app", tmp_dir, app_name);
|
||||
log::info!("[root-update] DMG extracted to {}", tmp_dir);
|
||||
validate_update_tree(Path::new(&src_app), None)?;
|
||||
|
||||
// Bind the downloaded asset to the version returned by the update
|
||||
// service before changing plists or executing anything from the staged
|
||||
// bundle. A release asset with the right filename but the wrong bundle
|
||||
// must not be allowed to replace the installed application.
|
||||
let info_plist = format!("{}/Contents/Info.plist", src_app);
|
||||
let staged_version_result = (|| -> ResultType<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) {
|
||||
let update_temp_dir = get_update_temp_dir_string();
|
||||
let mut evt: HashMap<&str, String> =
|
||||
@@ -931,37 +1793,63 @@ pub fn extract_update_dmg(file: &str) {
|
||||
}
|
||||
|
||||
fn extract_dmg(dmg_path: &str, target_dir: &str) -> ResultType<()> {
|
||||
let mount_point = "/Volumes/RustDeskUpdate";
|
||||
let target_path = Path::new(target_dir);
|
||||
|
||||
if target_path.exists() {
|
||||
std::fs::remove_dir_all(target_path)?;
|
||||
}
|
||||
std::fs::create_dir_all(target_path)?;
|
||||
extract_dmg_inner(dmg_path, target_dir)
|
||||
}
|
||||
|
||||
let status = Command::new("hdiutil")
|
||||
.args(&["attach", "-nobrowse", "-mountpoint", mount_point, dmg_path])
|
||||
fn extract_dmg_into_existing_dir(dmg_path: &str, target_dir: &str) -> ResultType<()> {
|
||||
let target_path = Path::new(target_dir);
|
||||
if !target_path.exists() {
|
||||
bail!("[root-update] Temp directory does not exist: {:?}", target_path);
|
||||
}
|
||||
extract_dmg_inner(dmg_path, target_dir)
|
||||
}
|
||||
|
||||
fn extract_dmg_inner(dmg_path: &str, target_dir: &str) -> ResultType<()> {
|
||||
let mount_output = Command::new("/usr/bin/mktemp")
|
||||
.args(["-d", "/tmp/.rustdeskmount-XXXXXX"])
|
||||
.output()?;
|
||||
if !mount_output.status.success() {
|
||||
bail!("Failed to create a private DMG mount directory");
|
||||
}
|
||||
let mount_point = String::from_utf8(mount_output.stdout)
|
||||
.map_err(|e| anyhow!("Invalid DMG mount directory: {}", e))?
|
||||
.trim()
|
||||
.to_owned();
|
||||
if mount_point.is_empty() {
|
||||
bail!("Failed to create a private DMG mount directory");
|
||||
}
|
||||
let status = Command::new("/usr/bin/hdiutil")
|
||||
.args(["attach", "-nobrowse", "-mountpoint"])
|
||||
.arg(&mount_point)
|
||||
.arg(dmg_path)
|
||||
.status()?;
|
||||
|
||||
if !status.success() {
|
||||
let _ = std::fs::remove_dir(&mount_point);
|
||||
bail!("Failed to attach DMG image at {}: {:?}", dmg_path, status);
|
||||
}
|
||||
|
||||
struct DmgGuard(&'static str);
|
||||
struct DmgGuard(String);
|
||||
impl Drop for DmgGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = Command::new("hdiutil")
|
||||
.args(&["detach", self.0, "-force"])
|
||||
let _ = Command::new("/usr/bin/hdiutil")
|
||||
.args(["detach", self.0.as_str(), "-force"])
|
||||
.status();
|
||||
let _ = std::fs::remove_dir(&self.0);
|
||||
}
|
||||
}
|
||||
let _guard = DmgGuard(mount_point);
|
||||
let _guard = DmgGuard(mount_point.clone());
|
||||
|
||||
let app_name = format!("{}.app", crate::get_app_name());
|
||||
let src_path = format!("{}/{}", mount_point, app_name);
|
||||
let dest_path = format!("{}/{}", target_dir, app_name);
|
||||
|
||||
let copy_status = Command::new("ditto")
|
||||
let copy_status = Command::new("/usr/bin/ditto")
|
||||
.args(&[&src_path, &dest_path])
|
||||
.status()?;
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Applications/RustDesk.app/Contents/MacOS/</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/rustdesk_service.err</string>
|
||||
<string>/var/log/rustdesk_service.err</string>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/rustdesk_service.out</string>
|
||||
<string>/var/log/rustdesk_service.out</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
on run {daemon_file, agent_file, user}
|
||||
|
||||
set prefs_dir to "/Users/" & user & "/Library/Preferences/com.carriez.RustDesk/"
|
||||
set prefs_toml to quoted form of (prefs_dir & "RustDesk.toml")
|
||||
set prefs2_toml to quoted form of (prefs_dir & "RustDesk2.toml")
|
||||
|
||||
set sh1 to "echo " & quoted form of daemon_file & " > /Library/LaunchDaemons/com.carriez.RustDesk_service.plist && chown root:wheel /Library/LaunchDaemons/com.carriez.RustDesk_service.plist;"
|
||||
|
||||
set sh2 to "echo " & quoted form of agent_file & " > /Library/LaunchAgents/com.carriez.RustDesk_server.plist && chown root:wheel /Library/LaunchAgents/com.carriez.RustDesk_server.plist;"
|
||||
|
||||
set sh3 to "cp -rf /Users/" & user & "/Library/Preferences/com.carriez.RustDesk/RustDesk.toml /var/root/Library/Preferences/com.carriez.RustDesk/;"
|
||||
set sh3 to "cp -rf " & prefs_toml & " /var/root/Library/Preferences/com.carriez.RustDesk/;"
|
||||
|
||||
set sh4 to "cp -rf /Users/" & user & "/Library/Preferences/com.carriez.RustDesk/RustDesk2.toml /var/root/Library/Preferences/com.carriez.RustDesk/;"
|
||||
set sh4 to "cp -rf " & prefs2_toml & " /var/root/Library/Preferences/com.carriez.RustDesk/;"
|
||||
|
||||
set sh5 to "launchctl load -w /Library/LaunchDaemons/com.carriez.RustDesk_service.plist;"
|
||||
set sh5 to "launchctl bootout system/com.carriez.RustDesk_service 2>/dev/null || launchctl unload -w /Library/LaunchDaemons/com.carriez.RustDesk_service.plist 2>/dev/null || true; launchctl bootstrap system /Library/LaunchDaemons/com.carriez.RustDesk_service.plist 2>/dev/null || launchctl load -w /Library/LaunchDaemons/com.carriez.RustDesk_service.plist;"
|
||||
|
||||
set sh to sh1 & sh2 & sh3 & sh4 & sh5
|
||||
|
||||
|
||||
@@ -2278,6 +2278,10 @@ fn get_shortcut_icon_location(install_dir: &str, exe: &str) -> String {
|
||||
}
|
||||
|
||||
pub fn create_shortcut(id: &str) -> ResultType<()> {
|
||||
if !crate::common::is_valid_untrusted_peer_id(id) {
|
||||
bail!("Invalid peer id for shortcut");
|
||||
}
|
||||
|
||||
let exe = std::env::current_exe()?.to_str().unwrap_or("").to_owned();
|
||||
// https://github.com/rustdesk/rustdesk/issues/13735
|
||||
// Replace ':' with '_' for filename since ':' is not allowed in Windows filenames
|
||||
|
||||
@@ -357,15 +357,13 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_add_primay_video_service(&mut self) {
|
||||
let primary_video_service_name = video_service::get_service_name(
|
||||
VideoSource::Monitor,
|
||||
*display_service::PRIMARY_DISPLAY_IDX,
|
||||
);
|
||||
if !self.contains(&primary_video_service_name) {
|
||||
pub fn try_add_monitor_service(&mut self, display_idx: usize) {
|
||||
let monitor_service_name =
|
||||
video_service::get_service_name(VideoSource::Monitor, display_idx);
|
||||
if !self.contains(&monitor_service_name) {
|
||||
self.add_service(Box::new(video_service::new(
|
||||
VideoSource::Monitor,
|
||||
*display_service::PRIMARY_DISPLAY_IDX,
|
||||
display_idx,
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -381,14 +379,17 @@ impl Server {
|
||||
self.connections.insert(conn.id(), conn);
|
||||
}
|
||||
|
||||
pub fn add_connection(&mut self, conn: ConnInner, noperms: &Vec<&'static str>) {
|
||||
let primary_video_service_name = video_service::get_service_name(
|
||||
VideoSource::Monitor,
|
||||
*display_service::PRIMARY_DISPLAY_IDX,
|
||||
);
|
||||
pub fn add_monitor_connection(
|
||||
&mut self,
|
||||
conn: ConnInner,
|
||||
noperms: &Vec<&'static str>,
|
||||
display_idx: usize,
|
||||
) {
|
||||
let monitor_service_name =
|
||||
video_service::get_service_name(VideoSource::Monitor, display_idx);
|
||||
for s in self.services.values() {
|
||||
let name = s.name();
|
||||
if Self::is_video_service_name(&name) && name != primary_video_service_name {
|
||||
if Self::is_video_service_name(&name) && name != monitor_service_name {
|
||||
continue;
|
||||
}
|
||||
if !noperms.contains(&(&name as _)) {
|
||||
@@ -783,8 +784,7 @@ async fn sync_and_watch_config_dir(sync_done_tx: Option<tokio::sync::oneshot::Se
|
||||
loop {
|
||||
sleep(CONFIG_SYNC_INTERVAL_SECS).await;
|
||||
let cfg = (Config::get(), Config2::get());
|
||||
let should_sync =
|
||||
cfg != cfg0 || (is_root_config_empty && !cfg.0.is_empty());
|
||||
let should_sync = cfg != cfg0 || (is_root_config_empty && !cfg.0.is_empty());
|
||||
if should_sync {
|
||||
if is_root_config_empty {
|
||||
log::info!("root config is empty, sync our config to root");
|
||||
|
||||
@@ -509,7 +509,9 @@ impl Connection {
|
||||
tx_video: Some(tx_video),
|
||||
},
|
||||
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,
|
||||
server,
|
||||
hash,
|
||||
@@ -1923,13 +1925,15 @@ impl Connection {
|
||||
Err(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.
|
||||
// But the displays may be updated later, before creating the video capturer.
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
self.retina.set_displays(&displays);
|
||||
}
|
||||
// A separate primary lookup here could race with display hot-plug.
|
||||
self.display_idx = primary_display_idx;
|
||||
pi.displays = displays;
|
||||
pi.current_display = self.display_idx as _;
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
@@ -2038,8 +2042,8 @@ impl Connection {
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
let _h = try_start_record_cursor_pos();
|
||||
self.auto_disconnect_timer = Self::get_auto_disconenct_timer();
|
||||
s.try_add_primay_video_service();
|
||||
s.add_connection(self.inner.clone(), &noperms);
|
||||
s.try_add_monitor_service(self.display_idx);
|
||||
s.add_monitor_connection(self.inner.clone(), &noperms, self.display_idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2842,7 +2846,6 @@ impl Connection {
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
if let Some(lr) = _s.lr.clone().take() {
|
||||
self.handle_login_request_without_validation(&lr).await;
|
||||
// Switching sides authorizes without a password, so it must not bypass the
|
||||
// whitelist, which can be a locked policy pushed by the server.
|
||||
if !self.check_id_whitelist().await {
|
||||
@@ -2856,6 +2859,15 @@ impl Connection {
|
||||
if let Ok(uuid) = uuid::Uuid::from_slice(_s.uuid.to_vec().as_ref()) {
|
||||
if let Some((_instant, uuid_old)) = uuid_old {
|
||||
if uuid == uuid_old {
|
||||
if lr.union.is_some() {
|
||||
log::warn!(
|
||||
"Rejected switch sides response for non-remote-desktop session; closing connection"
|
||||
);
|
||||
self.send_login_error("Connection not allowed").await;
|
||||
return false;
|
||||
}
|
||||
self.reset_session_scope_for_login();
|
||||
self.handle_login_request_without_validation(&lr).await;
|
||||
self.from_switch = true;
|
||||
self.set_conn_audit_primary_auth(ConnAuditPrimaryAuth::SwitchSides);
|
||||
if !self.send_logon_response_and_keep_alive().await {
|
||||
@@ -4182,7 +4194,9 @@ impl Connection {
|
||||
let display_idx = s.display as usize;
|
||||
if self.display_idx != display_idx {
|
||||
if let Some(server) = self.server.upgrade() {
|
||||
self.switch_display_to(display_idx, server.clone());
|
||||
if !self.switch_display_to(display_idx, server.clone()) {
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
if !self.view_camera && s.width != 0 && s.height != 0 {
|
||||
@@ -4209,6 +4223,13 @@ impl Connection {
|
||||
}
|
||||
}
|
||||
|
||||
fn video_source_count(video_source: VideoSource) -> usize {
|
||||
match video_source {
|
||||
VideoSource::Monitor => display_service::get_sync_displays().len(),
|
||||
VideoSource::Camera => camera::Cameras::get_sync_cameras().len(),
|
||||
}
|
||||
}
|
||||
|
||||
fn video_source(&self) -> VideoSource {
|
||||
if self.view_camera {
|
||||
VideoSource::Camera
|
||||
@@ -4217,18 +4238,28 @@ impl Connection {
|
||||
}
|
||||
}
|
||||
|
||||
fn switch_display_to(&mut self, display_idx: usize, server: Arc<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 old_service_name =
|
||||
video_service::get_service_name(self.video_source(), self.display_idx);
|
||||
let mut lock = server.write().unwrap();
|
||||
if display_idx != *display_service::PRIMARY_DISPLAY_IDX {
|
||||
if !lock.contains(&new_service_name) {
|
||||
lock.add_service(Box::new(video_service::new(
|
||||
self.video_source(),
|
||||
display_idx,
|
||||
)));
|
||||
}
|
||||
if !lock.contains(&new_service_name) {
|
||||
lock.add_service(Box::new(video_service::new(
|
||||
self.video_source(),
|
||||
display_idx,
|
||||
)));
|
||||
}
|
||||
// For versions greater than 1.2.4, a `CaptureDisplays` message will be sent immediately.
|
||||
// Unnecessary capturers will be removed then.
|
||||
@@ -4237,6 +4268,7 @@ impl Connection {
|
||||
}
|
||||
lock.subscribe(&new_service_name, self.inner.clone(), true);
|
||||
self.display_idx = display_idx;
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -4263,26 +4295,61 @@ impl Connection {
|
||||
|
||||
async fn capture_displays(&mut self, add: &[usize], sub: &[usize], set: &[usize]) {
|
||||
let video_source = self.video_source();
|
||||
if let Some(sever) = self.server.upgrade() {
|
||||
let mut lock = sever.write().unwrap();
|
||||
for display in add.iter() {
|
||||
let source_count = Self::video_source_count(video_source);
|
||||
// Only add/set can create services; sub only narrows existing subscriptions.
|
||||
let valid_add = add
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|display| *display < source_count)
|
||||
.collect::<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);
|
||||
if !lock.contains(&service_name) {
|
||||
lock.add_service(Box::new(video_service::new(video_source, *display)));
|
||||
}
|
||||
}
|
||||
for display in set.iter() {
|
||||
for display in valid_set.iter() {
|
||||
let service_name = video_service::get_service_name(video_source, *display);
|
||||
if !lock.contains(&service_name) {
|
||||
lock.add_service(Box::new(video_service::new(video_source, *display)));
|
||||
}
|
||||
}
|
||||
if !add.is_empty() {
|
||||
lock.capture_displays(self.inner.clone(), video_source, add, true, false);
|
||||
lock.capture_displays(self.inner.clone(), video_source, &valid_add, true, false);
|
||||
} else if !sub.is_empty() {
|
||||
lock.capture_displays(self.inner.clone(), video_source, sub, false, true);
|
||||
lock.capture_displays(self.inner.clone(), video_source, &valid_sub, false, true);
|
||||
} else {
|
||||
lock.capture_displays(self.inner.clone(), video_source, set, true, true);
|
||||
lock.capture_displays(self.inner.clone(), video_source, &valid_set, true, true);
|
||||
}
|
||||
self.multi_ui_session = lock.get_subbed_displays_count(self.inner.id()) > 1;
|
||||
if self.follow_remote_window {
|
||||
@@ -5696,6 +5763,7 @@ impl Connection {
|
||||
Some(misc::Union::ChangeDisplayResolution(_)) => "misc.change_display_resolution",
|
||||
Some(misc::Union::MessageQuery(_)) => "misc.message_query",
|
||||
Some(misc::Union::FollowCurrentDisplay(_)) => "misc.follow_current_display",
|
||||
Some(misc::Union::SwitchSidesRequest(_)) => "misc.switch_sides_request",
|
||||
Some(_) => "misc.other",
|
||||
None => "misc.empty",
|
||||
}
|
||||
@@ -6947,6 +7015,10 @@ mod test {
|
||||
misc_msg(|m| m.set_capture_displays(CaptureDisplays::new())),
|
||||
Some("misc.capture_displays"),
|
||||
),
|
||||
(
|
||||
misc_msg(|m| m.set_switch_sides_request(SwitchSidesRequest::new())),
|
||||
Some("misc.switch_sides_request"),
|
||||
),
|
||||
(msg(|m| m.set_clipboard(Clipboard::new())), None),
|
||||
(
|
||||
msg(|m| m.set_multi_clipboards(MultiClipboards::new())),
|
||||
@@ -6991,6 +7063,10 @@ mod test {
|
||||
misc_msg(|m| m.set_toggle_privacy_mode(TogglePrivacyMode::new())),
|
||||
Some("misc.toggle_privacy_mode"),
|
||||
),
|
||||
(
|
||||
misc_msg(|m| m.set_switch_sides_request(SwitchSidesRequest::new())),
|
||||
Some("misc.switch_sides_request"),
|
||||
),
|
||||
(misc_msg(|m| m.set_chat_message(ChatMessage::new())), None),
|
||||
(msg(|m| m.set_clipboard(Clipboard::new())), None),
|
||||
(
|
||||
@@ -7076,6 +7152,10 @@ mod test {
|
||||
msg(|m| m.set_terminal_action(TerminalAction::new())),
|
||||
Some("terminal_action"),
|
||||
),
|
||||
(
|
||||
misc_msg(|m| m.set_switch_sides_request(SwitchSidesRequest::new())),
|
||||
Some("misc.switch_sides_request"),
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -7086,6 +7166,10 @@ mod test {
|
||||
None,
|
||||
),
|
||||
(msg(|m| m.set_terminal_action(TerminalAction::new())), None),
|
||||
(
|
||||
misc_msg(|m| m.set_switch_sides_request(SwitchSidesRequest::new())),
|
||||
None,
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -7105,6 +7189,10 @@ mod test {
|
||||
msg(|m| m.set_screenshot_request(ScreenshotRequest::new())),
|
||||
Some("screenshot_request"),
|
||||
),
|
||||
(
|
||||
misc_msg(|m| m.set_switch_sides_request(SwitchSidesRequest::new())),
|
||||
Some("misc.switch_sides_request"),
|
||||
),
|
||||
(misc_msg(|m| m.set_refresh_video(true)), None),
|
||||
(misc_msg(|m| m.set_refresh_video_display(0)), None),
|
||||
(
|
||||
|
||||
@@ -25,12 +25,147 @@ struct ChangedResolution {
|
||||
lazy_static::lazy_static! {
|
||||
static ref IS_CAPTURER_MAGNIFIER_SUPPORTED: bool = is_capturer_mag_supported();
|
||||
static ref CHANGED_RESOLUTIONS: Arc<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();
|
||||
}
|
||||
|
||||
#[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
|
||||
static TEMP_IGNORE_DISPLAYS_CHANGED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
@@ -41,22 +176,14 @@ struct SyncDisplaysInfo {
|
||||
}
|
||||
|
||||
impl SyncDisplaysInfo {
|
||||
fn check_changed(&mut self, displays: Vec<DisplayInfo>) {
|
||||
if self.displays.len() != displays.len() {
|
||||
self.displays = displays;
|
||||
if !TEMP_IGNORE_DISPLAYS_CHANGED.load(Ordering::Relaxed) {
|
||||
self.is_synced = false;
|
||||
}
|
||||
fn check_changed(&mut self, displays: &[DisplayInfo]) {
|
||||
if self.displays.as_slice() == displays {
|
||||
return;
|
||||
}
|
||||
for (i, d) in displays.iter().enumerate() {
|
||||
if d != &self.displays[i] {
|
||||
self.displays = displays;
|
||||
if !TEMP_IGNORE_DISPLAYS_CHANGED.load(Ordering::Relaxed) {
|
||||
self.is_synced = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
self.displays = displays.to_vec();
|
||||
if !TEMP_IGNORE_DISPLAYS_CHANGED.load(Ordering::Relaxed) {
|
||||
self.is_synced = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +369,12 @@ fn run(sp: EmptyExtraFieldService) -> ResultType<()> {
|
||||
sp.send(msg_out);
|
||||
log::info!("Displays changed");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if sp.has_subscribes() {
|
||||
refresh_wayland_uinput_rect_if_changed();
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
}
|
||||
|
||||
@@ -304,6 +437,11 @@ pub(super) fn get_display_info(idx: usize) -> Option<DisplayInfo> {
|
||||
// Display to DisplayInfo
|
||||
// The DisplayInfo is be sent to the peer.
|
||||
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`.
|
||||
// If there are multiple displays, we use the logical size for `uinput` by setting scale to d.scale().
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -346,7 +484,8 @@ pub(super) fn check_update_displays(all: &Vec<Display>) {
|
||||
}
|
||||
})
|
||||
.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> {
|
||||
@@ -357,34 +496,38 @@ pub fn is_inited_msg() -> Option<Message> {
|
||||
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")]
|
||||
{
|
||||
if !is_x11() {
|
||||
return super::wayland::get_displays().await;
|
||||
let (displays, primary_display_idx) =
|
||||
super::wayland::get_displays_and_primary().await?;
|
||||
let primary_display_idx =
|
||||
normalize_primary_display_idx(primary_display_idx, displays.len());
|
||||
return Ok((displays, primary_display_idx));
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
let displays = display_service::try_get_displays();
|
||||
#[cfg(windows)]
|
||||
let displays = display_service::try_get_displays_add_amyuni_headless();
|
||||
check_update_displays(&displays?);
|
||||
Ok(SYNC_DISPLAYS.lock().unwrap().displays.clone())
|
||||
let displays = displays?;
|
||||
let primary_display_idx = get_primary_2(&displays);
|
||||
let sync_displays = update_sync_displays(&displays);
|
||||
let primary_display_idx =
|
||||
normalize_primary_display_idx(primary_display_idx, sync_displays.len());
|
||||
Ok((sync_displays, primary_display_idx))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn get_primary() -> usize {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if !is_x11() {
|
||||
return match super::wayland::get_primary() {
|
||||
Ok(n) => n,
|
||||
Err(_) => 0,
|
||||
};
|
||||
}
|
||||
fn normalize_primary_display_idx(primary_display_idx: usize, display_len: usize) -> usize {
|
||||
// Zero is the protocol fallback when the list is empty or its primary index is stale.
|
||||
if primary_display_idx < display_len {
|
||||
primary_display_idx
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
try_get_displays().map(|d| get_primary_2(&d)).unwrap_or(0)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -486,3 +629,16 @@ pub fn try_get_displays_(add_amyuni_headless: bool) -> ResultType<Vec<Display>>
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<()> {
|
||||
set_uinput_resolution(minx, maxx, miny, maxy).await?;
|
||||
|
||||
std::thread::spawn(|| {
|
||||
// Confirm the device adopted the new range before the caller caches it.
|
||||
// spawn_blocking because ENIGO is a std Mutex and send_refresh blocks on IPC.
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if let Some(mouse) = ENIGO.lock().unwrap().get_custom_mouse() {
|
||||
if let Some(mouse) = mouse
|
||||
.as_mut_any()
|
||||
.downcast_mut::<super::uinput::client::UInputMouse>()
|
||||
{
|
||||
allow_err!(mouse.send_refresh());
|
||||
} else {
|
||||
log::error!("failed downcast uinput mouse");
|
||||
return mouse.send_refresh();
|
||||
}
|
||||
bail!("failed to downcast custom mouse to UInputMouse");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
// No custom mouse: nothing to refresh.
|
||||
Ok(())
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -1098,12 +1100,23 @@ pub fn handle_mouse_simulation_(evt: &MouseEvent, conn: i32) {
|
||||
MOUSE_TYPE_MOVE => {
|
||||
// Switching back to absolute movement implicitly disables relative mouse mode.
|
||||
set_relative_mouse_active(conn, false);
|
||||
en.mouse_move_to(evt.x, evt.y);
|
||||
// On Wayland with uinput, the client sends coordinates in the layout it was
|
||||
// told at session init. If the compositor has since moved a monitor, correct
|
||||
// them onto the current layout. https://github.com/rustdesk/rustdesk/issues/15601
|
||||
#[cfg(target_os = "linux")]
|
||||
let (mx, my) = if wayland_use_uinput() {
|
||||
super::display_service::remap_wayland_uinput_coord(evt.x, evt.y)
|
||||
} else {
|
||||
(evt.x, evt.y)
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let (mx, my) = (evt.x, evt.y);
|
||||
en.mouse_move_to(mx, my);
|
||||
*LATEST_PEER_INPUT_CURSOR.lock().unwrap() = Input {
|
||||
conn,
|
||||
time: get_time(),
|
||||
x: evt.x,
|
||||
y: evt.y,
|
||||
x: mx,
|
||||
y: my,
|
||||
};
|
||||
}
|
||||
// MOUSE_TYPE_MOVE_RELATIVE: Relative mouse movement for gaming/3D applications.
|
||||
|
||||
@@ -130,7 +130,16 @@ pub mod client {
|
||||
}
|
||||
|
||||
pub fn send_refresh(&mut self) -> ResultType<()> {
|
||||
self.send(Data::Mouse(DataMouse::Refresh))
|
||||
self.rt
|
||||
.block_on(self.conn.send(&Data::Mouse(DataMouse::Refresh)))?;
|
||||
// Wait for the service to confirm it recreated the device, so a
|
||||
// failed refresh is distinguishable from a good one.
|
||||
match self.rt.block_on(self.conn.next_timeout(IPC_REQUEST_TIMEOUT)) {
|
||||
Ok(Some(Data::Empty)) => Ok(()),
|
||||
Ok(Some(resp)) => bail!("unexpected uinput mouse refresh response: {:?}", &resp),
|
||||
Ok(None) => bail!("uinput mouse refresh failed, connection closed"),
|
||||
Err(e) => bail!("uinput mouse refresh timeout {}, {}", IPC_REQUEST_TIMEOUT, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -851,9 +860,10 @@ pub mod service {
|
||||
match data {
|
||||
Data::Mouse(data) => {
|
||||
if let DataMouse::Refresh = data {
|
||||
let resolution = RESOLUTION.lock().unwrap();
|
||||
let rng_x = resolution.0.clone();
|
||||
let rng_y = resolution.1.clone();
|
||||
let (rng_x, rng_y) = {
|
||||
let resolution = RESOLUTION.lock().unwrap();
|
||||
(resolution.0.clone(), resolution.1.clone())
|
||||
};
|
||||
log::info!(
|
||||
"Refresh uinput mouce with rng_x: ({}, {}), rng_y: ({}, {})",
|
||||
rng_x.0,
|
||||
@@ -861,11 +871,19 @@ pub mod service {
|
||||
rng_y.0,
|
||||
rng_y.1
|
||||
);
|
||||
mouse = match mouce::UInputMouseManager::new(rng_x, rng_y) {
|
||||
Ok(mouse) => mouse,
|
||||
match mouce::UInputMouseManager::new(rng_x, rng_y) {
|
||||
Ok(m) => {
|
||||
mouse = m;
|
||||
// Ack: device adopted the new range.
|
||||
allow_err!(stream.send(&Data::Empty).await);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to create mouse, {}", e);
|
||||
return;
|
||||
// Keep the current device; withhold the ack
|
||||
// so the client times out and retries.
|
||||
log::error!(
|
||||
"Failed to recreate uinput mouse, keeping current: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -137,6 +137,9 @@ pub(super) async fn check_init() -> ResultType<()> {
|
||||
if !is_x11() {
|
||||
if CAP_DISPLAY_INFO.read().unwrap().is_empty() {
|
||||
if crate::input_service::wayland_use_uinput() {
|
||||
// The cached layout may predate compositor changes made while no session
|
||||
// was active, https://github.com/rustdesk/rustdesk/issues/15601
|
||||
scrap::wayland::display::clear_wayland_displays_cache();
|
||||
if let Some((minx, maxx, miny, maxy)) =
|
||||
scrap::wayland::display::get_desktop_rect_for_uinput()
|
||||
{
|
||||
@@ -147,9 +150,28 @@ pub(super) async fn check_init() -> ResultType<()> {
|
||||
miny,
|
||||
maxy
|
||||
);
|
||||
allow_err!(
|
||||
input_service::update_mouse_resolution(minx, maxx, miny, maxy).await
|
||||
);
|
||||
// Bound the IPC wait like the periodic refresh does, so a hung
|
||||
// response can't stall session init.
|
||||
match timeout(
|
||||
3_000,
|
||||
input_service::update_mouse_resolution(minx, maxx, miny, maxy),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(())) => {
|
||||
super::display_service::set_wayland_uinput_rect((
|
||||
minx, maxx, miny, maxy,
|
||||
));
|
||||
// Snapshot the per-display layout the client's coordinates
|
||||
// will be based on, so the mouse path can correct them if
|
||||
// the compositor moves a monitor mid-session.
|
||||
super::display_service::set_wayland_layout_baseline(
|
||||
scrap::wayland::display::get_display_rects_for_uinput(),
|
||||
);
|
||||
}
|
||||
Ok(Err(err)) => log::error!("Failed to update mouse resolution: {}", err),
|
||||
Err(err) => log::error!("Failed to update mouse resolution: {}", err),
|
||||
}
|
||||
} else {
|
||||
log::warn!("Failed to get desktop rect for uinput");
|
||||
}
|
||||
@@ -175,8 +197,7 @@ pub(super) async fn check_init() -> ResultType<()> {
|
||||
*PIPEWIRE_INITIALIZED.write().unwrap() = true;
|
||||
let num = all.len();
|
||||
let primary = super::display_service::get_primary_2(&all);
|
||||
super::display_service::check_update_displays(&all);
|
||||
let mut displays = super::display_service::get_sync_displays();
|
||||
let mut displays = super::display_service::update_sync_displays(&all);
|
||||
for display in displays.iter_mut() {
|
||||
display.cursor_embedded = is_cursor_embedded();
|
||||
}
|
||||
@@ -220,27 +241,15 @@ pub(super) async fn check_init() -> ResultType<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn get_displays() -> ResultType<Vec<DisplayInfo>> {
|
||||
pub(super) async fn get_displays_and_primary() -> ResultType<(Vec<DisplayInfo>, usize)> {
|
||||
check_init().await?;
|
||||
// Keep one read guard so clear/reinitialization cannot split these across cache snapshots.
|
||||
let cap_map = CAP_DISPLAY_INFO.read().unwrap();
|
||||
if let Some(addr) = cap_map.values().next() {
|
||||
let cap_display_info: *const CapDisplayInfo = *addr as _;
|
||||
unsafe {
|
||||
let cap_display_info = &*cap_display_info;
|
||||
Ok(cap_display_info.displays.clone())
|
||||
}
|
||||
} else {
|
||||
bail!("Failed to get capturer display info");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn get_primary() -> ResultType<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)
|
||||
Ok((cap_display_info.displays.clone(), cap_display_info.primary))
|
||||
}
|
||||
} else {
|
||||
bail!("Failed to get capturer display info");
|
||||
|
||||
@@ -5,6 +5,14 @@ fn main() {}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
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();
|
||||
hbb_common::init_log(false, "service");
|
||||
crate::start_os_service();
|
||||
|
||||
@@ -151,7 +151,7 @@ class Header: Reactor.Component {
|
||||
<span #action>{svg_action}</span>
|
||||
<span #display>{svg_display}</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.renderDisplayPop()}
|
||||
{this.renderActionPop()}
|
||||
|
||||
@@ -504,6 +504,7 @@ impl sciter::EventHandler for SciterSession {
|
||||
fn get_id();
|
||||
fn get_default_pi();
|
||||
fn get_option(String);
|
||||
fn get_local_option(String);
|
||||
fn t(String);
|
||||
fn set_option(String, String);
|
||||
fn input_os_password(String, bool);
|
||||
@@ -638,6 +639,10 @@ impl SciterSession {
|
||||
crate::client::translate(name)
|
||||
}
|
||||
|
||||
pub fn get_local_option(&self, key: String) -> String {
|
||||
crate::ui_interface::get_local_option(key)
|
||||
}
|
||||
|
||||
pub fn get_icon(&self) -> String {
|
||||
super::get_icon()
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ var audio_enabled = true; // server side
|
||||
var file_enabled = true; // server side
|
||||
var restart_enabled = true; // server side
|
||||
var recording_enabled = true; // server side
|
||||
var show_recording_button = handler.get_local_option("hide-recording-button") != "Y";
|
||||
var privacy_mode_enabled = true; // server side
|
||||
var scroll_body = $(body);
|
||||
var peer_platform = "";
|
||||
|
||||
@@ -911,6 +911,29 @@ pub fn get_langs() -> String {
|
||||
json!(x).to_string()
|
||||
}
|
||||
|
||||
// Preserve relative paths for existing configurations and only remove accidental
|
||||
// surrounding whitespace. Config values are not shell-expanded (for example, `~`).
|
||||
fn trim_video_save_directory(value: &str) -> Option<&str> {
|
||||
let value = value.trim();
|
||||
if !value.is_empty() {
|
||||
Some(value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// A Windows service typically runs with System32 as its working directory, so
|
||||
// require an absolute path to avoid resolving recordings there unexpectedly.
|
||||
#[cfg(any(windows, test))]
|
||||
fn validate_windows_service_video_save_directory(value: &str) -> Option<&str> {
|
||||
let value = trim_video_save_directory(value)?;
|
||||
if std::path::Path::new(value).is_absolute() {
|
||||
Some(value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn video_save_directory(root: bool) -> String {
|
||||
let appname = crate::get_app_name();
|
||||
@@ -930,6 +953,15 @@ pub fn video_save_directory(root: bool) -> String {
|
||||
// Currently, only installed windows run as root
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let dir = Config::get_option(OPTION_WINDOWS_SERVICE_VIDEO_SAVE_DIRECTORY);
|
||||
if let Some(dir) = validate_windows_service_video_save_directory(&dir) {
|
||||
return dir.to_owned();
|
||||
}
|
||||
if !dir.trim().is_empty() {
|
||||
log::warn!(
|
||||
"Ignoring {OPTION_WINDOWS_SERVICE_VIDEO_SAVE_DIRECTORY}: path must be absolute"
|
||||
);
|
||||
}
|
||||
let drive = std::env::var("SystemDrive").unwrap_or("C:".to_owned());
|
||||
let dir =
|
||||
std::path::PathBuf::from(format!("{drive}\\ProgramData\\{appname}\\recording",));
|
||||
@@ -941,8 +973,8 @@ pub fn video_save_directory(root: bool) -> String {
|
||||
let dir = LocalConfig::get_option_from_file(OPTION_VIDEO_SAVE_DIRECTORY);
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
let dir = LocalConfig::get_option(OPTION_VIDEO_SAVE_DIRECTORY);
|
||||
if !dir.is_empty() {
|
||||
return dir;
|
||||
if let Some(dir) = trim_video_save_directory(&dir) {
|
||||
return dir.to_owned();
|
||||
}
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
if let Ok(home) = config::APP_HOME_DIR.read() {
|
||||
@@ -1705,3 +1737,41 @@ pub fn is_remote_modify_enabled_by_control_permissions() -> Option<bool> {
|
||||
.lock()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{trim_video_save_directory, validate_windows_service_video_save_directory};
|
||||
|
||||
#[test]
|
||||
fn trim_configured_video_save_directory() {
|
||||
assert_eq!(
|
||||
trim_video_save_directory(" relative/recordings "),
|
||||
Some("relative/recordings")
|
||||
);
|
||||
assert_eq!(trim_video_save_directory(" "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_service_video_save_directory() {
|
||||
let absolute = if cfg!(windows) {
|
||||
r"C:\recordings"
|
||||
} else {
|
||||
"/recordings"
|
||||
};
|
||||
let padded = format!(" {absolute} ");
|
||||
|
||||
assert_eq!(
|
||||
validate_windows_service_video_save_directory(&padded),
|
||||
Some(absolute)
|
||||
);
|
||||
assert_eq!(
|
||||
validate_windows_service_video_save_directory("recordings"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
validate_windows_service_video_save_directory(&format!("\"{absolute}\"")),
|
||||
None
|
||||
);
|
||||
assert_eq!(validate_windows_service_video_save_directory(" "), None);
|
||||
}
|
||||
}
|
||||
|
||||
316
src/updater.rs
316
src/updater.rs
@@ -11,6 +11,51 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::os::{
|
||||
fd::AsRawFd,
|
||||
unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt},
|
||||
};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
struct MacUpdateLock {
|
||||
_file: std::fs::File,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn acquire_mac_update_lock() -> ResultType<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 {
|
||||
CheckUpdate,
|
||||
Exit,
|
||||
@@ -22,7 +67,17 @@ lazy_static::lazy_static! {
|
||||
|
||||
static CONTROLLING_SESSION_COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
const DUR_ONE_DAY: Duration = Duration::from_secs(60 * 60 * 24);
|
||||
/// Initial wait after startup before the first update check (30 seconds).
|
||||
pub const INITIAL_CHECK_DELAY: Duration = Duration::from_secs(30);
|
||||
|
||||
/// One full day — default interval between update checks.
|
||||
pub const DUR_ONE_DAY: Duration = Duration::from_secs(60 * 60 * 24);
|
||||
|
||||
/// Minimum interval between consecutive update checks (10 minutes).
|
||||
pub const MIN_INTERVAL: Duration = Duration::from_secs(60 * 10);
|
||||
|
||||
/// Retry interval when an update check fails or a session is active (30 minutes).
|
||||
pub const RETRY_INTERVAL: Duration = Duration::from_secs(60 * 30);
|
||||
|
||||
pub fn update_controlling_session_count(count: usize) {
|
||||
CONTROLLING_SESSION_COUNT.store(count, Ordering::SeqCst);
|
||||
@@ -47,7 +102,9 @@ pub fn stop_auto_update() {
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn has_no_active_conns() -> bool {
|
||||
/// Returns true when there are no active incoming or outgoing connections.
|
||||
/// Used to avoid updating while a remote session is in progress.
|
||||
pub fn has_no_active_conns() -> bool {
|
||||
let conns = crate::Connection::alive_conns();
|
||||
conns.is_empty() && has_no_controlling_conns()
|
||||
}
|
||||
@@ -82,13 +139,11 @@ fn start_auto_update_check() -> Sender<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) {
|
||||
log::error!("Error checking for updates: {}", e);
|
||||
}
|
||||
|
||||
const MIN_INTERVAL: Duration = Duration::from_secs(60 * 10);
|
||||
const RETRY_INTERVAL: Duration = Duration::from_secs(60 * 30);
|
||||
let mut last_check_time = Instant::now();
|
||||
let mut check_interval = DUR_ONE_DAY;
|
||||
loop {
|
||||
@@ -118,6 +173,12 @@ fn start_auto_update_check_(rx_msg: Receiver<UpdateMsg>) {
|
||||
}
|
||||
|
||||
fn check_update(manually: bool) -> ResultType<()> {
|
||||
// On macOS, auto-update is handled by check_update_as_root() in the service process.
|
||||
// The shared check_update() path is only used for manual update checks from the GUI.
|
||||
#[cfg(target_os = "macos")]
|
||||
if !manually {
|
||||
return Ok(());
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
let update_msi = crate::platform::is_msi_installed()? && !crate::is_custom_client();
|
||||
if !(manually || config::Config::get_bool_option(config::keys::OPTION_ALLOW_AUTO_UPDATE)) {
|
||||
@@ -348,6 +409,251 @@ pub fn get_download_file_from_url(url: &str) -> Option<PathBuf> {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::get_download_file_from_url;
|
||||
|
||||
Reference in New Issue
Block a user