mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-16 17:31:04 +03:00
Compare commits
16 Commits
0fd1a0eecb
...
temporary-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2637003859 | ||
|
|
618bf37deb | ||
|
|
c1a587cfa4 | ||
|
|
9a1c8da143 | ||
|
|
978c901f49 | ||
|
|
d453a19601 | ||
|
|
50c4e435de | ||
|
|
d5c6d0f6b7 | ||
|
|
b6ff62c74b | ||
|
|
ba6de7990f | ||
|
|
a59ad333fc | ||
|
|
82aa28f129 | ||
|
|
3f93005be2 | ||
|
|
23a147b0dc | ||
|
|
e4539fc304 | ||
|
|
6dbd810454 |
17
.github/workflows/flutter-build.yml
vendored
17
.github/workflows/flutter-build.yml
vendored
@@ -43,6 +43,7 @@ env:
|
||||
# https://github.com/rustdesk/rustdesk/actions/runs/14414119794/job/40427970174
|
||||
# 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`.
|
||||
VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d"
|
||||
VCPKG_CMAKE_VERSION: "4.3.0"
|
||||
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version
|
||||
VERSION: "1.5.0"
|
||||
NDK_VERSION: "r28c"
|
||||
@@ -1536,7 +1537,6 @@ jobs:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set Swap Space
|
||||
if: ${{ matrix.job.arch == 'x86_64' }}
|
||||
uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0
|
||||
with:
|
||||
swap-size-gb: 12
|
||||
@@ -1571,6 +1571,15 @@ jobs:
|
||||
name: bridge-artifact
|
||||
path: ./
|
||||
|
||||
# vcpkg 2026.07.29's SPDX scripts require CMake 4.3+, but this ARM64 runner selects CMake 3.31.
|
||||
- name: Install CMake for vcpkg on Linux ARM64
|
||||
if: matrix.job.arch == 'aarch64' && env.UPLOAD_ARTIFACT == 'true'
|
||||
run: |
|
||||
python3 -m pip install --user "cmake==${VCPKG_CMAKE_VERSION}"
|
||||
user_base="$(python3 -m site --user-base)"
|
||||
"${user_base}/bin/cmake" --version
|
||||
echo "${user_base}/bin" >> "${GITHUB_PATH}"
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
@@ -2141,6 +2150,12 @@ jobs:
|
||||
echo "Modified vcpkg.json for armv7 build:"
|
||||
grep -A 2 -B 2 '"baseline"' vcpkg.json
|
||||
|
||||
- name: Set Swap Space
|
||||
if: matrix.job.arch == 'armv7'
|
||||
uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0
|
||||
with:
|
||||
swap-size-gb: 12
|
||||
|
||||
- name: Free Space
|
||||
run: |
|
||||
df -h
|
||||
|
||||
19
AGENTS.md
19
AGENTS.md
@@ -74,6 +74,25 @@
|
||||
* Accept a little duplication over a restructure. A new function that repeats a few lines of an existing one is a better diff than reshaping the original so both can share it.
|
||||
* Put new logic in self-contained functions in the module it belongs to (platform-specific logic in `src/platform/`, with `use` inside the function body to avoid churning shared import blocks). Call sites in shared files (`src/tray.rs`, `src/core_main.rs`, `src/server/connection.rs`, …) should be thin one-line hooks.
|
||||
|
||||
### Scope check before touching shared code
|
||||
|
||||
* Before changing a shared trait, a shared struct, or the signature of a widely used function, check whether the bug or feature is specific to one path. If it is, keep the change inside that path unless that is impossible, and say in the PR why it was.
|
||||
* If an unrelated caller needs `Default::default()`, `None`, or another placeholder solely to satisfy a signature you changed, the diff is too broad: stop and redesign.
|
||||
* The expected shape of a fix is a new function in the feature's own module, plus at most a new field or a thin hook in the shared code it needs. Feature-specific state belongs beside the feature's existing state, not in a new abstraction every caller has to learn.
|
||||
|
||||
### Mandatory regression-surface check
|
||||
|
||||
Before considering any implementation complete, perform a minimization pass over the final diff.
|
||||
|
||||
* Inspect every modified existing file and every modified existing code path. Each must be strictly necessary for the requested change. Revert changes that are merely cleanup, refactoring, consistency improvements, or fixes for pre-existing issues.
|
||||
* For new features, preserve the existing implementation path when the feature is disabled or unsupported whenever practical. `feature off` should run the old code, not a rewritten equivalent.
|
||||
* Do not route existing behavior through a new abstraction merely to share code with the new feature. Prefer a parallel new function or a small amount of duplication over changing a proven existing path.
|
||||
* Keep new implementation logic in new or feature-specific modules. Changes to shared/core files should normally be thin hooks, capability checks, or protocol plumbing.
|
||||
* Do not fix unrelated pre-existing bugs in the same PR. Put them in a separate change unless they directly block correctness or security of the requested work.
|
||||
* For submodule bumps, inspect the exact commit range and ensure unrelated changes are not being pulled into the parent PR.
|
||||
* Before finalizing, explicitly report the regression surface: list the existing files and existing runtime paths whose behavior changed, and explain why each change is unavoidable.
|
||||
* During review, treat an unnecessarily modified legacy path as a review finding even if tests pass and the rewritten behavior appears equivalent.
|
||||
|
||||
## Reviewing a PR
|
||||
|
||||
* Review only what the diff introduces. Verify ownership with `gh pr diff` before reporting a finding — if the offending lines are untouched context, it is a pre-existing problem, not this PR's.
|
||||
|
||||
@@ -244,11 +244,38 @@ List<(String, String)> otherDefaultSettings() {
|
||||
kKeyUseAllMyDisplaysForTheRemoteSession
|
||||
),
|
||||
('Keep terminal sessions on disconnect', kOptionTerminalPersistent),
|
||||
(
|
||||
'Allow terminal apps to copy to clipboard',
|
||||
kOptionAllowTerminalClipboardWrite
|
||||
),
|
||||
];
|
||||
|
||||
return v;
|
||||
}
|
||||
|
||||
String getOtherDefaultSettingOption(String key) {
|
||||
if (key == kOptionAllowTerminalClipboardWrite) {
|
||||
return bind.mainGetLocalOption(key: key);
|
||||
}
|
||||
return bind.mainGetUserDefaultOption(key: key);
|
||||
}
|
||||
|
||||
Future<void> setOtherDefaultSettingOption(String key, String value) {
|
||||
if (key == kOptionAllowTerminalClipboardWrite) {
|
||||
return bind.mainSetLocalOption(
|
||||
key: key,
|
||||
value: value == kTerminalClipboardWriteAllowed
|
||||
? kTerminalClipboardWriteAllowed
|
||||
: kTerminalClipboardWriteDenied,
|
||||
);
|
||||
}
|
||||
return bind.mainSetUserDefaultOption(key: key, value: value);
|
||||
}
|
||||
|
||||
bool isOtherDefaultSettingReadOnly(String key) =>
|
||||
isOptionFixed(key) ||
|
||||
(key == kOptionAllowTerminalClipboardWrite && bind.isDisableSettings());
|
||||
|
||||
class TrackpadSpeedWidget extends StatefulWidget {
|
||||
final SimpleWrapper<int> value;
|
||||
// If null, no debouncer will be applied.
|
||||
|
||||
@@ -115,6 +115,11 @@ const String kOptionEnableAudio = "enable-audio";
|
||||
const String kOptionEnableCamera = "enable-camera";
|
||||
const String kOptionEnableTerminal = "enable-terminal";
|
||||
const String kOptionTerminalPersistent = "terminal-persistent";
|
||||
const String kOptionAllowTerminalClipboardWrite =
|
||||
"allow-terminal-clipboard-write";
|
||||
const String kTerminalClipboardWriteUnconfigured = "";
|
||||
const String kTerminalClipboardWriteAllowed = "Y";
|
||||
const String kTerminalClipboardWriteDenied = "N";
|
||||
const String kOptionEnableTunnel = "enable-tunnel";
|
||||
const String kOptionEnableRemoteRestart = "enable-remote-restart";
|
||||
const String kOptionEnableBlockInput = "enable-block-input";
|
||||
|
||||
@@ -330,12 +330,14 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
void onConnect(
|
||||
{bool isFileTransfer = false,
|
||||
bool isViewCamera = false,
|
||||
bool isTerminal = false}) {
|
||||
bool isTerminal = false,
|
||||
bool isTcpTunneling = false}) {
|
||||
var id = _idController.id;
|
||||
connect(context, id,
|
||||
isFileTransfer: isFileTransfer,
|
||||
isViewCamera: isViewCamera,
|
||||
isTerminal: isTerminal);
|
||||
isTerminal: isTerminal,
|
||||
isTcpTunneling: isTcpTunneling);
|
||||
}
|
||||
|
||||
/// UI for the remote ID TextField.
|
||||
@@ -568,6 +570,14 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
'${translate('Terminal')} (beta)',
|
||||
() => onConnect(isTerminal: true)
|
||||
),
|
||||
// `connect` routes this through the
|
||||
// desktop path only; the peer card gates
|
||||
// it the same way.
|
||||
if (isDesktop)
|
||||
(
|
||||
'TCP tunneling',
|
||||
() => onConnect(isTcpTunneling: true)
|
||||
),
|
||||
]
|
||||
.map((e) => MenuEntryButton<String>(
|
||||
childBuilder: (TextStyle? style) =>
|
||||
|
||||
@@ -2080,14 +2080,13 @@ class _DisplayState extends State<_Display> {
|
||||
}
|
||||
|
||||
Widget otherRow(String label, String key) {
|
||||
final value = bind.mainGetUserDefaultOption(key: key) == 'Y';
|
||||
final isOptFixed = isOptionFixed(key);
|
||||
final value = getOtherDefaultSettingOption(key) == 'Y';
|
||||
final isOptFixed = isOtherDefaultSettingReadOnly(key);
|
||||
onChanged(bool b) async {
|
||||
await bind.mainSetUserDefaultOption(
|
||||
key: key,
|
||||
value: b
|
||||
? 'Y'
|
||||
: (key == kOptionEnableFileCopyPaste ? 'N' : defaultOptionNo));
|
||||
await setOtherDefaultSettingOption(
|
||||
key,
|
||||
b ? 'Y' : (key == kOptionEnableFileCopyPaste ? 'N' : defaultOptionNo),
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ class TerminalPage extends StatefulWidget {
|
||||
required this.tabKey,
|
||||
this.forceRelay,
|
||||
this.connToken,
|
||||
this.onClipboardWriteBlocked,
|
||||
this.onClipboardWriteSucceeded,
|
||||
}) : super(key: key);
|
||||
final String id;
|
||||
final String? password;
|
||||
@@ -26,6 +28,8 @@ class TerminalPage extends StatefulWidget {
|
||||
final bool? forceRelay;
|
||||
final bool? isSharedPassword;
|
||||
final String? connToken;
|
||||
final ValueChanged<String>? onClipboardWriteBlocked;
|
||||
final ValueChanged<String>? onClipboardWriteSucceeded;
|
||||
final int terminalId;
|
||||
|
||||
/// Tab key for focus management, passed from parent to avoid duplicate construction
|
||||
@@ -71,6 +75,8 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
|
||||
// Create terminal model with specific terminal ID
|
||||
_terminalModel = TerminalModel(_ffi, widget.terminalId);
|
||||
_terminalModel.onClipboardWriteBlocked = widget.onClipboardWriteBlocked;
|
||||
_terminalModel.onClipboardWriteSucceeded = widget.onClipboardWriteSucceeded;
|
||||
debugPrint(
|
||||
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
@@ -10,6 +11,8 @@ import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
||||
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/terminal_copy_shortcut.dart';
|
||||
import 'package:flutter_hbb/models/terminal_model.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../../models/platform_model.dart';
|
||||
@@ -19,6 +22,12 @@ import '../widgets/material_mod_popup_menu.dart' as mod_menu;
|
||||
import '../widgets/popup_menu.dart';
|
||||
import 'package:bot_toast/bot_toast.dart';
|
||||
|
||||
typedef _TerminalClipboardSource = ({
|
||||
String peerId,
|
||||
int terminalId,
|
||||
String tabKey,
|
||||
});
|
||||
|
||||
class TerminalTabPage extends StatefulWidget {
|
||||
final Map<String, dynamic> params;
|
||||
|
||||
@@ -30,6 +39,18 @@ class TerminalTabPage extends StatefulWidget {
|
||||
|
||||
class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
DesktopTabController get tabController => Get.find<DesktopTabController>();
|
||||
bool get _canConfigureTerminalClipboardPermission =>
|
||||
canConfigureTerminalClipboardPermission(
|
||||
settingsDisabled: bind.isDisableSettings(),
|
||||
optionFixed: isOptionFixed(kOptionAllowTerminalClipboardWrite),
|
||||
);
|
||||
bool get _canHandleTerminalClipboardWriteRequest =>
|
||||
canHandleTerminalClipboardWriteRequest(
|
||||
localOption: bind.mainGetLocalOption(
|
||||
key: kOptionAllowTerminalClipboardWrite,
|
||||
),
|
||||
canConfigurePermission: _canConfigureTerminalClipboardPermission,
|
||||
);
|
||||
|
||||
static const IconData selectedIcon = Icons.terminal;
|
||||
static const IconData unselectedIcon = Icons.terminal_outlined;
|
||||
@@ -38,6 +59,9 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
final Set<String> _closingTabs = {};
|
||||
// When true, all session cleanup should persist (window-level close in progress)
|
||||
bool _windowClosing = false;
|
||||
CancelFunc? _terminalClipboardNoticeCancel;
|
||||
final _terminalClipboardNotice =
|
||||
TerminalClipboardNoticeCoordinator<_TerminalClipboardSource>();
|
||||
|
||||
_TerminalTabPageState(Map<String, dynamic> params) {
|
||||
Get.put(DesktopTabController(tabType: DesktopTabType.terminal));
|
||||
@@ -45,7 +69,10 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
WindowController.fromWindowId(windowId())
|
||||
.setTitle(getWindowNameWithId(id));
|
||||
};
|
||||
tabController.onRemoved = (_, id) => onRemoveId(id);
|
||||
tabController.onRemoved = (_, id) {
|
||||
_closeTerminalClipboardNoticeForTab(id);
|
||||
onRemoveId(id);
|
||||
};
|
||||
tabController.onCloseWindow = _closeWindowFromConnection;
|
||||
final terminalId = params['terminalId'] ?? _nextTerminalId++;
|
||||
tabController.add(_createTerminalTab(
|
||||
@@ -70,6 +97,11 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
final alias = bind.mainGetPeerOptionSync(id: peerId, key: 'alias');
|
||||
final tabLabel =
|
||||
alias.isNotEmpty ? '$alias #$terminalId' : '$peerId #$terminalId';
|
||||
final clipboardSource = (
|
||||
peerId: peerId,
|
||||
terminalId: terminalId,
|
||||
tabKey: tabKey,
|
||||
);
|
||||
return TabInfo(
|
||||
key: tabKey,
|
||||
label: tabLabel,
|
||||
@@ -86,10 +118,169 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
tabController: tabController,
|
||||
forceRelay: forceRelay,
|
||||
connToken: connToken,
|
||||
onClipboardWriteBlocked: _canHandleTerminalClipboardWriteRequest
|
||||
? (text) => _handleTerminalClipboardWriteBlocked(
|
||||
clipboardSource,
|
||||
text,
|
||||
)
|
||||
: null,
|
||||
onClipboardWriteSucceeded: (_) {
|
||||
_handleTerminalClipboardWriteSucceeded(clipboardSource);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTerminalClipboardWriteBlocked(
|
||||
_TerminalClipboardSource source,
|
||||
String clipboardText,
|
||||
) {
|
||||
if (!mounted) return;
|
||||
final option = bind.mainGetLocalOption(
|
||||
key: kOptionAllowTerminalClipboardWrite,
|
||||
);
|
||||
final request = _terminalClipboardNotice.recordBlocked(
|
||||
source: source,
|
||||
text: clipboardText,
|
||||
option: option,
|
||||
canWrite: _canWriteTerminalClipboard,
|
||||
);
|
||||
if (request != null) _showTerminalClipboardNotice(request);
|
||||
}
|
||||
|
||||
void _showTerminalClipboardNotice(
|
||||
TerminalClipboardNoticeRequest<_TerminalClipboardSource> request,
|
||||
) {
|
||||
_terminalClipboardNoticeCancel = BotToast.showCustomNotification(
|
||||
duration: null,
|
||||
enableSlideOff: false,
|
||||
onlyOne: true,
|
||||
onClose: _handleTerminalClipboardNoticeClosed,
|
||||
toastBuilder: (_) => AnimatedBuilder(
|
||||
animation: _terminalClipboardNotice,
|
||||
builder: (_, __) => MaterialBanner(
|
||||
leading: const Icon(Icons.content_copy_outlined),
|
||||
content: Text(translate(kTerminalClipboardNoticeMessageKey)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _terminalClipboardNotice.canClaimAction
|
||||
? _handleTerminalClipboardNegativeAction
|
||||
: null,
|
||||
child: Text(translate(request.negativeActionKey)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _terminalClipboardNotice.canClaimAction
|
||||
? _handleTerminalClipboardPositiveAction
|
||||
: null,
|
||||
child: Text(translate(request.actionKey)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTerminalClipboardNegativeAction() {
|
||||
final request = _terminalClipboardNotice.claimCurrentAction();
|
||||
if (request == null) return;
|
||||
if (request.persistAllowed) {
|
||||
unawaited(_declineTerminalClipboardWrite());
|
||||
} else {
|
||||
_closeTerminalClipboardNotice();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTerminalClipboardPositiveAction() {
|
||||
final request = _terminalClipboardNotice.claimCurrentAction();
|
||||
if (request == null) return;
|
||||
unawaited(_completeTerminalClipboardWrite(request));
|
||||
}
|
||||
|
||||
void _handleTerminalClipboardNoticeClosed() {
|
||||
_terminalClipboardNoticeCancel = null;
|
||||
_terminalClipboardNotice.noticeClosed();
|
||||
}
|
||||
|
||||
bool _canWriteTerminalClipboard(
|
||||
_TerminalClipboardSource source,
|
||||
) {
|
||||
if (!_canHandleTerminalClipboardWriteRequest) return false;
|
||||
final ffi = TerminalConnectionManager.getExistingConnection(source.peerId);
|
||||
return ffi != null &&
|
||||
!ffi.closed &&
|
||||
ffi.ffiModel.permissions['clipboard'] != false &&
|
||||
tabController.state.value.tabs.any((tab) => tab.key == source.tabKey) &&
|
||||
ffi.terminalModels.containsKey(source.terminalId);
|
||||
}
|
||||
|
||||
void _handleTerminalClipboardWriteSucceeded(
|
||||
_TerminalClipboardSource source,
|
||||
) {
|
||||
final request = _terminalClipboardNotice.currentForSource(source);
|
||||
if (request == null) return;
|
||||
_closeTerminalClipboardNotice();
|
||||
}
|
||||
|
||||
Future<void> _declineTerminalClipboardWrite() async {
|
||||
try {
|
||||
await bind.mainSetLocalOption(
|
||||
key: kOptionAllowTerminalClipboardWrite,
|
||||
value: kTerminalClipboardWriteDenied,
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
'[TerminalTabPage] Failed to save terminal clipboard permission: $error');
|
||||
return;
|
||||
} finally {
|
||||
_terminalClipboardNotice.releaseAction();
|
||||
}
|
||||
_closeTerminalClipboardNotice();
|
||||
}
|
||||
|
||||
Future<void> _completeTerminalClipboardWrite(
|
||||
TerminalClipboardNoticeRequest<_TerminalClipboardSource> request,
|
||||
) async {
|
||||
final source = request.source;
|
||||
var completed = false;
|
||||
try {
|
||||
completed = await completeTerminalClipboardWrite(
|
||||
clipboardText: request.text,
|
||||
canWrite: () => _canWriteTerminalClipboard(source),
|
||||
writeClipboard: writeTerminalClipboard,
|
||||
persistAllowed: request.persistAllowed
|
||||
? () => bind.mainSetLocalOption(
|
||||
key: kOptionAllowTerminalClipboardWrite,
|
||||
value: kTerminalClipboardWriteAllowed,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
'[TerminalTabPage] Failed to complete terminal clipboard write: $error');
|
||||
} finally {
|
||||
_terminalClipboardNotice.releaseAction();
|
||||
}
|
||||
if (!completed) return;
|
||||
_closeTerminalClipboardNotice();
|
||||
}
|
||||
|
||||
void _closeTerminalClipboardNoticeForTab(String tabKey) {
|
||||
final current = _terminalClipboardNotice.current;
|
||||
if (current?.source.tabKey != tabKey) return;
|
||||
_closeTerminalClipboardNotice();
|
||||
}
|
||||
|
||||
void _closeTerminalClipboardNotice() {
|
||||
if (!_terminalClipboardNotice.beginClose()) return;
|
||||
final cancel = _terminalClipboardNoticeCancel;
|
||||
if (cancel == null) {
|
||||
debugPrint('[TerminalTabPage] Clipboard notice controller is missing');
|
||||
_terminalClipboardNotice.noticeClosed();
|
||||
return;
|
||||
}
|
||||
cancel();
|
||||
}
|
||||
|
||||
/// Unified tab close handler for all close paths (button, shortcut, programmatic).
|
||||
/// Shows audit dialog, cleans up session if not persistent, then removes the UI tab.
|
||||
Future<void> _closeTab(String tabKey) async {
|
||||
@@ -147,6 +338,8 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
// Remove all UI tabs immediately (same instant behavior as the old tabController.clear())
|
||||
// Keep the cleanup target lookup below synchronous before its first await:
|
||||
// it relies on the current frame still retaining each TerminalPage's FFI/model.
|
||||
_terminalClipboardNotice.clear();
|
||||
_terminalClipboardNoticeCancel?.call();
|
||||
tabController.clear();
|
||||
// Run session cleanup in parallel with bounded timeout (closeTerminal() has internal 3s timeout).
|
||||
// Skip tabs already being closed by a concurrent _closeTab() to avoid duplicate FFI calls.
|
||||
@@ -357,6 +550,8 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
@override
|
||||
void dispose() {
|
||||
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
|
||||
_terminalClipboardNotice.clear();
|
||||
_terminalClipboardNoticeCancel?.call();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -1269,16 +1269,18 @@ class __DisplayPageState extends State<_DisplayPage> {
|
||||
}
|
||||
|
||||
SettingsTile otherRow(String label, String key) {
|
||||
final value = bind.mainGetUserDefaultOption(key: key) == 'Y';
|
||||
final isOptFixed = isOptionFixed(key);
|
||||
final value = getOtherDefaultSettingOption(key) == 'Y';
|
||||
final isOptFixed = isOtherDefaultSettingReadOnly(key);
|
||||
return SettingsTile.switchTile(
|
||||
initialValue: value,
|
||||
title: Text(translate(label)),
|
||||
onToggle: isOptFixed
|
||||
? null
|
||||
: (b) async {
|
||||
await bind.mainSetUserDefaultOption(
|
||||
key: key, value: b ? 'Y' : defaultOptionNo);
|
||||
await setOtherDefaultSettingOption(
|
||||
key,
|
||||
b ? 'Y' : defaultOptionNo,
|
||||
);
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/terminal_copy_shortcut.dart';
|
||||
import 'package:flutter_hbb/models/terminal_model.dart';
|
||||
import 'package:flutter_hbb/models/terminal_mouse_handler.dart';
|
||||
import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart';
|
||||
import 'package:flutter_hbb/web/dummy.dart'
|
||||
if (dart.library.html) 'package:flutter_hbb/web/terminal_font.dart';
|
||||
@@ -19,6 +20,49 @@ import 'package:xterm/xterm.dart';
|
||||
import '../../desktop/pages/terminal_connection_manager.dart';
|
||||
import '../../consts.dart';
|
||||
|
||||
const _terminalBackgroundOpacity = 0.7;
|
||||
|
||||
Widget _buildTerminalViewForPlatform({
|
||||
required bool reportMouseInput,
|
||||
required bool reportTouchInput,
|
||||
required Terminal terminal,
|
||||
required TerminalController controller,
|
||||
required TerminalStyle textStyle,
|
||||
required EdgeInsets padding,
|
||||
required bool deleteDetection,
|
||||
required Map<ShortcutActivator, Intent>? shortcuts,
|
||||
required FocusOnKeyEventCallback onKeyEvent,
|
||||
required void Function(TapDownDetails, CellOffset) onSecondaryTapDown,
|
||||
}) {
|
||||
if (reportMouseInput || reportTouchInput) {
|
||||
return TerminalMouseInteraction(
|
||||
terminal,
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
textStyle: textStyle,
|
||||
deleteDetection: deleteDetection,
|
||||
reportTouchInput: reportTouchInput,
|
||||
shortcuts: shortcuts,
|
||||
onKeyEvent: onKeyEvent,
|
||||
backgroundOpacity: _terminalBackgroundOpacity,
|
||||
padding: padding,
|
||||
onSecondaryTapDown: onSecondaryTapDown,
|
||||
);
|
||||
}
|
||||
return TerminalView(
|
||||
terminal,
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
textStyle: textStyle,
|
||||
deleteDetection: deleteDetection,
|
||||
shortcuts: shortcuts,
|
||||
onKeyEvent: onKeyEvent,
|
||||
backgroundOpacity: _terminalBackgroundOpacity,
|
||||
padding: padding,
|
||||
onSecondaryTapDown: onSecondaryTapDown,
|
||||
);
|
||||
}
|
||||
|
||||
class TerminalPage extends StatefulWidget {
|
||||
const TerminalPage({
|
||||
Key? key,
|
||||
@@ -41,6 +85,19 @@ class TerminalPage extends StatefulWidget {
|
||||
|
||||
class _TerminalPageState extends State<TerminalPage>
|
||||
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
|
||||
bool get _canConfigureTerminalClipboardPermission =>
|
||||
canConfigureTerminalClipboardPermission(
|
||||
settingsDisabled: bind.isDisableSettings(),
|
||||
optionFixed: isOptionFixed(kOptionAllowTerminalClipboardWrite),
|
||||
);
|
||||
bool get _canHandleTerminalClipboardWriteRequest =>
|
||||
canHandleTerminalClipboardWriteRequest(
|
||||
localOption: bind.mainGetLocalOption(
|
||||
key: kOptionAllowTerminalClipboardWrite,
|
||||
),
|
||||
canConfigurePermission: _canConfigureTerminalClipboardPermission,
|
||||
);
|
||||
|
||||
late FFI _ffi;
|
||||
late TerminalModel _terminalModel;
|
||||
double? _cellHeight;
|
||||
@@ -57,6 +114,9 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
// For iOS edge swipe gesture
|
||||
double _swipeStartX = 0;
|
||||
double _swipeCurrentX = 0;
|
||||
ScaffoldFeatureController<MaterialBanner, MaterialBannerClosedReason>?
|
||||
_terminalClipboardNoticeController;
|
||||
final _terminalClipboardNotice = TerminalClipboardNoticeCoordinator<int>();
|
||||
|
||||
// For web only.
|
||||
// 'monospace' does not work on web, use Google Fonts, `??` is only for null safety.
|
||||
@@ -89,6 +149,12 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
|
||||
// Create terminal model with specific terminal ID
|
||||
_terminalModel = TerminalModel(_ffi, widget.terminalId);
|
||||
if (_canHandleTerminalClipboardWriteRequest) {
|
||||
_terminalModel.onClipboardWriteBlocked =
|
||||
_handleTerminalClipboardWriteBlocked;
|
||||
_terminalModel.onClipboardWriteSucceeded =
|
||||
_handleTerminalClipboardWriteSucceeded;
|
||||
}
|
||||
debugPrint(
|
||||
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
|
||||
|
||||
@@ -134,12 +200,144 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
_ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
|
||||
}
|
||||
|
||||
void _handleTerminalClipboardWriteBlocked(String clipboardText) {
|
||||
if (!mounted) return;
|
||||
final option = bind.mainGetLocalOption(
|
||||
key: kOptionAllowTerminalClipboardWrite,
|
||||
);
|
||||
final request = _terminalClipboardNotice.recordBlocked(
|
||||
source: widget.terminalId,
|
||||
text: clipboardText,
|
||||
option: option,
|
||||
canWrite: (_) => _canWriteTerminalClipboard,
|
||||
);
|
||||
if (request != null) _showTerminalClipboardNotice(request);
|
||||
}
|
||||
|
||||
void _showTerminalClipboardNotice(
|
||||
TerminalClipboardNoticeRequest<int> request,
|
||||
) {
|
||||
final controller = ScaffoldMessenger.of(context).showMaterialBanner(
|
||||
MaterialBanner(
|
||||
leading: const Icon(Icons.content_copy_outlined),
|
||||
content: Text(translate(kTerminalClipboardNoticeMessageKey)),
|
||||
actions: [
|
||||
AnimatedBuilder(
|
||||
animation: _terminalClipboardNotice,
|
||||
builder: (_, __) => TextButton(
|
||||
onPressed: _terminalClipboardNotice.canClaimAction
|
||||
? _handleTerminalClipboardNegativeAction
|
||||
: null,
|
||||
child: Text(translate(request.negativeActionKey)),
|
||||
),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: _terminalClipboardNotice,
|
||||
builder: (_, __) => TextButton(
|
||||
onPressed: _terminalClipboardNotice.canClaimAction
|
||||
? _handleTerminalClipboardPositiveAction
|
||||
: null,
|
||||
child: Text(translate(request.actionKey)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
_terminalClipboardNoticeController = controller;
|
||||
unawaited(controller.closed.then<void>((_) {
|
||||
if (identical(_terminalClipboardNoticeController, controller)) {
|
||||
_terminalClipboardNoticeController = null;
|
||||
_terminalClipboardNotice.noticeClosed();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
void _handleTerminalClipboardNegativeAction() {
|
||||
final request = _terminalClipboardNotice.claimCurrentAction();
|
||||
if (request == null) return;
|
||||
if (request.persistAllowed) {
|
||||
unawaited(_declineTerminalClipboardWrite());
|
||||
} else {
|
||||
_closeTerminalClipboardNotice();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTerminalClipboardPositiveAction() {
|
||||
final request = _terminalClipboardNotice.claimCurrentAction();
|
||||
if (request == null) return;
|
||||
unawaited(_completeTerminalClipboardWrite(request));
|
||||
}
|
||||
|
||||
bool get _canWriteTerminalClipboard =>
|
||||
_canHandleTerminalClipboardWriteRequest &&
|
||||
!_ffi.closed &&
|
||||
_ffi.ffiModel.permissions['clipboard'] != false;
|
||||
|
||||
void _handleTerminalClipboardWriteSucceeded(String _) {
|
||||
_closeTerminalClipboardNotice();
|
||||
}
|
||||
|
||||
Future<void> _declineTerminalClipboardWrite() async {
|
||||
try {
|
||||
await bind.mainSetLocalOption(
|
||||
key: kOptionAllowTerminalClipboardWrite,
|
||||
value: kTerminalClipboardWriteDenied,
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
'[TerminalPage] Failed to save terminal clipboard permission: $error');
|
||||
return;
|
||||
} finally {
|
||||
_terminalClipboardNotice.releaseAction();
|
||||
}
|
||||
_closeTerminalClipboardNotice();
|
||||
}
|
||||
|
||||
Future<void> _completeTerminalClipboardWrite(
|
||||
TerminalClipboardNoticeRequest<int> request,
|
||||
) async {
|
||||
var completed = false;
|
||||
try {
|
||||
completed = await completeTerminalClipboardWrite(
|
||||
clipboardText: request.text,
|
||||
canWrite: () => _canWriteTerminalClipboard,
|
||||
writeClipboard: writeTerminalClipboard,
|
||||
persistAllowed: request.persistAllowed
|
||||
? () => bind.mainSetLocalOption(
|
||||
key: kOptionAllowTerminalClipboardWrite,
|
||||
value: kTerminalClipboardWriteAllowed,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
'[TerminalPage] Failed to complete terminal clipboard write: $error');
|
||||
} finally {
|
||||
_terminalClipboardNotice.releaseAction();
|
||||
}
|
||||
if (!completed) return;
|
||||
_closeTerminalClipboardNotice();
|
||||
}
|
||||
|
||||
void _closeTerminalClipboardNotice() {
|
||||
if (!_terminalClipboardNotice.beginClose()) return;
|
||||
final controller = _terminalClipboardNoticeController;
|
||||
if (controller == null) {
|
||||
debugPrint('[TerminalPage] Clipboard notice controller is missing');
|
||||
_terminalClipboardNotice.noticeClosed();
|
||||
return;
|
||||
}
|
||||
controller.close();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Unregister terminal model from FFI
|
||||
_ffi.unregisterTerminalModel(widget.terminalId);
|
||||
_terminalModel.dispose();
|
||||
_keyboardDebounce?.cancel();
|
||||
_terminalClipboardNotice.clear();
|
||||
_terminalClipboardNoticeController?.close();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
TerminalConnectionManager.releaseConnection(widget.id);
|
||||
@@ -234,12 +432,12 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final heightPx = constraints.maxHeight;
|
||||
return TerminalView(
|
||||
_terminalModel.terminal,
|
||||
return _buildTerminalViewForPlatform(
|
||||
reportMouseInput: isWebDesktop || isAndroid,
|
||||
reportTouchInput: isIOS,
|
||||
terminal: _terminalModel.terminal,
|
||||
controller: _terminalModel.terminalController,
|
||||
autofocus: true,
|
||||
textStyle: _getTerminalStyle(),
|
||||
backgroundOpacity: 0.7,
|
||||
// The following comment is from xterm.dart source code:
|
||||
// Workaround to detect delete key for platforms and IMEs that do not
|
||||
// emit a hardware delete event. Preferred on mobile platforms. [false] by
|
||||
|
||||
@@ -1,7 +1,108 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
enum TerminalClipboardWritePermission { denied, unconfigured, allowed }
|
||||
|
||||
class RustDeskTerminal extends Terminal {
|
||||
RustDeskTerminal({super.maxLines});
|
||||
RustDeskTerminal({
|
||||
super.maxLines,
|
||||
required TerminalClipboardWritePermission Function()
|
||||
clipboardWritePermission,
|
||||
required Future<bool> Function(String) onClipboardWrite,
|
||||
ValueChanged<String>? onClipboardWriteBlocked,
|
||||
ValueChanged<String>? onClipboardWriteSucceeded,
|
||||
}) : _clipboardWritePermission = clipboardWritePermission,
|
||||
_onClipboardWrite = onClipboardWrite,
|
||||
_onClipboardWriteBlocked = onClipboardWriteBlocked,
|
||||
_onClipboardWriteSucceeded = onClipboardWriteSucceeded {
|
||||
onPrivateOSC = _handlePrivateOsc;
|
||||
}
|
||||
|
||||
static const _clipboardOscCode = '52';
|
||||
static const _systemClipboardSelection = 'c';
|
||||
// Match the terminal helper's existing payload safety ceiling.
|
||||
static const _maxClipboardWriteBytes = 16 * 1024 * 1024;
|
||||
static const _base64InputBytesPerBlock = 3;
|
||||
static const _base64EncodedCharsPerBlock = 4;
|
||||
static final _osc52Selection = RegExp(r'^[cpqs0-7]*$');
|
||||
final TerminalClipboardWritePermission Function() _clipboardWritePermission;
|
||||
final Future<bool> Function(String) _onClipboardWrite;
|
||||
final ValueChanged<String>? _onClipboardWriteBlocked;
|
||||
final ValueChanged<String>? _onClipboardWriteSucceeded;
|
||||
|
||||
bool get isClipboardWriteAllowed =>
|
||||
_clipboardWritePermission() == TerminalClipboardWritePermission.allowed;
|
||||
|
||||
void _handlePrivateOsc(String code, List<String> args) {
|
||||
if (code != _clipboardOscCode) return;
|
||||
if (args.length != 2 || !_osc52Selection.hasMatch(args.first)) {
|
||||
debugPrint('[RustDeskTerminal] Rejected malformed OSC 52 command');
|
||||
return;
|
||||
}
|
||||
if (args.last == '?') {
|
||||
debugPrint('[RustDeskTerminal] Rejected OSC 52 clipboard query');
|
||||
return;
|
||||
}
|
||||
final permission = _clipboardWritePermission();
|
||||
if (permission == TerminalClipboardWritePermission.denied) {
|
||||
debugPrint('[RustDeskTerminal] Rejected unauthorized OSC 52 write');
|
||||
return;
|
||||
}
|
||||
final selection = args.first;
|
||||
if (selection.isNotEmpty &&
|
||||
!selection.contains(_systemClipboardSelection)) {
|
||||
debugPrint('[RustDeskTerminal] Ignored unsupported OSC 52 selection');
|
||||
return;
|
||||
}
|
||||
if (selection.replaceAll(_systemClipboardSelection, '').isNotEmpty) {
|
||||
debugPrint('[RustDeskTerminal] Ignored unsupported OSC 52 selections');
|
||||
}
|
||||
final text = _decodeClipboardPayload(args.last);
|
||||
if (text == null) return;
|
||||
if (permission == TerminalClipboardWritePermission.unconfigured) {
|
||||
debugPrint('[RustDeskTerminal] Blocked OSC 52 write pending consent');
|
||||
_onClipboardWriteBlocked?.call(text);
|
||||
return;
|
||||
}
|
||||
unawaited(_writeClipboard(text));
|
||||
}
|
||||
|
||||
Future<void> _writeClipboard(String text) async {
|
||||
final succeeded = await _onClipboardWrite(text);
|
||||
if (succeeded) {
|
||||
_onClipboardWriteSucceeded?.call(text);
|
||||
return;
|
||||
}
|
||||
debugPrint(
|
||||
'[RustDeskTerminal] OSC 52 clipboard write requires interaction');
|
||||
_onClipboardWriteBlocked?.call(text);
|
||||
}
|
||||
|
||||
String? _decodeClipboardPayload(String payload) {
|
||||
if (payload.length > _maxBase64EncodedLength(_maxClipboardWriteBytes)) {
|
||||
debugPrint('[RustDeskTerminal] Rejected oversized OSC 52 payload');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final bytes = base64.decode(payload);
|
||||
if (bytes.length > _maxClipboardWriteBytes) {
|
||||
debugPrint('[RustDeskTerminal] Rejected oversized OSC 52 payload');
|
||||
return null;
|
||||
}
|
||||
return utf8.decode(bytes);
|
||||
} on FormatException {
|
||||
debugPrint('[RustDeskTerminal] Rejected malformed OSC 52 payload');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static int _maxBase64EncodedLength(int maxBytes) =>
|
||||
((maxBytes + _base64InputBytesPerBlock - 1) ~/
|
||||
_base64InputBytesPerBlock) *
|
||||
_base64EncodedCharsPerBlock;
|
||||
|
||||
@override
|
||||
void eraseScrollbackOnly() {
|
||||
|
||||
15
flutter/lib/models/terminal_clipboard_writer.dart
Normal file
15
flutter/lib/models/terminal_clipboard_writer.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
Future<bool> writeTerminalClipboardPlatform(
|
||||
String text, {
|
||||
bool userInitiated = false,
|
||||
}) async {
|
||||
try {
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
return true;
|
||||
} catch (error) {
|
||||
debugPrint('[Terminal] Failed to write clipboard: $error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
29
flutter/lib/models/terminal_clipboard_writer_web.dart
Normal file
29
flutter/lib/models/terminal_clipboard_writer_web.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import 'dart:js_interop';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
const _writeTerminalClipboardCommand = 'write_terminal_clipboard';
|
||||
|
||||
@JS('setByName')
|
||||
external JSPromise<JSBoolean> _setByName(
|
||||
JSString name,
|
||||
JSString value,
|
||||
JSBoolean userInitiated,
|
||||
);
|
||||
|
||||
Future<bool> writeTerminalClipboardPlatform(
|
||||
String text, {
|
||||
bool userInitiated = false,
|
||||
}) async {
|
||||
try {
|
||||
final result = await _setByName(
|
||||
_writeTerminalClipboardCommand.toJS,
|
||||
text.toJS,
|
||||
userInitiated.toJS,
|
||||
).toDart;
|
||||
return result.toDart;
|
||||
} catch (error) {
|
||||
debugPrint('[Terminal] Failed to write Web clipboard: $error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -3,20 +3,130 @@ import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
import 'terminal_clipboard_writer.dart'
|
||||
if (dart.library.html) 'terminal_clipboard_writer_web.dart';
|
||||
|
||||
const _controlShiftVPasteShortcut = SingleActivator(
|
||||
LogicalKeyboardKey.keyV,
|
||||
control: true,
|
||||
shift: true,
|
||||
);
|
||||
|
||||
Future<void> writeTerminalClipboard(String text) async {
|
||||
try {
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
} catch (error) {
|
||||
debugPrint('[Terminal] Failed to write clipboard: $error');
|
||||
typedef TerminalClipboardWriter = Future<bool> Function(
|
||||
String text, {
|
||||
required bool userInitiated,
|
||||
});
|
||||
|
||||
class TerminalClipboardNoticeRequest<T> {
|
||||
const TerminalClipboardNoticeRequest({
|
||||
required this.source,
|
||||
required this.text,
|
||||
required this.persistAllowed,
|
||||
});
|
||||
|
||||
final T source;
|
||||
final String text;
|
||||
final bool persistAllowed;
|
||||
|
||||
String get actionKey => persistAllowed ? 'Enable' : 'Copy to clipboard';
|
||||
|
||||
String get negativeActionKey => persistAllowed ? 'Decline' : 'Dismiss';
|
||||
}
|
||||
|
||||
const kTerminalClipboardNoticeMessageKey = 'terminal-clipboard-write-tip';
|
||||
|
||||
class TerminalClipboardNoticeCoordinator<T> extends ChangeNotifier {
|
||||
TerminalClipboardNoticeRequest<T>? _current;
|
||||
bool _noticeVisible = false;
|
||||
bool _actionInProgress = false;
|
||||
|
||||
TerminalClipboardNoticeRequest<T>? get current => _current;
|
||||
bool get canClaimAction =>
|
||||
_noticeVisible && !_actionInProgress && _current != null;
|
||||
|
||||
TerminalClipboardNoticeRequest<T>? currentForSource(T source) {
|
||||
final current = _current;
|
||||
if (current == null || current.source != source) return null;
|
||||
return current;
|
||||
}
|
||||
|
||||
TerminalClipboardNoticeRequest<T>? recordBlocked({
|
||||
required T source,
|
||||
required String text,
|
||||
required String option,
|
||||
required bool Function(T source) canWrite,
|
||||
}) {
|
||||
if (!canWrite(source)) return null;
|
||||
final requestAllowsPersistence =
|
||||
option == kTerminalClipboardWriteUnconfigured;
|
||||
if (option != kTerminalClipboardWriteAllowed && !requestAllowsPersistence) {
|
||||
return null;
|
||||
}
|
||||
if (_noticeVisible && _actionInProgress) return null;
|
||||
final wasVisible = _noticeVisible;
|
||||
final persistAllowed =
|
||||
wasVisible ? _current?.persistAllowed : requestAllowsPersistence;
|
||||
final request = TerminalClipboardNoticeRequest(
|
||||
source: source,
|
||||
text: text,
|
||||
persistAllowed: persistAllowed ?? requestAllowsPersistence,
|
||||
);
|
||||
_current = request;
|
||||
if (wasVisible) return null;
|
||||
_noticeVisible = true;
|
||||
return request;
|
||||
}
|
||||
|
||||
TerminalClipboardNoticeRequest<T>? claimCurrentAction() {
|
||||
if (!canClaimAction) return null;
|
||||
final current = _current;
|
||||
if (current == null) return null;
|
||||
_actionInProgress = true;
|
||||
notifyListeners();
|
||||
return current;
|
||||
}
|
||||
|
||||
void releaseAction() {
|
||||
if (!_actionInProgress) return;
|
||||
_actionInProgress = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool beginClose() {
|
||||
if (!_noticeVisible) return false;
|
||||
_actionInProgress = true;
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
void noticeClosed() => clear();
|
||||
|
||||
void clear() {
|
||||
_current = null;
|
||||
_noticeVisible = false;
|
||||
_actionInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> writeTerminalClipboard(
|
||||
String text, {
|
||||
bool userInitiated = false,
|
||||
}) =>
|
||||
writeTerminalClipboardPlatform(text, userInitiated: userInitiated);
|
||||
|
||||
Future<bool> completeTerminalClipboardWrite({
|
||||
required String clipboardText,
|
||||
required bool Function() canWrite,
|
||||
required TerminalClipboardWriter writeClipboard,
|
||||
Future<void> Function()? persistAllowed,
|
||||
}) async {
|
||||
if (!canWrite()) return false;
|
||||
if (!await writeClipboard(clipboardText, userInitiated: true)) return false;
|
||||
await persistAllowed?.call();
|
||||
return true;
|
||||
}
|
||||
|
||||
Map<ShortcutActivator, Intent>? platformTerminalShortcuts() {
|
||||
@@ -68,7 +178,7 @@ FocusOnKeyEventCallback terminalCopyHandler(
|
||||
if (selection != null && !selection.isCollapsed) {
|
||||
if (event is KeyDownEvent) {
|
||||
final text = terminal.buffer.getText(selection);
|
||||
unawaited(writeTerminalClipboard(text));
|
||||
unawaited(writeTerminalClipboard(text, userInitiated: true));
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
@@ -11,8 +11,38 @@ import 'input_modifier_utils.dart';
|
||||
import 'model.dart';
|
||||
import 'platform_model.dart';
|
||||
import 'rustdesk_terminal.dart';
|
||||
import 'terminal_copy_shortcut.dart';
|
||||
import 'terminal_mouse_handler.dart';
|
||||
|
||||
bool canConfigureTerminalClipboardPermission({
|
||||
required bool settingsDisabled,
|
||||
required bool optionFixed,
|
||||
}) =>
|
||||
!settingsDisabled && !optionFixed;
|
||||
|
||||
bool canHandleTerminalClipboardWriteRequest({
|
||||
required String localOption,
|
||||
required bool canConfigurePermission,
|
||||
}) =>
|
||||
canConfigurePermission || localOption == kTerminalClipboardWriteAllowed;
|
||||
|
||||
TerminalClipboardWritePermission terminalClipboardWritePermission(
|
||||
String localOption, {
|
||||
required bool remoteClipboardEnabled,
|
||||
bool canRequestConsent = true,
|
||||
}) {
|
||||
if (!remoteClipboardEnabled) {
|
||||
return TerminalClipboardWritePermission.denied;
|
||||
}
|
||||
if (localOption == kTerminalClipboardWriteAllowed) {
|
||||
return TerminalClipboardWritePermission.allowed;
|
||||
}
|
||||
if (localOption == kTerminalClipboardWriteUnconfigured && canRequestConsent) {
|
||||
return TerminalClipboardWritePermission.unconfigured;
|
||||
}
|
||||
return TerminalClipboardWritePermission.denied;
|
||||
}
|
||||
|
||||
class TerminalModel with ChangeNotifier {
|
||||
final String id; // peer id
|
||||
final FFI parent;
|
||||
@@ -62,6 +92,9 @@ class TerminalModel with ChangeNotifier {
|
||||
/// The listener (typically TerminalPage) can use this to auto-close the tab/page.
|
||||
VoidCallback? onClosed;
|
||||
|
||||
ValueChanged<String>? onClipboardWriteBlocked;
|
||||
ValueChanged<String>? onClipboardWriteSucceeded;
|
||||
|
||||
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.
|
||||
@@ -130,7 +163,19 @@ class TerminalModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
TerminalModel(this.parent, [this.terminalId = 0]) : id = parent.id {
|
||||
terminal = RustDeskTerminal(maxLines: 10000);
|
||||
terminal = RustDeskTerminal(
|
||||
maxLines: 10000,
|
||||
onClipboardWrite: writeTerminalClipboard,
|
||||
clipboardWritePermission: () => terminalClipboardWritePermission(
|
||||
bind.mainGetLocalOption(key: kOptionAllowTerminalClipboardWrite),
|
||||
remoteClipboardEnabled:
|
||||
parent.ffiModel.permissions['clipboard'] != false,
|
||||
canRequestConsent: onClipboardWriteBlocked != null,
|
||||
),
|
||||
onClipboardWriteBlocked: (text) => onClipboardWriteBlocked?.call(text),
|
||||
onClipboardWriteSucceeded: (text) =>
|
||||
onClipboardWriteSucceeded?.call(text),
|
||||
);
|
||||
terminal.mouseHandler = const WheelButtonFixMouseHandler();
|
||||
terminalController = TerminalController();
|
||||
|
||||
@@ -593,6 +638,8 @@ class TerminalModel with ChangeNotifier {
|
||||
clearAltLock = null;
|
||||
onResizeExternal = null;
|
||||
onClosed = null;
|
||||
onClipboardWriteBlocked = null;
|
||||
onClipboardWriteSucceeded = null;
|
||||
// Clear buffers to free memory
|
||||
_inputBuffer.clear();
|
||||
_pendingOutputChunks.clear();
|
||||
|
||||
@@ -62,13 +62,17 @@ class TerminalMouseDragReporter {
|
||||
var _ownsControllerSuspension = false;
|
||||
var _releasePending = false;
|
||||
var _reporting = false;
|
||||
var _dragged = false;
|
||||
|
||||
bool handleDown(
|
||||
PointerDownEvent event,
|
||||
Terminal terminal,
|
||||
TerminalViewState? terminalView,
|
||||
) {
|
||||
if (!_isPrimaryMouse(event) || !_reportsDrag(terminal.mouseMode)) {
|
||||
TerminalViewState? terminalView, {
|
||||
bool reportTouchInput = false,
|
||||
bool deferReport = false,
|
||||
}) {
|
||||
if (!_isPrimaryPointer(event, reportTouchInput) ||
|
||||
!_reportsDrag(terminal.mouseMode)) {
|
||||
return false;
|
||||
}
|
||||
if (terminalView == null || terminalView.widget.readOnly) return false;
|
||||
@@ -83,14 +87,33 @@ class TerminalMouseDragReporter {
|
||||
_pointerId = event.pointer;
|
||||
_controller = controller;
|
||||
_ownsControllerSuspension = true;
|
||||
_releasePending = true;
|
||||
_reporting = true;
|
||||
_releasePending = !deferReport;
|
||||
_reporting = !deferReport;
|
||||
_dragged = false;
|
||||
controller.setSuspendPointerInput(true);
|
||||
_clearSelection(controller);
|
||||
final position = _cellAt(event, terminalView);
|
||||
_lastReportedPosition = position;
|
||||
if (!deferReport) {
|
||||
terminal.textInput(
|
||||
_report(terminal.mouseReportMode, position),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool activateDeferredDown(Terminal terminal) {
|
||||
if (_pointerId == null ||
|
||||
_controller == null ||
|
||||
_releasePending ||
|
||||
!_reportsDrag(terminal.mouseMode)) {
|
||||
return false;
|
||||
}
|
||||
_releasePending = true;
|
||||
_reporting = true;
|
||||
_clearSelection(_controller);
|
||||
terminal.textInput(
|
||||
_report(terminal.mouseReportMode, position),
|
||||
_report(terminal.mouseReportMode, _lastReportedPosition),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -98,26 +121,36 @@ class TerminalMouseDragReporter {
|
||||
bool handleMove(
|
||||
PointerMoveEvent event,
|
||||
Terminal terminal,
|
||||
TerminalViewState? terminalView,
|
||||
) {
|
||||
TerminalViewState? terminalView, {
|
||||
void Function(bool dragged)? beforeRelease,
|
||||
void Function()? onCancel,
|
||||
}) {
|
||||
if (event.pointer != _pointerId) return false;
|
||||
if (terminalView == null) {
|
||||
onCancel?.call();
|
||||
cancel();
|
||||
return true;
|
||||
}
|
||||
final reportsDrag = _reportsDrag(terminal.mouseMode);
|
||||
if (!_isPrimaryMouse(event)) {
|
||||
if (!_hasPrimaryButton(event)) {
|
||||
if (_releasePending && reportsDrag) {
|
||||
_reportRelease(
|
||||
_finishRelease(
|
||||
event,
|
||||
terminal,
|
||||
_reporting ? _cellAt(event, terminalView) : _lastReportedPosition,
|
||||
terminalView,
|
||||
beforeRelease: beforeRelease,
|
||||
);
|
||||
} else {
|
||||
onCancel?.call();
|
||||
}
|
||||
cancel();
|
||||
return true;
|
||||
}
|
||||
if (!_reporting || !reportsDrag) {
|
||||
if (!reportsDrag) _releasePending = false;
|
||||
if (!reportsDrag && _releasePending) {
|
||||
_releasePending = false;
|
||||
onCancel?.call();
|
||||
}
|
||||
_reporting = false;
|
||||
// Keep ownership until the matching end event to suppress local selection.
|
||||
final controller = _controller;
|
||||
@@ -126,7 +159,7 @@ class TerminalMouseDragReporter {
|
||||
}
|
||||
|
||||
final position = _cellAt(event, terminalView);
|
||||
_lastReportedPosition = position;
|
||||
_recordPosition(position);
|
||||
terminal.textInput(
|
||||
_report(terminal.mouseReportMode, position, motion: true),
|
||||
);
|
||||
@@ -138,16 +171,22 @@ class TerminalMouseDragReporter {
|
||||
bool handleEnd(
|
||||
PointerEvent event,
|
||||
Terminal terminal,
|
||||
TerminalViewState? terminalView,
|
||||
) {
|
||||
TerminalViewState? terminalView, {
|
||||
void Function(bool dragged)? beforeRelease,
|
||||
void Function()? onCancel,
|
||||
}) {
|
||||
if (event.pointer != _pointerId) return false;
|
||||
if (terminalView != null &&
|
||||
_releasePending &&
|
||||
_reportsDrag(terminal.mouseMode)) {
|
||||
_reportRelease(
|
||||
_finishRelease(
|
||||
event,
|
||||
terminal,
|
||||
_reporting ? _cellAt(event, terminalView) : _lastReportedPosition,
|
||||
terminalView,
|
||||
beforeRelease: beforeRelease,
|
||||
);
|
||||
} else {
|
||||
onCancel?.call();
|
||||
}
|
||||
_clearSelection(_controller);
|
||||
final controller = _controller;
|
||||
@@ -172,6 +211,7 @@ class TerminalMouseDragReporter {
|
||||
_ownsControllerSuspension = false;
|
||||
_releasePending = false;
|
||||
_reporting = false;
|
||||
_dragged = false;
|
||||
}
|
||||
|
||||
void updateController(TerminalController controller) {
|
||||
@@ -203,6 +243,24 @@ class TerminalMouseDragReporter {
|
||||
);
|
||||
}
|
||||
|
||||
void _finishRelease(
|
||||
PointerEvent event,
|
||||
Terminal terminal,
|
||||
TerminalViewState terminalView, {
|
||||
void Function(bool dragged)? beforeRelease,
|
||||
}) {
|
||||
final position =
|
||||
_reporting ? _cellAt(event, terminalView) : _lastReportedPosition;
|
||||
if (_reporting) _recordPosition(position);
|
||||
beforeRelease?.call(_dragged);
|
||||
_reportRelease(terminal, position);
|
||||
}
|
||||
|
||||
void _recordPosition(CellOffset position) {
|
||||
_dragged = _dragged || position != _lastReportedPosition;
|
||||
_lastReportedPosition = position;
|
||||
}
|
||||
|
||||
CellOffset _cellAt(PointerEvent event, TerminalViewState terminalView) {
|
||||
final renderTerminal = terminalView.renderTerminal;
|
||||
return renderTerminal.getCellOffset(
|
||||
@@ -210,9 +268,13 @@ class TerminalMouseDragReporter {
|
||||
);
|
||||
}
|
||||
|
||||
bool _isPrimaryMouse(PointerEvent event) =>
|
||||
event.kind == PointerDeviceKind.mouse &&
|
||||
(event.buttons & kPrimaryMouseButton) == kPrimaryMouseButton;
|
||||
bool _isPrimaryPointer(PointerEvent event, bool reportTouchInput) =>
|
||||
(event.kind == PointerDeviceKind.mouse ||
|
||||
reportTouchInput && event.kind == PointerDeviceKind.touch) &&
|
||||
_hasPrimaryButton(event);
|
||||
|
||||
bool _hasPrimaryButton(PointerEvent event) =>
|
||||
(event.buttons & kPrimaryButton) == kPrimaryButton;
|
||||
|
||||
bool _reportsDrag(MouseMode mode) =>
|
||||
mode == MouseMode.upDownScrollDrag || mode == MouseMode.upDownScrollMove;
|
||||
|
||||
@@ -1,45 +1,17 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
import 'platform_model.dart';
|
||||
import 'rustdesk_terminal.dart';
|
||||
import 'terminal_copy_shortcut.dart';
|
||||
import 'terminal_mouse_drag_reporter.dart';
|
||||
|
||||
/// xterm 4.0.0 encodes wheel buttons as 68..71; the extra bit reads as a Shift
|
||||
/// modifier, so strict full-screen apps ignore the report and never scroll.
|
||||
/// Upstream fix: TerminalStudio/xterm.dart#238.
|
||||
class WheelButtonFixMouseHandler implements TerminalMouseHandler {
|
||||
const WheelButtonFixMouseHandler({this.positionProvider});
|
||||
|
||||
final CellOffset? Function()? positionProvider;
|
||||
|
||||
@override
|
||||
String? call(TerminalMouseEvent event) {
|
||||
if (!event.button.isWheel) {
|
||||
return defaultMouseHandler(event);
|
||||
}
|
||||
// Same gate as UpDownMouseHandler: only the scroll modes report a wheel,
|
||||
// and a wheel release is never reported, so the report is always a press.
|
||||
if (!event.state.mouseMode.reportScroll ||
|
||||
event.buttonState == TerminalMouseButtonState.up) {
|
||||
return null;
|
||||
}
|
||||
return _reportWheel(event);
|
||||
}
|
||||
|
||||
String _reportWheel(TerminalMouseEvent event) {
|
||||
// Wheel buttons 4..7 go on the wire as 64..67, but `id` is 64 + 4..7.
|
||||
final button = event.button.id - 4;
|
||||
final position = positionProvider?.call() ?? event.position;
|
||||
return encodeTerminalMouseReport(
|
||||
event.state.mouseReportMode,
|
||||
button,
|
||||
position,
|
||||
);
|
||||
}
|
||||
}
|
||||
part 'terminal_mouse_handler_input.dart';
|
||||
part 'terminal_web_clipboard_gesture.dart';
|
||||
|
||||
class TerminalMouseInteraction extends StatefulWidget {
|
||||
const TerminalMouseInteraction(
|
||||
@@ -47,6 +19,12 @@ class TerminalMouseInteraction extends StatefulWidget {
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.focusNode,
|
||||
this.autofocus = false,
|
||||
this.textStyle = const TerminalStyle(),
|
||||
this.deleteDetection = false,
|
||||
this.reportTouchInput = false,
|
||||
this.shortcuts,
|
||||
this.onKeyEvent,
|
||||
this.backgroundOpacity = 1,
|
||||
this.padding,
|
||||
this.onSecondaryTapDown,
|
||||
@@ -55,6 +33,12 @@ class TerminalMouseInteraction extends StatefulWidget {
|
||||
final Terminal terminal;
|
||||
final TerminalController controller;
|
||||
final FocusNode? focusNode;
|
||||
final bool autofocus;
|
||||
final TerminalStyle textStyle;
|
||||
final bool deleteDetection;
|
||||
final bool reportTouchInput;
|
||||
final Map<ShortcutActivator, Intent>? shortcuts;
|
||||
final FocusOnKeyEventCallback? onKeyEvent;
|
||||
final double backgroundOpacity;
|
||||
final EdgeInsets? padding;
|
||||
final void Function(TapDownDetails, CellOffset)? onSecondaryTapDown;
|
||||
@@ -81,8 +65,13 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
|
||||
Buffer? _selectionBuffer;
|
||||
int? _selectionPointerId;
|
||||
Timer? _selectionScrollTimer;
|
||||
Timer? _pendingTouchMouseTimer;
|
||||
PointerDownEvent? _pendingTouchMouseDown;
|
||||
var _selectionHasScrolled = false;
|
||||
var _scrollDirection = _noScroll;
|
||||
// xterm can finish its tap callbacks after the raw drag was reported.
|
||||
var _suppressXtermLeftButton = false;
|
||||
var _terminalClipboardGesturePrepared = false;
|
||||
TerminalViewState? get _terminalView => _terminalViewKey.currentState;
|
||||
|
||||
@override
|
||||
@@ -90,6 +79,7 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
|
||||
super.initState();
|
||||
_mouseHandler = WheelButtonFixMouseHandler(
|
||||
positionProvider: _cellAtPointer,
|
||||
suppressLeftButton: kIsWeb ? _consumeXtermLeftButtonSuppression : null,
|
||||
);
|
||||
_installMouseHandler(widget.terminal);
|
||||
}
|
||||
@@ -100,10 +90,15 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
|
||||
final terminalChanged = !identical(oldWidget.terminal, widget.terminal);
|
||||
final controllerChanged =
|
||||
!identical(oldWidget.controller, widget.controller);
|
||||
final touchInputChanged =
|
||||
oldWidget.reportTouchInput != widget.reportTouchInput;
|
||||
if (!terminalChanged && !controllerChanged && !touchInputChanged) return;
|
||||
_cancelPendingTouchMouseDrag();
|
||||
if (!terminalChanged && !controllerChanged) return;
|
||||
if (controllerChanged && !terminalChanged) {
|
||||
_mouseDrag.updateController(widget.controller);
|
||||
} else {
|
||||
_discardPendingTerminalClipboardWrites();
|
||||
_mouseDrag.cancel();
|
||||
}
|
||||
_clearSelectionDrag();
|
||||
@@ -123,46 +118,18 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
|
||||
}
|
||||
}
|
||||
|
||||
CellOffset? _cellAtPointer() {
|
||||
final terminalView = _terminalView;
|
||||
final pointerPosition = _pointerPosition;
|
||||
if (terminalView == null || pointerPosition == null) return null;
|
||||
final renderTerminal = terminalView.renderTerminal;
|
||||
return renderTerminal.getCellOffset(
|
||||
renderTerminal.globalToLocal(pointerPosition),
|
||||
);
|
||||
}
|
||||
|
||||
void _updatePointerPosition(PointerEvent event) =>
|
||||
_pointerPosition = event.position;
|
||||
|
||||
void _handlePointerDown(PointerDownEvent event) {
|
||||
_updatePointerPosition(event);
|
||||
if (_mouseDrag.handleDown(event, widget.terminal, _terminalView)) {
|
||||
_clearSelectionDrag();
|
||||
return;
|
||||
}
|
||||
if (event.kind != PointerDeviceKind.mouse ||
|
||||
(event.buttons & kPrimaryMouseButton) != kPrimaryMouseButton) {
|
||||
return;
|
||||
}
|
||||
_clearSelectionDrag();
|
||||
final terminalView = _terminalView;
|
||||
if (terminalView == null) return;
|
||||
final renderTerminal = terminalView.renderTerminal;
|
||||
final localPosition = renderTerminal.globalToLocal(event.position);
|
||||
final selectionBuffer = widget.terminal.buffer;
|
||||
_selectionPointerId = event.pointer;
|
||||
_selectionBase = selectionBuffer.createAnchorFromOffset(
|
||||
renderTerminal.getCellOffset(localPosition),
|
||||
);
|
||||
_selectionBuffer = selectionBuffer;
|
||||
_selectionPointer = localPosition;
|
||||
}
|
||||
|
||||
void _handlePointerMove(PointerMoveEvent event) {
|
||||
_updatePointerPosition(event);
|
||||
if (_mouseDrag.handleMove(event, widget.terminal, _terminalView)) return;
|
||||
if (_handlePendingTouchMove(event)) return;
|
||||
if (_mouseDrag.handleMove(
|
||||
event,
|
||||
widget.terminal,
|
||||
_terminalView,
|
||||
beforeRelease: _finishTerminalClipboardWrite,
|
||||
onCancel: _cancelTerminalClipboardWrite,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
if (event.pointer != _selectionPointerId) return;
|
||||
if (event.kind != PointerDeviceKind.mouse ||
|
||||
(event.buttons & kPrimaryMouseButton) != kPrimaryMouseButton) {
|
||||
@@ -241,8 +208,28 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
|
||||
|
||||
void _handlePointerEnd(PointerEvent event) {
|
||||
_updatePointerPosition(event);
|
||||
if (!_mouseDrag.handleEnd(event, widget.terminal, _terminalView) &&
|
||||
event.pointer != _selectionPointerId) return;
|
||||
final pendingTouch = _pendingTouchMouseDown;
|
||||
if (pendingTouch != null && pendingTouch.pointer == event.pointer) {
|
||||
final movedBeyondSlop =
|
||||
(event.position - pendingTouch.position).distance > kTouchSlop;
|
||||
if (event is PointerUpEvent && !movedBeyondSlop) {
|
||||
_activatePendingTouchMouseDrag(cancelOnFailure: false);
|
||||
} else {
|
||||
_takePendingTouchMouseDrag(pointer: event.pointer);
|
||||
}
|
||||
}
|
||||
final handledByMouseDrag = _mouseDrag.handleEnd(
|
||||
event,
|
||||
widget.terminal,
|
||||
_terminalView,
|
||||
beforeRelease: event is PointerUpEvent
|
||||
? _finishTerminalClipboardWrite
|
||||
: (_) => _cancelTerminalClipboardWrite(),
|
||||
onCancel: _cancelTerminalClipboardWrite,
|
||||
);
|
||||
if (!handledByMouseDrag && event.pointer != _selectionPointerId) {
|
||||
return;
|
||||
}
|
||||
if (_selectionHasScrolled) _scrollSelection(scroll: false);
|
||||
_clearSelectionDrag();
|
||||
}
|
||||
@@ -265,6 +252,8 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_discardPendingTerminalClipboardWrites();
|
||||
_cancelPendingTouchMouseDrag();
|
||||
_mouseDrag.cancel();
|
||||
_clearSelectionDrag();
|
||||
_restoreMouseHandler(widget.terminal);
|
||||
@@ -290,10 +279,14 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
|
||||
controller: widget.controller,
|
||||
scrollController: _scrollController,
|
||||
focusNode: widget.focusNode,
|
||||
autofocus: widget.autofocus,
|
||||
textStyle: widget.textStyle,
|
||||
deleteDetection: widget.deleteDetection,
|
||||
backgroundOpacity: widget.backgroundOpacity,
|
||||
padding: widget.padding,
|
||||
shortcuts: platformTerminalShortcuts(),
|
||||
onKeyEvent: terminalCopyHandler(widget.terminal, widget.controller),
|
||||
shortcuts: widget.shortcuts ?? platformTerminalShortcuts(),
|
||||
onKeyEvent: widget.onKeyEvent ??
|
||||
terminalCopyHandler(widget.terminal, widget.controller),
|
||||
onSecondaryTapDown: widget.onSecondaryTapDown,
|
||||
),
|
||||
);
|
||||
|
||||
162
flutter/lib/models/terminal_mouse_handler_input.dart
Normal file
162
flutter/lib/models/terminal_mouse_handler_input.dart
Normal file
@@ -0,0 +1,162 @@
|
||||
part of 'terminal_mouse_handler.dart';
|
||||
|
||||
/// xterm 4.0.0 encodes wheel buttons as 68..71; the extra bit reads as a Shift
|
||||
/// modifier, so strict full-screen apps ignore the report and never scroll.
|
||||
/// Upstream fix: TerminalStudio/xterm.dart#238.
|
||||
class WheelButtonFixMouseHandler implements TerminalMouseHandler {
|
||||
const WheelButtonFixMouseHandler({
|
||||
this.positionProvider,
|
||||
this.suppressLeftButton,
|
||||
});
|
||||
|
||||
final CellOffset? Function()? positionProvider;
|
||||
final bool Function(TerminalMouseButtonState)? suppressLeftButton;
|
||||
|
||||
@override
|
||||
String? call(TerminalMouseEvent event) {
|
||||
if (!event.button.isWheel) {
|
||||
if (event.button == TerminalMouseButton.left &&
|
||||
suppressLeftButton?.call(event.buttonState) == true) {
|
||||
return null;
|
||||
}
|
||||
return defaultMouseHandler(event);
|
||||
}
|
||||
// Same gate as UpDownMouseHandler: only the scroll modes report a wheel,
|
||||
// and a wheel release is never reported, so the report is always a press.
|
||||
if (!event.state.mouseMode.reportScroll ||
|
||||
event.buttonState == TerminalMouseButtonState.up) {
|
||||
return null;
|
||||
}
|
||||
return _reportWheel(event);
|
||||
}
|
||||
|
||||
String _reportWheel(TerminalMouseEvent event) {
|
||||
// Wheel buttons 4..7 go on the wire as 64..67, but `id` is 64 + 4..7.
|
||||
final button = event.button.id - 4;
|
||||
final position = positionProvider?.call() ?? event.position;
|
||||
return encodeTerminalMouseReport(
|
||||
event.state.mouseReportMode,
|
||||
button,
|
||||
position,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension _TerminalMouseInput on _TerminalMouseInteractionState {
|
||||
CellOffset? _cellAtPointer() {
|
||||
final terminalView = _terminalView;
|
||||
final pointerPosition = _pointerPosition;
|
||||
if (terminalView == null || pointerPosition == null) return null;
|
||||
final renderTerminal = terminalView.renderTerminal;
|
||||
return renderTerminal.getCellOffset(
|
||||
renderTerminal.globalToLocal(pointerPosition),
|
||||
);
|
||||
}
|
||||
|
||||
void _updatePointerPosition(PointerEvent event) =>
|
||||
_pointerPosition = event.position;
|
||||
|
||||
void _handlePointerDown(PointerDownEvent event) {
|
||||
_updatePointerPosition(event);
|
||||
_suppressXtermLeftButton = false;
|
||||
if (_startPendingTouchMouseDrag(event)) return;
|
||||
if (_mouseDrag.handleDown(event, widget.terminal, _terminalView)) {
|
||||
_prepareTerminalClipboardWrite();
|
||||
if (kIsWeb) _suppressXtermLeftButton = true;
|
||||
_clearSelectionDrag();
|
||||
return;
|
||||
}
|
||||
if (event.kind != PointerDeviceKind.mouse ||
|
||||
(event.buttons & kPrimaryMouseButton) != kPrimaryMouseButton) {
|
||||
return;
|
||||
}
|
||||
_clearSelectionDrag();
|
||||
final terminalView = _terminalView;
|
||||
if (terminalView == null) return;
|
||||
final renderTerminal = terminalView.renderTerminal;
|
||||
final localPosition = renderTerminal.globalToLocal(event.position);
|
||||
final selectionBuffer = widget.terminal.buffer;
|
||||
_selectionPointerId = event.pointer;
|
||||
_selectionBase = selectionBuffer.createAnchorFromOffset(
|
||||
renderTerminal.getCellOffset(localPosition),
|
||||
);
|
||||
_selectionBuffer = selectionBuffer;
|
||||
_selectionPointer = localPosition;
|
||||
}
|
||||
|
||||
bool _startPendingTouchMouseDrag(PointerDownEvent event) {
|
||||
if (!widget.reportTouchInput ||
|
||||
event.kind != PointerDeviceKind.touch ||
|
||||
!_mouseDrag.handleDown(
|
||||
event,
|
||||
widget.terminal,
|
||||
_terminalView,
|
||||
reportTouchInput: true,
|
||||
deferReport: true,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
_pendingTouchMouseDown = event;
|
||||
_pendingTouchMouseTimer = Timer(
|
||||
kLongPressTimeout,
|
||||
_activatePendingTouchMouseDrag,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _activatePendingTouchMouseDrag({
|
||||
bool cancelOnFailure = true,
|
||||
}) {
|
||||
if (_takePendingTouchMouseDrag() == null) return false;
|
||||
if (_mouseDrag.activateDeferredDown(widget.terminal)) {
|
||||
_prepareTerminalClipboardWrite();
|
||||
_clearSelectionDrag();
|
||||
return true;
|
||||
}
|
||||
if (cancelOnFailure) _mouseDrag.cancel();
|
||||
return false;
|
||||
}
|
||||
|
||||
PointerDownEvent? _takePendingTouchMouseDrag({int? pointer}) {
|
||||
final pending = _pendingTouchMouseDown;
|
||||
if (pending == null || pointer != null && pointer != pending.pointer) {
|
||||
return null;
|
||||
}
|
||||
_pendingTouchMouseTimer?.cancel();
|
||||
_pendingTouchMouseTimer = null;
|
||||
_pendingTouchMouseDown = null;
|
||||
return pending;
|
||||
}
|
||||
|
||||
void _cancelPendingTouchMouseDrag({
|
||||
int? pointer,
|
||||
bool deferCancel = false,
|
||||
}) {
|
||||
if (_takePendingTouchMouseDrag(pointer: pointer) == null) return;
|
||||
if (deferCancel) {
|
||||
scheduleMicrotask(_mouseDrag.cancel);
|
||||
} else {
|
||||
_mouseDrag.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
bool _handlePendingTouchMove(PointerMoveEvent event) {
|
||||
final pending = _pendingTouchMouseDown;
|
||||
if (pending == null || pending.pointer != event.pointer) return false;
|
||||
if ((event.position - pending.position).distance > kTouchSlop) {
|
||||
_cancelPendingTouchMouseDrag(
|
||||
pointer: event.pointer,
|
||||
deferCancel: true,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _consumeXtermLeftButtonSuppression(TerminalMouseButtonState state) {
|
||||
final suppress = _suppressXtermLeftButton;
|
||||
if (state == TerminalMouseButtonState.up) {
|
||||
_suppressXtermLeftButton = false;
|
||||
}
|
||||
return suppress;
|
||||
}
|
||||
}
|
||||
56
flutter/lib/models/terminal_web_clipboard_gesture.dart
Normal file
56
flutter/lib/models/terminal_web_clipboard_gesture.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
part of 'terminal_mouse_handler.dart';
|
||||
|
||||
const _prepareTerminalClipboardCommand = 'prepare_terminal_clipboard';
|
||||
const _finishTerminalClipboardCommand = 'finish_terminal_clipboard';
|
||||
const _cancelTerminalClipboardCommand = 'cancel_terminal_clipboard';
|
||||
|
||||
extension _TerminalWebClipboardGesture on _TerminalMouseInteractionState {
|
||||
void _prepareTerminalClipboardWrite() {
|
||||
if (!kIsWeb) return;
|
||||
_cancelTerminalClipboardWrite();
|
||||
final terminal = widget.terminal;
|
||||
if (terminal is! RustDeskTerminal || !terminal.isClipboardWriteAllowed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ffiSetByName(_prepareTerminalClipboardCommand);
|
||||
_terminalClipboardGesturePrepared = true;
|
||||
} catch (error) {
|
||||
debugPrint('[Terminal] Failed to prepare Web clipboard write: $error');
|
||||
}
|
||||
}
|
||||
|
||||
void _finishTerminalClipboardWrite(bool responseExpected) {
|
||||
if (!_terminalClipboardGesturePrepared) return;
|
||||
_terminalClipboardGesturePrepared = false;
|
||||
if (!kIsWeb) return;
|
||||
try {
|
||||
ffiSetByName(
|
||||
_finishTerminalClipboardCommand,
|
||||
responseExpected ? 'true' : 'false',
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint('[Terminal] Failed to finish Web clipboard write: $error');
|
||||
}
|
||||
}
|
||||
|
||||
void _cancelTerminalClipboardWrite() {
|
||||
if (!_terminalClipboardGesturePrepared) return;
|
||||
_terminalClipboardGesturePrepared = false;
|
||||
_sendTerminalClipboardCancel();
|
||||
}
|
||||
|
||||
void _discardPendingTerminalClipboardWrites() {
|
||||
_cancelTerminalClipboardWrite();
|
||||
_sendTerminalClipboardCancel();
|
||||
}
|
||||
|
||||
void _sendTerminalClipboardCancel() {
|
||||
if (!kIsWeb) return;
|
||||
try {
|
||||
ffiSetByName(_cancelTerminalClipboardCommand);
|
||||
} catch (error) {
|
||||
debugPrint('[Terminal] Failed to cancel Web clipboard write: $error');
|
||||
}
|
||||
}
|
||||
}
|
||||
Submodule libs/hbb_common updated: b2b1ac453d...05ed68fed8
@@ -132,7 +132,15 @@ impl Display {
|
||||
.map(Display)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let displays_dxgi = Self::all_().unwrap_or(Default::default());
|
||||
let mut displays_dxgi = match Self::all_() {
|
||||
Ok(displays) => displays,
|
||||
Err(e) => {
|
||||
hbb_common::log::error!("DXGI display enumeration failed: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
// Win+P "Show only on 1/2" still enumerates detached DXGI outputs.
|
||||
displays_dxgi.retain(|d| d.is_online() && d.width() > 0 && d.height() > 0);
|
||||
|
||||
// Return gdi displays if dxgi is not supported
|
||||
if displays_dxgi.is_empty() {
|
||||
@@ -155,7 +163,6 @@ impl Display {
|
||||
}
|
||||
|
||||
// Reorder displays from dxgi
|
||||
let mut displays_dxgi = displays_dxgi;
|
||||
let mut displays_dxgi_ordered = Vec::new();
|
||||
for name in names_gdi.iter() {
|
||||
let pos = match displays_dxgi.iter().position(|d| d.name() == *name) {
|
||||
@@ -176,11 +183,11 @@ impl Display {
|
||||
}
|
||||
|
||||
pub fn width(&self) -> usize {
|
||||
self.0.width() as usize
|
||||
self.0.width().max(0) as usize
|
||||
}
|
||||
|
||||
pub fn height(&self) -> usize {
|
||||
self.0.height() as usize
|
||||
self.0.height().max(0) as usize
|
||||
}
|
||||
|
||||
pub fn name(&self) -> String {
|
||||
@@ -201,7 +208,8 @@ impl Display {
|
||||
|
||||
pub fn is_primary(&self) -> bool {
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-devmodea
|
||||
self.origin() == (0, 0)
|
||||
// Detached outputs can still report origin (0,0) with a zero size.
|
||||
self.origin() == (0, 0) && self.width() > 0 && self.height() > 0
|
||||
}
|
||||
|
||||
#[cfg(feature = "vram")]
|
||||
|
||||
@@ -297,6 +297,30 @@ pub fn clear_wayland_displays_cache() {
|
||||
// capturer rebuild loop clears about once a second.
|
||||
}
|
||||
|
||||
// Bumped ONLY by the layout-drift edge in display_service (its single owner), never by cache
|
||||
// clears: session inits and hotplug workers clear the cache too, and a bump there tears down
|
||||
// every OTHER live capturer on a multi-display session. A capturer records this at build and
|
||||
// treats a later bump as "the layout changed under me, rebuild" — the only trigger a rotation
|
||||
// has, since it changes neither the CRTC mode nor the framebuffer size (rustdesk#15886).
|
||||
static SNAPSHOT_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// Whether no snapshot has been cached: the signature of an enumeration that failed at session
|
||||
/// build (an `Err` is deliberately not cached), as opposed to a session that started healthy.
|
||||
#[cfg(feature = "drm")]
|
||||
pub fn wayland_snapshot_missing() -> bool {
|
||||
DISPLAYS.lock().unwrap().is_none()
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "drm"))]
|
||||
pub fn bump_layout_generation() {
|
||||
SNAPSHOT_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
|
||||
#[cfg(feature = "drm")]
|
||||
pub fn wayland_snapshot_generation() -> u64 {
|
||||
SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire)
|
||||
}
|
||||
|
||||
// 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();
|
||||
@@ -332,7 +356,8 @@ fn desktop_rect_of(displays: &[WaylandDisplayInfo]) -> Option<(i32, i32, i32, i3
|
||||
// Otherwise, we use the logical size for `uinput`.
|
||||
if displays.len() == 1 {
|
||||
let d = &displays[0];
|
||||
return Some((d.x, d.x + d.width, d.y, d.y + d.height));
|
||||
let (w, h) = oriented_physical(d);
|
||||
return Some((d.x, d.x + w, d.y, d.y + h));
|
||||
}
|
||||
|
||||
let mut min_x = i32::MAX;
|
||||
@@ -344,6 +369,8 @@ fn desktop_rect_of(displays: &[WaylandDisplayInfo]) -> Option<(i32, i32, i32, i3
|
||||
min_y = min_y.min(d.y);
|
||||
let size = if let Some(logical_size) = d.logical_size {
|
||||
logical_size
|
||||
} else if d.transform == 90 || d.transform == 270 {
|
||||
oriented_physical(d)
|
||||
} else {
|
||||
// When `logical_size` is None, we cannot obtain the correct desktop rectangle.
|
||||
// This may occur if the Wayland compositor does not provide logical size information,
|
||||
@@ -374,6 +401,24 @@ pub struct DisplayRect {
|
||||
pub y: i32,
|
||||
pub w: i32,
|
||||
pub h: i32,
|
||||
// Carried so the drift comparison sees 0<->180 and 90<->270 flips, whose rects are
|
||||
// otherwise identical; the remap itself matches by name and containment, never by this.
|
||||
pub transform: i32,
|
||||
}
|
||||
|
||||
/// Physical size in delivered orientation: a 90/270 output scans out WxH but is captured,
|
||||
/// advertised and pointed at as HxW.
|
||||
fn oriented_physical(d: &WaylandDisplayInfo) -> (i32, i32) {
|
||||
if d.transform == 90 || d.transform == 270 {
|
||||
(d.height, d.width)
|
||||
} else {
|
||||
(d.width, d.height)
|
||||
}
|
||||
}
|
||||
|
||||
/// The logical rectangles of a display list, for a caller that already has the list.
|
||||
pub fn logical_rects_of_displays(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
|
||||
logical_rects_of(displays)
|
||||
}
|
||||
|
||||
fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
|
||||
@@ -386,9 +431,9 @@ fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
|
||||
.iter()
|
||||
.map(|d| {
|
||||
let (w, h) = if single {
|
||||
(d.width, d.height)
|
||||
oriented_physical(d)
|
||||
} else {
|
||||
d.logical_size.unwrap_or((d.width, d.height))
|
||||
d.logical_size.unwrap_or_else(|| oriented_physical(d))
|
||||
};
|
||||
DisplayRect {
|
||||
name: d.name.clone(),
|
||||
@@ -396,6 +441,7 @@ fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
|
||||
y: d.y,
|
||||
w,
|
||||
h,
|
||||
transform: d.transform,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -495,8 +541,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_clear_keeps_the_failure_stamp() {
|
||||
// The stamp describes the seat, not the cache: the ~1/s capturer rebuild loop clears,
|
||||
// and dropping the stamp with it would defeat the backoff. Sole test touching these
|
||||
// statics; serialize before adding another.
|
||||
// and dropping the stamp with it would defeat the backoff. The generation test also
|
||||
// calls clear now; both only assert monotonic/unchanged state, so they can interleave.
|
||||
*LAST_FAILED_LOOKUP.lock().unwrap() = Some(Instant::now());
|
||||
clear_wayland_displays_cache();
|
||||
let stamp = *LAST_FAILED_LOOKUP.lock().unwrap();
|
||||
@@ -519,6 +565,7 @@ mod tests {
|
||||
height,
|
||||
logical_size,
|
||||
refresh_rate: 60,
|
||||
transform: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -553,6 +600,42 @@ mod tests {
|
||||
assert_eq!(desktop_rect_of(&displays), Some((0, 5120, 0, 1440)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_rotated_display_swaps_the_uinput_rect() {
|
||||
// Review finding 1 on rustdesk#15889: the single-display branch served the unrotated
|
||||
// mode, so the pointer could not reach ~44% of a portrait screen.
|
||||
let mut d = display(0, 0, 1920, 1080, None);
|
||||
d.transform = 90;
|
||||
assert_eq!(desktop_rect_of(&[d.clone()]), Some((0, 1080, 0, 1920)));
|
||||
let rects = logical_rects_of(&[d]);
|
||||
assert_eq!((rects[0].w, rects[0].h), (1080, 1920));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transform_flip_is_visible_to_the_drift_comparison() {
|
||||
// Review finding 5: 0<->180 and 90<->270 leave every rect identical; the transform
|
||||
// field is what lets `baseline != live` fire on them.
|
||||
let mut a = display(0, 0, 1920, 1080, Some((1920, 1080)));
|
||||
let mut b = a.clone();
|
||||
a.transform = 90;
|
||||
b.transform = 270;
|
||||
assert_ne!(logical_rects_of(&[a.clone(), a.clone()]), logical_rects_of(&[b.clone(), b]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_explicit_bump_moves_the_generation() {
|
||||
// A cache clear must NOT bump: session inits clear too, and a bump there rebuilds
|
||||
// every other live capturer (adversarial finding on the first version of this).
|
||||
let before = SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire);
|
||||
clear_wayland_displays_cache();
|
||||
assert_eq!(
|
||||
SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire),
|
||||
before
|
||||
);
|
||||
bump_layout_generation();
|
||||
assert!(SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire) > before);
|
||||
}
|
||||
|
||||
fn rect(name: &str, x: i32, y: i32, w: i32, h: i32) -> DisplayRect {
|
||||
DisplayRect {
|
||||
name: name.to_owned(),
|
||||
@@ -560,6 +643,7 @@ mod tests {
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
transform: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ if(VCPKG_HOST_IS_WINDOWS)
|
||||
vcpkg_acquire_msys(MSYS_ROOT PACKAGES automake1.16)
|
||||
set(SHELL "${MSYS_ROOT}/usr/bin/bash.exe")
|
||||
vcpkg_add_to_path("${MSYS_ROOT}/usr/share/automake-1.16")
|
||||
string(APPEND OPTIONS " --pkg-config=${CURRENT_HOST_INSTALLED_DIR}/tools/pkgconf/pkgconf${VCPKG_HOST_EXECUTABLE_SUFFIX}")
|
||||
string(APPEND OPTIONS " --pkg-config=${CURRENT_HOST_INSTALLED_DIR}/tools/pkgconf/pkgconf${VCPKG_HOST_EXECUTABLE_SUFFIX} ")
|
||||
else()
|
||||
find_program(SHELL bash)
|
||||
endif()
|
||||
|
||||
@@ -1753,6 +1753,10 @@ pub struct LoginConfigHandler {
|
||||
pub remember: bool,
|
||||
config: PeerConfig,
|
||||
pub port_forward: (String, i32),
|
||||
/// Held by a port-forward mapping from filling `port_forward` and `hash`
|
||||
/// until its login is built from them; a window's mappings log in
|
||||
/// concurrently.
|
||||
pub(crate) port_forward_login_turn: Arc<hbb_common::tokio::sync::Mutex<()>>,
|
||||
pub version: i64,
|
||||
features: Option<Features>,
|
||||
pub session_id: u64, // used for local <-> server communication
|
||||
@@ -1792,6 +1796,10 @@ impl Deref for LoginConfigHandler {
|
||||
}
|
||||
|
||||
impl LoginConfigHandler {
|
||||
pub(crate) fn set_hash(&mut self, hash: Hash) {
|
||||
self.hash = hash;
|
||||
}
|
||||
|
||||
/// Initialize the login config handler.
|
||||
///
|
||||
/// # Arguments
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "قفل اللوحة"),
|
||||
("Sync clipboard between sessions", "مزامنة الحافظة بين الجلسات"),
|
||||
("sync-clipboard-between-sessions-tip", "النص أو الصور المنسوخة في جلسة بعيدة واحدة تُرسَل أيضًا إلى حافظة جلساتك المتصلة الأخرى."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "تفعيل"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Заблакіраваць палатно"),
|
||||
("Sync clipboard between sessions", "Сінхранізаваць буфер абмену паміж сеансамі"),
|
||||
("sync-clipboard-between-sessions-tip", "Тэкст або відарысы, скапіяваныя ў адным аддаленым сеансе, таксама адпраўляюцца ў буфер абмену іншых вашых падключаных сеансаў."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Уключыць"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Заключване на платното"),
|
||||
("Sync clipboard between sessions", "Синхронизиране на клипборда между сесиите"),
|
||||
("sync-clipboard-between-sessions-tip", "Текст или изображения, копирани в една отдалечена сесия, се изпращат и към клипборда на другите ви свързани сесии."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Активирай"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Bloca el llenç"),
|
||||
("Sync clipboard between sessions", "Sincronitza el porta-retalls entre sessions"),
|
||||
("sync-clipboard-between-sessions-tip", "El text o les imatges copiats en una sessió remota també s'envien al porta-retalls de les altres sessions connectades."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Habilita"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "当前不支持多个屏幕的合并截屏,请切换到单个屏幕重试。"),
|
||||
("screenshot-action-tip", "请选择如何继续截屏。"),
|
||||
("Save as", "另存为"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "导出"),
|
||||
("Export Logs", "导出日志"),
|
||||
("Import Folder", "导入文件夹"),
|
||||
("Copy to clipboard", "复制到剪贴板"),
|
||||
("Enable remote printer", "启用远程打印机"),
|
||||
("Downloading {}", "正在下载 {}"),
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "锁定画布"),
|
||||
("Sync clipboard between sessions", "在会话间同步剪贴板"),
|
||||
("sync-clipboard-between-sessions-tip", "在一个远程会话中复制的文本或图片也会发送到其他已连接会话的剪贴板。"),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", "允许终端应用复制到剪贴板"),
|
||||
("Enable", "启用"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Zamknout zobrazení"),
|
||||
("Sync clipboard between sessions", "Synchronizovat schránku mezi relacemi"),
|
||||
("sync-clipboard-between-sessions-tip", "Text nebo obrázky zkopírované v jedné vzdálené relaci se odešlou i do schránky ostatních připojených relací."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Povolit"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Lås lærred"),
|
||||
("Sync clipboard between sessions", "Synkroniser udklipsholder mellem sessioner"),
|
||||
("sync-clipboard-between-sessions-tip", "Tekst eller billeder, der kopieres i én fjernsession, sendes også til udklipsholderen i dine andre forbundne sessioner."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Aktivér"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Sichtfeld sperren"),
|
||||
("Sync clipboard between sessions", "Zwischenablage zwischen Sitzungen synchronisieren"),
|
||||
("sync-clipboard-between-sessions-tip", "In einer Remote-Sitzung kopierter Text oder kopierte Bilder werden auch an die Zwischenablage Ihrer anderen verbundenen Sitzungen gesendet."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Aktivieren"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Κλείδωμα καμβά"),
|
||||
("Sync clipboard between sessions", "Συγχρονισμός προχείρου μεταξύ συνεδριών"),
|
||||
("sync-clipboard-between-sessions-tip", "Κείμενο ή εικόνες που αντιγράφονται σε μία απομακρυσμένη συνεδρία αποστέλλονται και στο πρόχειρο των άλλων συνδεδεμένων συνεδριών σας."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Ενεργοποίηση"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -276,5 +276,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("whitelist_cidr_tip", "CIDR notation is supported, e.g. 192.168.1.0/24"),
|
||||
("Your ip is blocked by the peer", "Your IP is blocked by the peer"),
|
||||
("sync-clipboard-between-sessions-tip", "Text or images copied in one remote session are also sent to the clipboard of your other connected sessions."),
|
||||
("terminal-clipboard-write-tip", "An app in the terminal wants to copy text to this device's clipboard. If granted, this permission applies to terminal apps in all connections until you turn it off in Settings. Manual copy and paste are unaffected."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Ŝlosi kanvason"),
|
||||
("Sync clipboard between sessions", "Sinkronigi poŝon inter seancoj"),
|
||||
("sync-clipboard-between-sessions-tip", "Teksto aŭ bildoj kopiitaj en unu fora seanco ankaŭ sendiĝas al la poŝo de viaj aliaj konektitaj seancoj."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Ebligi"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Bloquear lienzo"),
|
||||
("Sync clipboard between sessions", "Sincronizar portapapeles entre sesiones"),
|
||||
("sync-clipboard-between-sessions-tip", "El texto o las imágenes copiados en una sesión remota también se envían al portapapeles de tus otras sesiones conectadas."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Habilitar"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Lukusta lõuend"),
|
||||
("Sync clipboard between sessions", "Sünkrooni lõikelaud seansside vahel"),
|
||||
("sync-clipboard-between-sessions-tip", "Ühes kaugseansis kopeeritud tekst või pildid saadetakse ka teiste ühendatud seansside lõikelauale."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Luba"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Blokeatu oihala"),
|
||||
("Sync clipboard between sessions", "Sinkronizatu arbela saioen artean"),
|
||||
("sync-clipboard-between-sessions-tip", "Urruneko saio batean kopiatutako testua edo irudiak konektatutako beste saioen arbelera ere bidaltzen dira."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Gaitu"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "قفل کردن صفحه"),
|
||||
("Sync clipboard between sessions", "همگامسازی کلیپبورد بین نشستها"),
|
||||
("sync-clipboard-between-sessions-tip", "متن یا تصاویری که در یک نشست راه دور کپی میشوند به کلیپبورد سایر نشستهای متصل شما نیز ارسال میشوند."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "فعالسازی"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Lukitse näkymä"),
|
||||
("Sync clipboard between sessions", "Synkronoi leikepöytä istuntojen välillä"),
|
||||
("sync-clipboard-between-sessions-tip", "Yhdessä etäistunnossa kopioitu teksti tai kuvat lähetetään myös muiden yhdistettyjen istuntojen leikepöydälle."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Ota käyttöön"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Verrouiller la vue"),
|
||||
("Sync clipboard between sessions", "Synchroniser le presse-papiers entre les sessions"),
|
||||
("sync-clipboard-between-sessions-tip", "Le texte ou les images copiés dans une session distante sont également envoyés au presse-papiers de vos autres sessions connectées."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Activer"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "ტილოს დაბლოკვა"),
|
||||
("Sync clipboard between sessions", "გაცვლის ბუფერის სინქრონიზაცია სესიებს შორის"),
|
||||
("sync-clipboard-between-sessions-tip", "ერთ დაშორებულ სესიაში დაკოპირებული ტექსტი ან სურათები ასევე იგზავნება თქვენი სხვა დაკავშირებული სესიების გაცვლის ბუფერში."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "ჩართვა"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "કેનવાસ લોક કરો"),
|
||||
("Sync clipboard between sessions", "સત્રો વચ્ચે ક્લિપબોર્ડ સિંક કરો"),
|
||||
("sync-clipboard-between-sessions-tip", "એક રિમોટ સત્રમાં કૉપિ કરેલ ટેક્સ્ટ કે છબીઓ તમારા અન્ય જોડાયેલા સત્રોના ક્લિપબોર્ડ પર પણ મોકલવામાં આવે છે."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "સક્ષમ કરો"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "נעל לוח ציור"),
|
||||
("Sync clipboard between sessions", "סנכרן לוח בין סשנים"),
|
||||
("sync-clipboard-between-sessions-tip", "טקסט או תמונות שהועתקו בסשן מרוחק אחד נשלחים גם ללוח של שאר הסשנים המחוברים שלך."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "הפעל"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "कैनवास लॉक करें"),
|
||||
("Sync clipboard between sessions", "सत्रों के बीच क्लिपबोर्ड सिंक करें"),
|
||||
("sync-clipboard-between-sessions-tip", "एक रिमोट सत्र में कॉपी किए गए टेक्स्ट या चित्र आपके अन्य जुड़े सत्रों के क्लिपबोर्ड पर भी भेजे जाते हैं।"),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "सक्षम करें"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Zaključaj pozadinu"),
|
||||
("Sync clipboard between sessions", "Sinkroniziraj međuspremnik između sesija"),
|
||||
("sync-clipboard-between-sessions-tip", "Tekst ili slike kopirani u jednoj udaljenoj sesiji šalju se i u međuspremnik vaših ostalih povezanih sesija."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Omogući"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Nézet zárolása"),
|
||||
("Sync clipboard between sessions", "Vágólap szinkronizálása a munkamenetek között"),
|
||||
("sync-clipboard-between-sessions-tip", "Az egyik távoli munkamenetben másolt szöveg vagy kép a többi csatlakoztatott munkamenet vágólapjára is elküldésre kerül."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Engedélyezés"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Kunci kanvas"),
|
||||
("Sync clipboard between sessions", "Sinkronkan papan klip antar sesi"),
|
||||
("sync-clipboard-between-sessions-tip", "Teks atau gambar yang disalin di satu sesi jarak jauh juga dikirim ke papan klip sesi terhubung Anda yang lain."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Aktifkan"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Blocca tela"),
|
||||
("Sync clipboard between sessions", "Sincronizza gli appunti tra le sessioni"),
|
||||
("sync-clipboard-between-sessions-tip", "Il testo o le immagini copiati in una sessione remota vengono inviati anche agli appunti delle altre sessioni connesse."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Abilita"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "キャンバスをロック"),
|
||||
("Sync clipboard between sessions", "セッション間でクリップボードを同期"),
|
||||
("sync-clipboard-between-sessions-tip", "1つのリモートセッションでコピーしたテキストや画像は、接続中の他のセッションのクリップボードにも送信されます。"),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "有効にする"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "캔버스 잠금"),
|
||||
("Sync clipboard between sessions", "세션 간 클립보드 동기화"),
|
||||
("sync-clipboard-between-sessions-tip", "하나의 원격 세션에서 복사한 텍스트나 이미지는 연결된 다른 세션의 클립보드에도 전송됩니다."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "활성화"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Кенепті құлыптау"),
|
||||
("Sync clipboard between sessions", "Сеанстар арасында көшіру-тақтасын синхрондау"),
|
||||
("sync-clipboard-between-sessions-tip", "Бір қашықтағы сеанста көшірілген мәтін немесе суреттер басқа қосылған сеанстардың көшіру-тақтасына да жіберіледі."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Қосу"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Užrakinti drobę"),
|
||||
("Sync clipboard between sessions", "Sinchronizuoti iškarpinę tarp seansų"),
|
||||
("sync-clipboard-between-sessions-tip", "Viename nuotoliniame seanse nukopijuotas tekstas ar vaizdai taip pat siunčiami į kitų prijungtų seansų iškarpinę."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Įgalinti"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Bloķēt audeklu"),
|
||||
("Sync clipboard between sessions", "Sinhronizēt starpliktuvi starp sesijām"),
|
||||
("sync-clipboard-between-sessions-tip", "Vienā attālajā sesijā nokopētais teksts vai attēli tiek nosūtīti arī uz pārējo pievienoto sesiju starpliktuvi."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Iespējot"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "ക്യാൻവാസ് ലോക്ക് ചെയ്യുക"),
|
||||
("Sync clipboard between sessions", "സെഷനുകൾക്കിടയിൽ ക്ലിപ്പ്ബോർഡ് സമന്വയിപ്പിക്കുക"),
|
||||
("sync-clipboard-between-sessions-tip", "ഒരു റിമോട്ട് സെഷനിൽ പകർത്തിയ ടെക്സ്റ്റോ ചിത്രങ്ങളോ നിങ്ങളുടെ മറ്റ് കണക്റ്റുചെയ്ത സെഷനുകളുടെ ക്ലിപ്പ്ബോർഡിലേക്കും അയയ്ക്കപ്പെടും."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "അനുവദിക്കുക"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Lås lerret"),
|
||||
("Sync clipboard between sessions", "Synkroniser utklippstavlen mellom økter"),
|
||||
("sync-clipboard-between-sessions-tip", "Tekst eller bilder som kopieres i én ekstern økt, sendes også til utklippstavlen i de andre tilkoblede øktene dine."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Aktiver"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Canvas vergrendelen"),
|
||||
("Sync clipboard between sessions", "Klembord synchroniseren tussen sessies"),
|
||||
("sync-clipboard-between-sessions-tip", "Tekst of afbeeldingen die in één externe sessie worden gekopieerd, worden ook naar het klembord van uw andere verbonden sessies gestuurd."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Inschakelen"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Zablokuj ekran"),
|
||||
("Sync clipboard between sessions", "Synchronizuj schowek między sesjami"),
|
||||
("sync-clipboard-between-sessions-tip", "Tekst lub obrazy skopiowane w jednej sesji zdalnej są wysyłane także do schowka pozostałych połączonych sesji."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Włącz"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Bloquear tela"),
|
||||
("Sync clipboard between sessions", "Sincronizar área de transferência entre sessões"),
|
||||
("sync-clipboard-between-sessions-tip", "O texto ou as imagens copiados numa sessão remota também são enviados para a área de transferência das suas outras sessões ligadas."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Ativar"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "A captura de tela de múltiplas telas não é suportada no momento. Por favor, alterne para uma única tela e tente novamente."),
|
||||
("screenshot-action-tip", "Por favor, selecione como deseja continuar com a captura de tela."),
|
||||
("Save as", "Salvar como"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Exportar"),
|
||||
("Export Logs", "Exportar logs"),
|
||||
("Import Folder", "Importar pasta"),
|
||||
("Copy to clipboard", "Copiar para área de transferência"),
|
||||
("Enable remote printer", "Habilitar impressora remota"),
|
||||
("Downloading {}", "Baixando {}"),
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Bloquear tela"),
|
||||
("Sync clipboard between sessions", "Sincronizar área de transferência entre sessões"),
|
||||
("sync-clipboard-between-sessions-tip", "Texto ou imagens copiados em uma sessão remota também são enviados para a área de transferência das suas outras sessões conectadas."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Habilitar"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Blochează ecranul"),
|
||||
("Sync clipboard between sessions", "Sincronizează clipboardul între sesiuni"),
|
||||
("sync-clipboard-between-sessions-tip", "Textul sau imaginile copiate într-o sesiune la distanță sunt trimise și în clipboardul celorlalte sesiuni conectate."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Activează"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Заблокировать холст"),
|
||||
("Sync clipboard between sessions", "Синхронизировать буфер обмена между сеансами"),
|
||||
("sync-clipboard-between-sessions-tip", "Текст или изображения, скопированные в одном удалённом сеансе, также отправляются в буфер обмена других подключённых сеансов."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Включить"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Bloca sa tela"),
|
||||
("Sync clipboard between sessions", "Sincroniza sa punta de billete intre is sessiones"),
|
||||
("sync-clipboard-between-sessions-tip", "Su testu o is immàgines copiadas in una sessione remota sunt imbiadas fintzas a sa punta de billete de is àteras sessiones connètidas."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Abìlita"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Uzamknúť zobrazenie"),
|
||||
("Sync clipboard between sessions", "Synchronizovať schránku medzi reláciami"),
|
||||
("sync-clipboard-between-sessions-tip", "Text alebo obrázky skopírované v jednej vzdialenej relácii sa odošlú aj do schránky ostatných pripojených relácií."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Povoliť"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Zakleni platno"),
|
||||
("Sync clipboard between sessions", "Sinhroniziraj odložišče med sejami"),
|
||||
("sync-clipboard-between-sessions-tip", "Besedilo ali slike, kopirane v eni oddaljeni seji, se pošljejo tudi v odložišče vaših drugih povezanih sej."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Omogoči"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Kyç canvas"),
|
||||
("Sync clipboard between sessions", "Sinkronizo clipboard-in midis sesioneve"),
|
||||
("sync-clipboard-between-sessions-tip", "Teksti ose imazhet e kopjuara në një sesion të largët dërgohen edhe në clipboard-in e sesioneve të tjera të lidhura."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Aktivizo"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Zaključaj pozadinu"),
|
||||
("Sync clipboard between sessions", "Sinhronizuj klipbord između sesija"),
|
||||
("sync-clipboard-between-sessions-tip", "Tekst ili slike kopirane u jednoj udaljenoj sesiji šalju se i u klipbord vaših ostalih povezanih sesija."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Omogući"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Sammanslagning av skärmdumpar från flera skärmar stöds för närvarande inte. Byt till en enda skärm och försök igen."),
|
||||
("screenshot-action-tip", "Välj hur du vill fortsätta med skärmdumpen."),
|
||||
("Save as", "Spara som"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Export", "Exportera"),
|
||||
("Export Logs", "Exportera loggar"),
|
||||
("Import Folder", "Importera mapp"),
|
||||
("Copy to clipboard", "Kppiera till urklipp"),
|
||||
("Enable remote printer", "Aktivera fjärrskrivare"),
|
||||
("Downloading {}", "Laddar ner {}"),
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Lås canvas"),
|
||||
("Sync clipboard between sessions", "Synkronisera urklipp mellan sessioner"),
|
||||
("sync-clipboard-between-sessions-tip", "Text eller bilder som kopieras i en fjärrsession skickas även till urklipp i dina andra anslutna sessioner."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Aktivera"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "கேன்வாஸைப் பூட்டு"),
|
||||
("Sync clipboard between sessions", "அமர்வுகளுக்கு இடையே கிளிப்போர்டை ஒத்திசைக்கவும்"),
|
||||
("sync-clipboard-between-sessions-tip", "ஒரு தொலை அமர்வில் நகலெடுக்கப்பட்ட உரை அல்லது படங்கள் உங்கள் பிற இணைக்கப்பட்ட அமர்வுகளின் கிளிப்போர்டுக்கும் அனுப்பப்படும்."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "இயக்கு"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", ""),
|
||||
("Sync clipboard between sessions", ""),
|
||||
("sync-clipboard-between-sessions-tip", ""),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "ล็อคแคนวาส"),
|
||||
("Sync clipboard between sessions", "ซิงค์คลิปบอร์ดระหว่างเซสชัน"),
|
||||
("sync-clipboard-between-sessions-tip", "ข้อความหรือรูปภาพที่คัดลอกในเซสชันระยะไกลหนึ่งจะถูกส่งไปยังคลิปบอร์ดของเซสชันอื่นที่เชื่อมต่ออยู่ด้วย"),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "เปิดใช้งาน"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Tuvali kilitle"),
|
||||
("Sync clipboard between sessions", "Oturumlar arasında panoyu senkronize et"),
|
||||
("sync-clipboard-between-sessions-tip", "Bir uzak oturumda kopyalanan metin veya görseller, bağlı diğer oturumlarınızın panosuna da gönderilir."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Etkinleştir"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "鎖定畫布"),
|
||||
("Sync clipboard between sessions", "在工作階段間同步剪貼簿"),
|
||||
("sync-clipboard-between-sessions-tip", "在一個遠端工作階段中複製的文字或圖片也會傳送到其他已連線工作階段的剪貼簿。"),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "啟用"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Блокування полотна"),
|
||||
("Sync clipboard between sessions", "Синхронізувати буфер обміну між сеансами"),
|
||||
("sync-clipboard-between-sessions-tip", "Текст або зображення, скопійовані в одному віддаленому сеансі, також надсилаються до буфера обміну інших підключених сеансів."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Увімкнути"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -745,6 +745,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", "display-name"),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
|
||||
@@ -763,5 +763,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Lock canvas", "Khóa khung hình"),
|
||||
("Sync clipboard between sessions", "Đồng bộ clipboard giữa các phiên"),
|
||||
("sync-clipboard-between-sessions-tip", "Văn bản hoặc hình ảnh được sao chép trong một phiên từ xa cũng được gửi đến clipboard của các phiên đã kết nối khác."),
|
||||
("terminal-clipboard-write-tip", ""),
|
||||
("Allow terminal apps to copy to clipboard", ""),
|
||||
("Enable", "Bật"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -1496,8 +1496,8 @@ pub fn rename_exe_cmd(src_exe: &str, path: &str) -> ResultType<String> {
|
||||
.ok_or(anyhow!("Can't get file name of {src_exe}"))?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let app_name = crate::get_app_name().to_lowercase();
|
||||
if src_exe_filename.to_lowercase() == format!("{app_name}.exe") {
|
||||
let app_name = crate::get_app_name();
|
||||
if src_exe_filename == format!("{app_name}.exe") {
|
||||
Ok("".to_owned())
|
||||
} else {
|
||||
Ok(format!(
|
||||
|
||||
@@ -92,12 +92,11 @@ pub async fn listen(
|
||||
tokio::select! {
|
||||
Ok((forward, addr)) = listener.accept() => {
|
||||
log::info!("new connection from {:?}", addr);
|
||||
lc.write().unwrap().port_forward = (remote_host.clone(), remote_port);
|
||||
let id = id.clone();
|
||||
let password = password.clone();
|
||||
let mut forward = Framed::new(forward, BytesCodec::new());
|
||||
let mut close_port_forward = false;
|
||||
match connect_and_login(&id, &password, &mut ui_receiver, interface.clone(), &mut forward, key, token, is_rdp, &mut close_port_forward).await {
|
||||
match connect_and_login(&id, &password, &mut ui_receiver, interface.clone(), &mut forward, key, token, is_rdp, &mut close_port_forward, &remote_host, remote_port).await {
|
||||
Ok(Some(stream)) => {
|
||||
let interface = interface.clone();
|
||||
tokio::spawn(async move {
|
||||
@@ -143,6 +142,8 @@ async fn connect_and_login(
|
||||
token: &str,
|
||||
is_rdp: bool,
|
||||
close_port_forward: &mut bool,
|
||||
remote_host: &str,
|
||||
remote_port: i32,
|
||||
) -> ResultType<Option<Stream>> {
|
||||
let conn_type = if is_rdp {
|
||||
ConnType::RDP
|
||||
@@ -160,6 +161,8 @@ async fn connect_and_login(
|
||||
}
|
||||
let mut buffer = Vec::new();
|
||||
let mut received = false;
|
||||
let mut challenge = None;
|
||||
let mut pending_login = None;
|
||||
|
||||
let _keep_it = hc_connection(feedback, rendezvous_server, token).await;
|
||||
|
||||
@@ -177,7 +180,8 @@ async fn connect_and_login(
|
||||
let msg_in = Message::parse_from_bytes(&bytes)?;
|
||||
match msg_in.union {
|
||||
Some(message::Union::Hash(hash)) => {
|
||||
if !interface.handle_hash(password, hash, &mut stream).await {
|
||||
challenge = Some(hash.clone());
|
||||
if !hash_arrived(&interface, password, hash, pending_login.take(), remote_host, remote_port, &mut stream).await {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
@@ -208,9 +212,10 @@ async fn connect_and_login(
|
||||
},
|
||||
d = ui_receiver.recv() => {
|
||||
match d {
|
||||
Some(Data::Login((os_username, os_password, password, remember))) => {
|
||||
interface.handle_login_from_ui(os_username, os_password, password, remember, &mut stream).await;
|
||||
}
|
||||
Some(Data::Login(login)) => match &challenge {
|
||||
Some(hash) => login_from_ui(&interface, hash, login, remote_host, remote_port, &mut stream).await,
|
||||
None => pending_login = Some(login),
|
||||
},
|
||||
Some(Data::Message(msg)) => {
|
||||
allow_err!(stream.send(&msg).await);
|
||||
}
|
||||
@@ -233,6 +238,76 @@ async fn connect_and_login(
|
||||
Ok(Some(stream))
|
||||
}
|
||||
|
||||
|
||||
/// A mapping's login is built from the window's shared handler:
|
||||
/// `create_login_msg` reads `port_forward` and `handle_login_from_ui` reads
|
||||
/// `hash`. Mappings log in concurrently, so each fills them and sends under
|
||||
/// the window's turn lock, or one login carried another mapping's target or
|
||||
/// answered another's challenge.
|
||||
async fn login_with_hash(
|
||||
interface: &impl Interface,
|
||||
password: &str,
|
||||
hash: Hash,
|
||||
remote_host: &str,
|
||||
remote_port: i32,
|
||||
stream: &mut Stream,
|
||||
) -> bool {
|
||||
let lc = interface.get_lch();
|
||||
let turn = lc.read().unwrap().port_forward_login_turn.clone();
|
||||
let _turn = turn.lock().await;
|
||||
lc.write().unwrap().port_forward = (remote_host.to_owned(), remote_port);
|
||||
interface.handle_hash(password, hash, stream).await
|
||||
}
|
||||
|
||||
type UiLogin = (String, String, String, bool);
|
||||
|
||||
/// This connection's `Hash`. The window's password prompt is broadcast to
|
||||
/// every mapping and can reach this one first, so a password typed while
|
||||
/// the `Hash` was on its way is kept and answers it now, rather than being
|
||||
/// dropped in the hope that the mapping which prompted has already stored
|
||||
/// it in the shared handler.
|
||||
async fn hash_arrived(
|
||||
interface: &impl Interface,
|
||||
password: &str,
|
||||
hash: Hash,
|
||||
pending_login: Option<UiLogin>,
|
||||
remote_host: &str,
|
||||
remote_port: i32,
|
||||
stream: &mut Stream,
|
||||
) -> bool {
|
||||
match pending_login {
|
||||
Some(login) => {
|
||||
login_from_ui(interface, &hash, login, remote_host, remote_port, stream).await;
|
||||
true
|
||||
}
|
||||
None => login_with_hash(interface, password, hash, remote_host, remote_port, stream).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's password prompt is broadcast to every mapping; this one
|
||||
/// answers it with its own challenge.
|
||||
async fn login_from_ui(
|
||||
interface: &impl Interface,
|
||||
hash: &Hash,
|
||||
login: UiLogin,
|
||||
remote_host: &str,
|
||||
remote_port: i32,
|
||||
stream: &mut Stream,
|
||||
) {
|
||||
let lc = interface.get_lch();
|
||||
let turn = lc.read().unwrap().port_forward_login_turn.clone();
|
||||
let _turn = turn.lock().await;
|
||||
{
|
||||
let mut lc = lc.write().unwrap();
|
||||
lc.port_forward = (remote_host.to_owned(), remote_port);
|
||||
lc.set_hash(hash.clone());
|
||||
}
|
||||
let (os_username, os_password, password, remember) = login;
|
||||
interface
|
||||
.handle_login_from_ui(os_username, os_password, password, remember, stream)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn run_forward(forward: Framed<TcpStream, BytesCodec>, stream: Stream) -> ResultType<()> {
|
||||
log::info!("new port forwarding connection started");
|
||||
let mut forward = forward;
|
||||
@@ -257,3 +332,172 @@ async fn run_forward(forward: Framed<TcpStream, BytesCodec>, stream: Stream) ->
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod login_tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use hbb_common::{
|
||||
tcp::FramedStream,
|
||||
tokio::time::{sleep, Duration},
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// A window's interface over its shared handler. `handle_hash` can pause
|
||||
/// before building the login, where the real one looks passwords up.
|
||||
#[derive(Clone)]
|
||||
struct Ui {
|
||||
lc: Arc<RwLock<LoginConfigHandler>>,
|
||||
pause: Duration,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interface for Ui {
|
||||
fn send(&self, _data: Data) {}
|
||||
fn msgbox(&self, _msgtype: &str, _title: &str, _text: &str, _link: &str) {}
|
||||
fn handle_login_error(&self, _err: &str) -> bool {
|
||||
false
|
||||
}
|
||||
fn handle_peer_info(&self, _pi: PeerInfo) {}
|
||||
fn set_multiple_windows_session(&self, _sessions: Vec<WindowsSession>) {}
|
||||
async fn handle_hash(&self, pass: &str, hash: Hash, peer: &mut Stream) -> bool {
|
||||
sleep(self.pause).await;
|
||||
crate::client::handle_hash(self.lc.clone(), pass, hash, self, peer).await
|
||||
}
|
||||
async fn handle_login_from_ui(
|
||||
&self,
|
||||
os_username: String,
|
||||
os_password: String,
|
||||
password: String,
|
||||
remember: bool,
|
||||
peer: &mut Stream,
|
||||
) {
|
||||
crate::client::handle_login_from_ui(
|
||||
self.lc.clone(),
|
||||
os_username,
|
||||
os_password,
|
||||
password,
|
||||
remember,
|
||||
peer,
|
||||
)
|
||||
.await
|
||||
}
|
||||
async fn handle_test_delay(&self, _t: TestDelay, _peer: &mut Stream) {}
|
||||
fn get_lch(&self) -> Arc<RwLock<LoginConfigHandler>> {
|
||||
self.lc.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn window() -> Ui {
|
||||
let mut lc = LoginConfigHandler::default();
|
||||
lc.conn_type = ConnType::PORT_FORWARD;
|
||||
Ui {
|
||||
lc: Arc::new(RwLock::new(lc)),
|
||||
pause: Duration::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
/// (our end, the peer's end) of one connection.
|
||||
async fn loopback() -> (Stream, Stream) {
|
||||
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = l.local_addr().unwrap();
|
||||
let client = tokio::net::TcpStream::connect(addr).await.unwrap();
|
||||
let (server, _) = l.accept().await.unwrap();
|
||||
(
|
||||
Stream::Tcp(FramedStream::from(client, addr)),
|
||||
Stream::Tcp(FramedStream::from(server, addr)),
|
||||
)
|
||||
}
|
||||
|
||||
async fn login_at(peer: &mut Stream) -> LoginRequest {
|
||||
let bytes = peer.next().await.unwrap().unwrap();
|
||||
Message::parse_from_bytes(&bytes)
|
||||
.unwrap()
|
||||
.login_request()
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn target(lr: &LoginRequest) -> (String, i32) {
|
||||
(lr.port_forward().host.clone(), lr.port_forward().port)
|
||||
}
|
||||
|
||||
fn hash(challenge: &str) -> Hash {
|
||||
Hash {
|
||||
salt: "salt".to_owned(),
|
||||
challenge: challenge.to_owned(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// What the peer expects for password `pw` under `hash(challenge)`.
|
||||
fn digest(challenge: &str) -> Vec<u8> {
|
||||
let mut h = Sha256::new();
|
||||
h.update("pw");
|
||||
h.update("salt");
|
||||
let salted = h.finalize();
|
||||
let mut h2 = Sha256::new();
|
||||
h2.update(&salted[..]);
|
||||
h2.update(challenge);
|
||||
h2.finalize()[..].to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mappings_logging_in_at_once_each_carry_their_own_target() {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
rt.block_on(async {
|
||||
let mut ui = window();
|
||||
ui.pause = Duration::from_millis(50);
|
||||
let (mut a, mut a_peer) = loopback().await;
|
||||
let (mut b, mut b_peer) = loopback().await;
|
||||
tokio::join!(
|
||||
login_with_hash(&ui, "pw", hash("a"), "a", 1, &mut a),
|
||||
login_with_hash(&ui, "pw", hash("b"), "b", 2, &mut b),
|
||||
);
|
||||
assert_eq!(target(&login_at(&mut a_peer).await), ("a".to_owned(), 1));
|
||||
assert_eq!(target(&login_at(&mut b_peer).await), ("b".to_owned(), 2));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mapping_answers_the_prompt_with_its_own_challenge() {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
rt.block_on(async {
|
||||
let ui = window();
|
||||
let (mut a, mut a_peer) = loopback().await;
|
||||
let (mut b, mut b_peer) = loopback().await;
|
||||
// A's hash arrived last, so it is the one the handler holds.
|
||||
assert!(login_with_hash(&ui, "pw", hash("a"), "a", 1, &mut a).await);
|
||||
login_at(&mut a_peer).await;
|
||||
let typed = (String::new(), String::new(), "pw".to_owned(), false);
|
||||
login_from_ui(&ui, &hash("b"), typed, "b", 2, &mut b).await;
|
||||
let lr = login_at(&mut b_peer).await;
|
||||
assert_eq!(lr.password, digest("b"));
|
||||
assert_eq!(target(&lr), ("b".to_owned(), 2));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_password_typed_before_this_connections_hash_answers_it_when_it_comes() {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
rt.block_on(async {
|
||||
let ui = window();
|
||||
let (mut b, mut b_peer) = loopback().await;
|
||||
// The prompt's password reached B before its hash, and no other
|
||||
// mapping has stored it in the handler yet.
|
||||
let typed = (String::new(), String::new(), "pw".to_owned(), false);
|
||||
assert!(hash_arrived(&ui, "", hash("b"), Some(typed), "b", 2, &mut b).await);
|
||||
let lr = login_at(&mut b_peer).await;
|
||||
assert_eq!(lr.password, digest("b"));
|
||||
assert_eq!(target(&lr), ("b".to_owned(), 2));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -585,13 +585,9 @@ impl Connection {
|
||||
crate::rustdesk_interval(time::interval_at(Instant::now(), TEST_DELAY_TIMEOUT));
|
||||
let mut last_recv_time = Instant::now();
|
||||
|
||||
conn.stream.set_send_timeout(
|
||||
if conn.file_transfer.is_some() || conn.port_forward_socket.is_some() || conn.terminal {
|
||||
SEND_TIMEOUT_OTHER
|
||||
} else {
|
||||
SEND_TIMEOUT_VIDEO
|
||||
},
|
||||
);
|
||||
// The connection type is not known until the login request arrives;
|
||||
// `on_message` picks the type-specific timeout then.
|
||||
conn.stream.set_send_timeout(SEND_TIMEOUT_VIDEO);
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
std::thread::spawn(move || Self::handle_input(_rx_input, tx_cloned));
|
||||
@@ -1091,9 +1087,6 @@ impl Connection {
|
||||
}
|
||||
}
|
||||
video_service::notify_video_frame_fetched_by_conn_id(id, None);
|
||||
if conn.authorized {
|
||||
password::update_temporary_password();
|
||||
}
|
||||
if let Err(err) = conn.try_port_forward_loop(&mut rx_from_cm).await {
|
||||
conn.on_close(&err.to_string(), false).await;
|
||||
raii::AuthedConnID::check_remove_session(conn.inner.id(), conn.session_key());
|
||||
@@ -1751,6 +1744,10 @@ impl Connection {
|
||||
return false;
|
||||
}
|
||||
self.authorized = true;
|
||||
// One-time means gone once it has let a peer in, not once that peer
|
||||
// leaves. This session's later logins come in on the password the
|
||||
// session remembers, so they are not affected.
|
||||
password::update_temporary_password();
|
||||
// Releases the budget `check_id_whitelist` charges against this address: only a peer
|
||||
// that got this far proved more than a self-reported id.
|
||||
self.clear_id_whitelist_failures();
|
||||
@@ -2766,6 +2763,17 @@ impl Connection {
|
||||
}
|
||||
}
|
||||
|
||||
self.stream.set_send_timeout(
|
||||
if self.file_transfer.is_some()
|
||||
|| self.terminal
|
||||
|| matches!(self.lr.union, Some(login_request::Union::PortForward(_)))
|
||||
{
|
||||
SEND_TIMEOUT_OTHER
|
||||
} else {
|
||||
SEND_TIMEOUT_VIDEO
|
||||
},
|
||||
);
|
||||
|
||||
if !crate::common::is_direct_ip_access(&lr.username) && lr.username != Config::get_id()
|
||||
{
|
||||
self.send_login_error(crate::client::LOGIN_MSG_OFFLINE)
|
||||
|
||||
@@ -53,6 +53,82 @@ struct WaylandUinputRect {
|
||||
struct WaylandLayout {
|
||||
baseline: Vec<scrap::wayland::display::DisplayRect>,
|
||||
live: Vec<scrap::wayland::display::DisplayRect>,
|
||||
// What the live capturers were built against. Separate from `baseline` because a session
|
||||
// init resets that one, and the generation detector needs a memory that a reset cannot
|
||||
// erase: two inits straddling a rotation would otherwise leave nothing to compare against.
|
||||
seen: Vec<scrap::wayland::display::DisplayRect>,
|
||||
// A capturer recorded a build layout other than `seen`, tagged with the generation it was
|
||||
// built at: the poll observed the live layout between that capturer's snapshot read and its
|
||||
// record, so one of the two is stale and the next poll owes an edge whatever it sees. Only
|
||||
// while that generation is current: the record can also land between the poll consuming an
|
||||
// edge and the bump it promotes (or after the bump, with a snapshot from before it), and that
|
||||
// capturer rebuilds on its own, so a second promotion would tear the fresh ones down again.
|
||||
// Consumed by `observe`, which the poll runs right after `edge`; a session init's baseline
|
||||
// reset leaves it alone.
|
||||
unseen_build: Option<u64>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl WaylandLayout {
|
||||
// Replace the per-session input baseline. Before the first poll the outgoing baseline is
|
||||
// the only record of the layout the capturers were built against, so it seeds `seen`.
|
||||
fn reset_baseline(&mut self, baseline: Vec<scrap::wayland::display::DisplayRect>) {
|
||||
if self.seen.is_empty() {
|
||||
let previous = std::mem::take(&mut self.baseline);
|
||||
self.seen = previous;
|
||||
}
|
||||
self.baseline = baseline;
|
||||
self.live.clear();
|
||||
}
|
||||
|
||||
// An EDGE (live vs the layout the capturers were built against), not a level: comparing
|
||||
// against the baseline latches true for the whole session. With nothing observed yet the
|
||||
// baseline is that record, and a missing snapshot at init makes the first success the edge,
|
||||
// or transform=0 sticks.
|
||||
fn edge(
|
||||
&self,
|
||||
live: &[scrap::wayland::display::DisplayRect],
|
||||
snapshot_missing: bool,
|
||||
generation: u64,
|
||||
) -> bool {
|
||||
if self.unseen_build == Some(generation) {
|
||||
return true;
|
||||
}
|
||||
if !self.seen.is_empty() {
|
||||
return self.seen != live;
|
||||
}
|
||||
if self.baseline.is_empty() {
|
||||
return snapshot_missing;
|
||||
}
|
||||
self.baseline != live
|
||||
}
|
||||
|
||||
fn observe(&mut self, live: &[scrap::wayland::display::DisplayRect]) {
|
||||
self.live = live.to_vec();
|
||||
self.seen = live.to_vec();
|
||||
self.unseen_build = None;
|
||||
}
|
||||
|
||||
// What a capturer was built against, which seeds the memory when nothing else has. A session
|
||||
// init whose wayland query failed leaves an EMPTY baseline, and the capturer's own retry can
|
||||
// then succeed - so the capturer is the only thing that knows the layout it is showing, and
|
||||
// without this a rotation before the first poll is invisible to `edge`. Only when empty: a
|
||||
// capturer built later must not overwrite the memory the poll is keeping, since on a
|
||||
// multi-display session that memory is what the OTHER capturers were built against. A build
|
||||
// that disagrees with it is flagged instead: the capturer's snapshot read and this record
|
||||
// are two steps, and a poll landing between them observes the live layout first, which
|
||||
// would otherwise drop the record and leave the capturer on a transform nothing compares.
|
||||
fn note_capturer(&mut self, built_on: &[scrap::wayland::display::DisplayRect], built_gen: u64) {
|
||||
if built_on.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.seen.is_empty() {
|
||||
self.seen = built_on.to_vec();
|
||||
} else if self.seen != built_on {
|
||||
// The newest generation wins: a stale record landing late must not hide a fresh one.
|
||||
self.unseen_build = Some(self.unseen_build.map_or(built_gen, |g| g.max(built_gen)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Whether `live` differs from `baseline`. Read on every mouse move, so it is an atomic:
|
||||
@@ -75,9 +151,24 @@ pub(super) fn wayland_uinput_rect() -> Option<(i32, i32, i32, i32)> {
|
||||
#[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();
|
||||
WAYLAND_LAYOUT.lock().unwrap().reset_baseline(baseline);
|
||||
}
|
||||
|
||||
/// Record the layout a capturer was just built against, and the snapshot generation it read
|
||||
/// before taking that layout. See `WaylandLayout::note_capturer`.
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
pub(super) fn note_capturer_layout(
|
||||
displays: &[hbb_common::platform::linux::WaylandDisplayInfo],
|
||||
built_gen: u64,
|
||||
) {
|
||||
if displays.is_empty() {
|
||||
return;
|
||||
}
|
||||
let rects = scrap::wayland::display::logical_rects_of_displays(displays);
|
||||
WAYLAND_LAYOUT
|
||||
.lock()
|
||||
.unwrap()
|
||||
.note_capturer(&rects, built_gen);
|
||||
}
|
||||
|
||||
// Remap an injected coordinate onto the live compositor layout when it has drifted from
|
||||
@@ -100,11 +191,6 @@ fn refresh_wayland_uinput_rect_if_changed() {
|
||||
if is_x11() || !crate::input_service::wayland_use_uinput() {
|
||||
return;
|
||||
}
|
||||
// Nothing to poll at a login screen; the DRM path owns the rect there.
|
||||
#[cfg(feature = "drm")]
|
||||
if crate::platform::linux::is_login_screen_wayland_cached() {
|
||||
return;
|
||||
}
|
||||
{
|
||||
let mut lock = WAYLAND_UINPUT_RECT.lock().unwrap();
|
||||
if let Some(last_check) = lock.last_check {
|
||||
@@ -120,14 +206,55 @@ fn refresh_wayland_uinput_rect_if_changed() {
|
||||
// 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 (live_changed, mut drifted) = {
|
||||
let mut layout = WAYLAND_LAYOUT.lock().unwrap();
|
||||
#[cfg(feature = "drm")]
|
||||
let snapshot_missing = scrap::wayland::display::wayland_snapshot_missing();
|
||||
#[cfg(not(feature = "drm"))]
|
||||
let snapshot_missing = false;
|
||||
#[cfg(feature = "drm")]
|
||||
let generation = scrap::wayland::display::wayland_snapshot_generation();
|
||||
#[cfg(not(feature = "drm"))]
|
||||
let generation = 0;
|
||||
let live_changed = layout.edge(&live_rects, snapshot_missing, generation);
|
||||
let drifted = !layout.baseline.is_empty()
|
||||
&& !live_rects.is_empty()
|
||||
&& layout.baseline != live_rects;
|
||||
layout.live = live_rects;
|
||||
drifted
|
||||
layout.observe(&live_rects);
|
||||
(live_changed, drifted)
|
||||
};
|
||||
// Single owner of the generation bump: on the cache clear it let every session init tear
|
||||
// down every other live capturer. Baseline promotes with the clear (rustdesk#15601).
|
||||
#[cfg(feature = "drm")]
|
||||
{
|
||||
// An edge seen while DRM is transiently non-Available stays OWED rather than consumed.
|
||||
static PROMOTION_OWED: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
// The latch fires when a capturer was built with no wayland snapshot: a later cache
|
||||
// refill makes wayland_snapshot_missing lie, so live_changed alone would miss it. Taken
|
||||
// UNCONDITIONALLY: short-circuiting past it on a live_changed poll would leave it set and
|
||||
// spend a second, spurious promotion one poll later on the freshly rebuilt capturer.
|
||||
let blind_build = super::drm_capturer::take_unrotated_snapshot_pending();
|
||||
if live_changed || blind_build {
|
||||
PROMOTION_OWED.store(true, Ordering::Release);
|
||||
}
|
||||
if PROMOTION_OWED.load(Ordering::Acquire) && super::drm_capturer::is_available_cached() {
|
||||
PROMOTION_OWED.store(false, Ordering::Release);
|
||||
scrap::wayland::display::clear_wayland_displays_cache();
|
||||
scrap::wayland::display::bump_layout_generation();
|
||||
set_wayland_layout_baseline(live_rects.clone());
|
||||
WAYLAND_LAYOUT.lock().unwrap().live = live_rects.clone();
|
||||
drifted = false;
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "drm"))]
|
||||
let _ = live_changed;
|
||||
// At a login screen the DRM path owns the rect; only the range/remap update is skipped,
|
||||
// the snapshot invalidation above must still run (a greeter session has no other trigger).
|
||||
#[cfg(feature = "drm")]
|
||||
if crate::platform::linux::is_login_screen_wayland_cached() {
|
||||
return;
|
||||
}
|
||||
// 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.
|
||||
@@ -721,3 +848,177 @@ mod tests {
|
||||
assert_eq!(normalize_primary_display_idx(2, 2), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod wayland_layout_tests {
|
||||
use super::WaylandLayout;
|
||||
use scrap::wayland::display::DisplayRect;
|
||||
|
||||
fn layout(w: i32, h: i32, transform: i32) -> Vec<DisplayRect> {
|
||||
vec![DisplayRect {
|
||||
name: "DP-1".into(),
|
||||
x: 0,
|
||||
y: 0,
|
||||
w,
|
||||
h,
|
||||
transform,
|
||||
}]
|
||||
}
|
||||
|
||||
// rustdesk#15886: a video service starts, the output rotates, and a retry starts before the
|
||||
// 1.5 s poll. The baseline is reset on both, so it cannot be the edge detector's memory.
|
||||
#[test]
|
||||
fn a_rotation_between_two_session_inits_is_still_an_edge() {
|
||||
let upright = layout(1920, 1080, 0);
|
||||
let rotated = layout(1080, 1920, 1);
|
||||
let mut l = WaylandLayout::default();
|
||||
l.reset_baseline(upright.clone());
|
||||
l.observe(&upright);
|
||||
l.reset_baseline(upright.clone());
|
||||
l.reset_baseline(rotated.clone());
|
||||
assert!(l.edge(&rotated, false, 0));
|
||||
}
|
||||
|
||||
// The same, with no poll ever having run: the outgoing baseline is the only record of what
|
||||
// the first capturer was built against.
|
||||
#[test]
|
||||
fn a_rotation_between_two_inits_before_the_first_poll_is_still_an_edge() {
|
||||
let upright = layout(1920, 1080, 0);
|
||||
let rotated = layout(1080, 1920, 1);
|
||||
let mut l = WaylandLayout::default();
|
||||
l.reset_baseline(upright.clone());
|
||||
l.reset_baseline(rotated.clone());
|
||||
assert!(l.edge(&rotated, false, 0));
|
||||
}
|
||||
|
||||
// Control: without it the asserts above would pass on a detector that always fires.
|
||||
#[test]
|
||||
fn repeated_baseline_resets_without_a_rotation_are_not_an_edge() {
|
||||
let upright = layout(1920, 1080, 0);
|
||||
let mut l = WaylandLayout::default();
|
||||
l.reset_baseline(upright.clone());
|
||||
l.observe(&upright);
|
||||
l.reset_baseline(upright.clone());
|
||||
l.reset_baseline(upright.clone());
|
||||
assert!(!l.edge(&upright, false, 0));
|
||||
}
|
||||
|
||||
// rustdesk#15886: `ensure_inited()` runs the wayland query BEFORE the capturer exists, and a
|
||||
// failure there saves an EMPTY baseline. The capturer's own retry can succeed a moment later
|
||||
// and build on layout A, and that build is not blind, so nothing else records it. A rotation
|
||||
// before the first poll then had no memory to be an edge against.
|
||||
#[test]
|
||||
fn a_capturer_built_after_a_failed_init_still_owes_a_rebuild() {
|
||||
let upright = layout(1920, 1080, 0);
|
||||
let rotated = layout(1080, 1920, 1);
|
||||
|
||||
let mut l = WaylandLayout::default();
|
||||
l.reset_baseline(Vec::new());
|
||||
l.note_capturer(&upright, 0);
|
||||
assert!(l.edge(&rotated, false, 0));
|
||||
|
||||
// The same with another baseline reset between the build and the poll.
|
||||
let mut l2 = WaylandLayout::default();
|
||||
l2.reset_baseline(Vec::new());
|
||||
l2.note_capturer(&upright, 0);
|
||||
l2.reset_baseline(rotated.clone());
|
||||
assert!(l2.edge(&rotated, false, 0));
|
||||
|
||||
// Control: no rotation, no edge, in both shapes.
|
||||
let mut l3 = WaylandLayout::default();
|
||||
l3.reset_baseline(Vec::new());
|
||||
l3.note_capturer(&upright, 0);
|
||||
assert!(!l3.edge(&upright, false, 0));
|
||||
}
|
||||
|
||||
// A capturer built while the poll already has a memory must not overwrite it.
|
||||
#[test]
|
||||
fn a_later_capturer_does_not_overwrite_the_polls_memory() {
|
||||
let upright = layout(1920, 1080, 0);
|
||||
let rotated = layout(1080, 1920, 1);
|
||||
let mut l = WaylandLayout::default();
|
||||
l.observe(&upright);
|
||||
l.note_capturer(&rotated, 0);
|
||||
assert!(l.edge(&rotated, false, 0), "the poll's memory still says upright");
|
||||
}
|
||||
|
||||
// The constructor's snapshot read and its `note_capturer` are two steps, and the poll can
|
||||
// land between them. After a failed init (empty baseline) the constructor takes A and
|
||||
// publishes it; the output rotates; the poll reads B live, finds nothing recorded and the
|
||||
// snapshot present, so no edge, and observes B. The late `note_capturer(A)` then met a
|
||||
// non-empty memory and was dropped: the capturer showed A while the detector held B, and B
|
||||
// against B never bumped the generation.
|
||||
#[test]
|
||||
fn a_capturer_record_that_lost_the_race_with_the_first_poll_is_still_an_edge() {
|
||||
let upright = layout(1920, 1080, 0);
|
||||
let rotated = layout(1080, 1920, 1);
|
||||
let mut l = WaylandLayout::default();
|
||||
l.reset_baseline(Vec::new());
|
||||
assert!(!l.edge(&rotated, false, 0), "nothing recorded and the snapshot is present");
|
||||
l.observe(&rotated);
|
||||
l.note_capturer(&upright, 0);
|
||||
assert!(l.edge(&rotated, false, 0), "the capturer is built on upright, live is rotated");
|
||||
|
||||
// The promotion consumes it: the next poll sees the same layout and stays quiet.
|
||||
l.observe(&rotated);
|
||||
l.reset_baseline(rotated.clone());
|
||||
assert!(!l.edge(&rotated, false, 0));
|
||||
|
||||
// The same with a session init between the late record and the poll.
|
||||
let mut l2 = WaylandLayout::default();
|
||||
l2.reset_baseline(Vec::new());
|
||||
l2.observe(&rotated);
|
||||
l2.note_capturer(&upright, 0);
|
||||
l2.reset_baseline(rotated.clone());
|
||||
assert!(l2.edge(&rotated, false, 0));
|
||||
|
||||
// Control: a late record that agrees with the poll's memory is not an edge.
|
||||
let mut l3 = WaylandLayout::default();
|
||||
l3.reset_baseline(Vec::new());
|
||||
l3.observe(&upright);
|
||||
l3.note_capturer(&upright, 0);
|
||||
assert!(!l3.edge(&upright, false, 0));
|
||||
}
|
||||
|
||||
// The late record can also land after the poll consumed the edge but before the bump that
|
||||
// edge promotes, or after the bump with a snapshot taken before it. That capturer is stale
|
||||
// by generation and rebuilds on its own, so its record must not buy a second promotion
|
||||
// that tears the freshly rebuilt capturers down again.
|
||||
#[test]
|
||||
fn a_late_record_from_a_generation_already_promoted_is_not_a_second_edge() {
|
||||
let upright = layout(1920, 1080, 0);
|
||||
let rotated = layout(1080, 1920, 1);
|
||||
let mut l = WaylandLayout::default();
|
||||
l.reset_baseline(upright.clone());
|
||||
l.observe(&upright);
|
||||
// The output rotates, the poll consumes the edge, the capturer built on upright at
|
||||
// generation 7 records late, and the poll promotes to 8.
|
||||
assert!(l.edge(&rotated, false, 7));
|
||||
l.observe(&rotated);
|
||||
l.note_capturer(&upright, 7);
|
||||
l.reset_baseline(rotated.clone());
|
||||
assert!(!l.edge(&rotated, false, 8), "the capturer built at 7 rebuilds on its own");
|
||||
|
||||
// Control: a disagreeing record AT the promoted generation is a real edge.
|
||||
l.observe(&rotated);
|
||||
l.note_capturer(&upright, 8);
|
||||
assert!(l.edge(&rotated, false, 8));
|
||||
|
||||
// A stale record landing after a fresh one must not hide the fresh one.
|
||||
l.observe(&rotated);
|
||||
l.note_capturer(&upright, 8);
|
||||
l.note_capturer(&upright, 7);
|
||||
assert!(l.edge(&rotated, false, 8));
|
||||
}
|
||||
|
||||
// A promotion consumes the edge: the next poll sees the same layout and must stay quiet.
|
||||
#[test]
|
||||
fn a_promoted_layout_is_not_an_edge_again() {
|
||||
let rotated = layout(1080, 1920, 1);
|
||||
let mut l = WaylandLayout::default();
|
||||
l.reset_baseline(layout(1920, 1080, 0));
|
||||
l.observe(&rotated);
|
||||
l.reset_baseline(rotated.clone());
|
||||
assert!(!l.edge(&rotated, false, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,9 +52,17 @@ impl FrameSlot {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Shared.transform` before new() stores the real value: a cursor arriving this early is held
|
||||
/// back and replayed once the session transform is in, because the producer will not resend it
|
||||
/// until the shape changes.
|
||||
const TRANSFORM_PENDING: i32 = i32::MIN;
|
||||
|
||||
struct Shared {
|
||||
slot: Mutex<FrameSlot>,
|
||||
cv: Condvar,
|
||||
// Session transform, TRANSFORM_PENDING until new() stores it post-handshake; the receive
|
||||
// thread turns cursor bitmaps with it and defers any cursor that races the store.
|
||||
transform: std::sync::atomic::AtomicI32,
|
||||
}
|
||||
|
||||
pub struct IpcDrmCapturer {
|
||||
@@ -63,7 +71,14 @@ pub struct IpcDrmCapturer {
|
||||
display: i32,
|
||||
connector: Option<String>,
|
||||
// What the encoder was sized from: CapturerInfo{width,height} is read once, at build time.
|
||||
// With a rotated output these are the ROTATED dimensions, matching the frames delivered.
|
||||
session_size: Option<(usize, usize)>,
|
||||
// Output rotation in degrees: a rotated scanout holds the desktop drawn sideways, so frames
|
||||
// are turned back before delivery. Fixed per session; a rotation rebuilds the capturer.
|
||||
transform: i32,
|
||||
// The wayland snapshot generation this session was built from: a later invalidation means
|
||||
// the layout (a rotation included) may have changed, and frame() asks for a rebuild.
|
||||
snapshot_gen: u64,
|
||||
cur: Vec<u8>,
|
||||
cur_w: usize,
|
||||
cur_h: usize,
|
||||
@@ -76,6 +91,102 @@ fn connector_key(d: &DrmDisplayInfo) -> String {
|
||||
format!("{}:{}", d.device, d.name)
|
||||
}
|
||||
|
||||
/// Frame dimensions after undoing `transform` degrees of output rotation.
|
||||
fn rotated_dims(transform: i32, w: usize, h: usize) -> (usize, usize) {
|
||||
if transform == 90 || transform == 270 {
|
||||
(h, w)
|
||||
} else {
|
||||
(w, h)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hotspot of a rotated cursor bitmap: the same point mapping `unrotate_bgra` applies to
|
||||
/// pixels, applied to the one coordinate that must keep naming the click point.
|
||||
fn unrotate_hotspot(transform: i32, w: i32, h: i32, hotx: i32, hoty: i32) -> (i32, i32) {
|
||||
match transform {
|
||||
90 => (h - 1 - hoty, hotx),
|
||||
180 => (w - 1 - hotx, h - 1 - hoty),
|
||||
270 => (hoty, w - 1 - hotx),
|
||||
_ => (hotx, hoty),
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a 4-byte-pixel frame upright into tightly packed `dst`, undoing `transform` degrees;
|
||||
/// padded `src` rows ok (stride = len/h). Direction pinned by the tests to the measured anchor
|
||||
/// of rustdesk#15886; libyuv walks pixels, so channel order does not matter.
|
||||
fn unrotate_bgra(src: &[u8], w: usize, h: usize, transform: i32, dst: &mut Vec<u8>) {
|
||||
const PX: usize = 4;
|
||||
let stride = if h > 0 { src.len() / h } else { 0 };
|
||||
let (dw, dh) = rotated_dims(transform, w, h);
|
||||
dst.resize(
|
||||
dw.checked_mul(dh).and_then(|p| p.checked_mul(PX)).unwrap_or(0),
|
||||
0,
|
||||
);
|
||||
if dst.is_empty() || stride < w * PX {
|
||||
log::error!("unrotate: rejected geometry {w}x{h} stride {stride}; frame left blank");
|
||||
return;
|
||||
}
|
||||
let mode = match transform {
|
||||
90 => scrap::RotationMode::kRotate90,
|
||||
180 => scrap::RotationMode::kRotate180,
|
||||
270 => scrap::RotationMode::kRotate270,
|
||||
_ => scrap::RotationMode::kRotate0,
|
||||
};
|
||||
unsafe {
|
||||
scrap::ARGBRotate(
|
||||
src.as_ptr(),
|
||||
stride as i32,
|
||||
dst.as_mut_ptr(),
|
||||
(dw * PX) as i32,
|
||||
w as i32,
|
||||
h as i32,
|
||||
mode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transform and augmented origin for one wire entry, derived from ONE wayland snapshot so both
|
||||
/// reflect the same output assignment; two `get_displays()` reads could straddle a cache
|
||||
/// invalidation. `None` origin means nothing to augment with (caller keeps the DRM origin).
|
||||
fn transform_and_origin(
|
||||
drm: &[DrmDisplayInfo],
|
||||
wire_idx: usize,
|
||||
wl: &scrap::wayland::display::Displays,
|
||||
) -> (i32, Option<(i32, i32)>) {
|
||||
if wl.displays.is_empty() || (wl.displays.len() == 1 && drm.len() > 1) {
|
||||
if wl.displays.is_empty() && !drm.is_empty() {
|
||||
// A later successful enumeration refills the cache and hides this state from
|
||||
// wayland_snapshot_missing, so the layout poll needs this durable record to know a
|
||||
// capturer was built blind and owes a rebuild.
|
||||
UNROTATED_SNAPSHOT_PENDING.store(true, Ordering::Release);
|
||||
log::warn!(
|
||||
"drm: no wayland snapshot at capturer build for display {:?}; assuming unrotated",
|
||||
drm.get(wire_idx).map(|d| d.name.as_str()).unwrap_or("?")
|
||||
);
|
||||
}
|
||||
return (0, None);
|
||||
}
|
||||
let assignment = assign_wayland_outputs(drm, &wl.displays);
|
||||
// The transform comes ONLY from an identity match (name, or unique resolution), through the
|
||||
// SAME progressive-taken pass the advertise side keys its swap off: the layout-order
|
||||
// fallback is fine for an origin guess, but a rotation pinned on a guess splits the
|
||||
// advertised dimensions from the delivered ones.
|
||||
let transform = identity_matches(drm, &wl.displays)
|
||||
.get(wire_idx)
|
||||
.copied()
|
||||
.flatten()
|
||||
.map(|j| wl.displays[j].transform)
|
||||
// Hardware-rotated 180 scans out already upright (i915 advertises rotate-180 and
|
||||
// mutter uses it), and wl_output cannot tell hardware from software rotation, so 180
|
||||
// keeps master behavior until the plane rotation property travels the wire.
|
||||
.map(|t| if t == 90 || t == 270 { t } else { 0 })
|
||||
.unwrap_or(0);
|
||||
let origin = augment_with_wayland_geometry_from(drm, wl, &assignment)
|
||||
.get(wire_idx)
|
||||
.map(|di| (di.x, di.y));
|
||||
(transform, origin)
|
||||
}
|
||||
|
||||
/// Takes DRM_STATE: never call it while holding one of the per-display maps below.
|
||||
fn display_info_of(display: i32) -> Option<DrmDisplayInfo> {
|
||||
match &*DRM_STATE.lock().unwrap() {
|
||||
@@ -96,6 +207,9 @@ struct DisplayHealth {
|
||||
/// The dma-buf convert failed for this display. The COMMON cause is multi-GPU: our render node
|
||||
/// is not the GPU that exported the scanout. Follows the monitor for the process run.
|
||||
prefer_cpu: bool,
|
||||
/// The PipeWire fallback for this display was rejected on geometry (a transposed stream), so
|
||||
/// the lone-display carve-out in `mark_demoted_displays` must not keep advertising it online.
|
||||
fallback_rejected: bool,
|
||||
}
|
||||
|
||||
impl DisplayHealth {
|
||||
@@ -107,6 +221,7 @@ impl DisplayHealth {
|
||||
last_build: None,
|
||||
rapid_builds: 0,
|
||||
prefer_cpu: false,
|
||||
fallback_rejected: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +300,14 @@ fn render_node_count() -> usize {
|
||||
}
|
||||
|
||||
static UINPUT_REFRESH_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
/// A capturer was built with no wayland snapshot and runs unrotated; the layout poll consumes
|
||||
/// this to bump the generation once a live snapshot exists.
|
||||
static UNROTATED_SNAPSHOT_PENDING: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
pub(super) fn take_unrotated_snapshot_pending() -> bool {
|
||||
UNROTATED_SNAPSHOT_PENDING.swap(false, std::sync::atomic::Ordering::AcqRel)
|
||||
}
|
||||
static UINPUT_REFRESH_BUSY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
impl IpcDrmCapturer {
|
||||
@@ -193,7 +316,7 @@ impl IpcDrmCapturer {
|
||||
pub fn new(
|
||||
display: i32,
|
||||
expected: Option<DrmDisplayInfo>,
|
||||
) -> ResultType<(IpcDrmCapturer, Vec<DrmDisplayInfo>, usize)> {
|
||||
) -> ResultType<(IpcDrmCapturer, Vec<DrmDisplayInfo>, usize, Option<(i32, i32)>)> {
|
||||
let shared = Arc::new(Shared {
|
||||
slot: Mutex::new(FrameSlot {
|
||||
latest: None,
|
||||
@@ -201,6 +324,7 @@ impl IpcDrmCapturer {
|
||||
ended: None,
|
||||
}),
|
||||
cv: Condvar::new(),
|
||||
transform: std::sync::atomic::AtomicI32::new(TRANSFORM_PENDING),
|
||||
});
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let (tx, rx) = std::sync::mpsc::channel::<ResultType<(Vec<DrmDisplayInfo>, usize)>>();
|
||||
@@ -220,6 +344,18 @@ impl IpcDrmCapturer {
|
||||
bail!("drm capture handshake timed out");
|
||||
}
|
||||
};
|
||||
// One snapshot for the session: transform, origin and the advertised swap must all
|
||||
// reflect the same output assignment. The generation is read BEFORE the snapshot, so a
|
||||
// clear racing the build rebuilds once instead of running a session on stale geometry.
|
||||
let snapshot_gen = scrap::wayland::display::wayland_snapshot_generation();
|
||||
let wl = scrap::wayland::display::get_displays();
|
||||
let (transform, origin) = transform_and_origin(&displays, wire_idx, &wl);
|
||||
// This capturer now shows that layout. If the session init's own wayland query failed it
|
||||
// saved an empty baseline, so this is the only record of what the stream is built on.
|
||||
super::display_service::note_capturer_layout(&wl.displays, snapshot_gen);
|
||||
shared
|
||||
.transform
|
||||
.store(transform, std::sync::atomic::Ordering::Release);
|
||||
Ok((
|
||||
IpcDrmCapturer {
|
||||
shared,
|
||||
@@ -228,7 +364,9 @@ impl IpcDrmCapturer {
|
||||
connector: displays.get(wire_idx).map(connector_key),
|
||||
session_size: displays
|
||||
.get(wire_idx)
|
||||
.map(|d| (d.width as usize, d.height as usize)),
|
||||
.map(|d| rotated_dims(transform, d.width as usize, d.height as usize)),
|
||||
transform,
|
||||
snapshot_gen,
|
||||
cur: Vec::new(),
|
||||
cur_w: 0,
|
||||
cur_h: 0,
|
||||
@@ -237,6 +375,7 @@ impl IpcDrmCapturer {
|
||||
},
|
||||
displays,
|
||||
wire_idx,
|
||||
origin,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -294,10 +433,21 @@ impl TraitCapturer for IpcDrmCapturer {
|
||||
}
|
||||
if let Some((w, h, fmt, buf)) = slot.latest.take() {
|
||||
drop(slot);
|
||||
// convert_to_yuv only refuses a source LARGER than its destination, so a smaller
|
||||
// frame leaves stale edges on screen. On the FIRST frame nothing changed: the list
|
||||
// carries the CRTC mode, a frame the scanout fb, different when a CRTC scales.
|
||||
if self.session_size.is_some_and(|(sw, sh)| (w, h) != (sw, sh)) {
|
||||
// A layout change bumps the generation and is otherwise invisible here (mode
|
||||
// and framebuffer keep their size). Rebuild for the new transform; not counted
|
||||
// against health: the layout moved, the display did not fail.
|
||||
if scrap::wayland::display::wayland_snapshot_generation() != self.snapshot_gen {
|
||||
self.shared.slot.lock().unwrap().recycle(buf);
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("drm: display {} layout changed; rebuilding", self.display),
|
||||
));
|
||||
}
|
||||
// Frames arrive in scanout orientation, the session was sized rotated, so the
|
||||
// guard compares rotated dims. convert_to_yuv only refuses a LARGER source (a
|
||||
// smaller one leaves stale edges); first frame: CRTC mode vs scanout fb.
|
||||
let (fw, fh) = rotated_dims(self.transform, w, h);
|
||||
if self.session_size.is_some_and(|(sw, sh)| (fw, fh) != (sw, sh)) {
|
||||
self.shared.slot.lock().unwrap().recycle(buf);
|
||||
if !self.got_frame {
|
||||
self.note_session_without_frame();
|
||||
@@ -311,15 +461,35 @@ impl TraitCapturer for IpcDrmCapturer {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!(
|
||||
"drm: display {} {what} ({sw}x{sh} -> {w}x{h}); rebuilding",
|
||||
"drm: display {} {what} ({sw}x{sh} -> {fw}x{fh}); rebuilding",
|
||||
self.display
|
||||
),
|
||||
));
|
||||
}
|
||||
let previous = std::mem::replace(&mut self.cur, buf);
|
||||
self.shared.slot.lock().unwrap().recycle(previous);
|
||||
self.cur_w = w;
|
||||
self.cur_h = h;
|
||||
if self.transform == 0 {
|
||||
let previous = std::mem::replace(&mut self.cur, buf);
|
||||
self.shared.slot.lock().unwrap().recycle(previous);
|
||||
} else if !matches!(fmt, Pixfmt::BGRA | Pixfmt::RGBA) {
|
||||
// Unreachable with today's producers (the convert path emits 4-byte pixels
|
||||
// and the CPU path hardcodes BGRA); kept so a future non-4-byte producer
|
||||
// fails the session instead of shearing the image.
|
||||
self.shared.slot.lock().unwrap().recycle(buf);
|
||||
if !self.got_frame {
|
||||
self.note_session_without_frame();
|
||||
}
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!(
|
||||
"drm: display {} delivered {fmt:?} on a rotated output; rebuilding",
|
||||
self.display
|
||||
),
|
||||
));
|
||||
} else {
|
||||
unrotate_bgra(&buf, w, h, self.transform, &mut self.cur);
|
||||
self.shared.slot.lock().unwrap().recycle(buf);
|
||||
}
|
||||
self.cur_w = fw;
|
||||
self.cur_h = fh;
|
||||
self.cur_fmt = fmt;
|
||||
if !self.got_frame {
|
||||
// Clear ONLY the streak: `rapid_builds` is for a display that delivers a first
|
||||
@@ -330,6 +500,7 @@ impl TraitCapturer for IpcDrmCapturer {
|
||||
h.zero_frame_streak = 0;
|
||||
h.demotes = 0;
|
||||
h.since = Instant::now();
|
||||
h.fallback_rejected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -460,10 +631,21 @@ async fn recv_thread(
|
||||
}
|
||||
let _ = tx.send(Ok((displays, wire_idx)));
|
||||
|
||||
// A cursor that arrived before new() stored the session transform, held for replay. Only the
|
||||
// newest matters; the 200 ms recv timeout guarantees this is retried even on an idle wire.
|
||||
let mut pending_cursor: Option<(u64, u32, u32, i32, i32, Vec<u8>)> = None;
|
||||
let end_reason = loop {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
break "stopped".to_owned();
|
||||
}
|
||||
if pending_cursor.is_some() {
|
||||
let t = shared.transform.load(std::sync::atomic::Ordering::Acquire);
|
||||
if t != TRANSFORM_PENDING {
|
||||
if let Some((id, width, height, hotx, hoty, raw)) = pending_cursor.take() {
|
||||
deliver_drm_cursor(display, cursor_epoch, id, width, height, hotx, hoty, raw, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
let (msg, recv_fd) = match conn.recv_msg_timeout2(200).await {
|
||||
None => continue, // timeout: re-check stop at the loop top
|
||||
Some(Ok(pair)) => pair,
|
||||
@@ -580,18 +762,23 @@ async fn recv_thread(
|
||||
raw.len()
|
||||
);
|
||||
}
|
||||
set_drm_cursor(
|
||||
display,
|
||||
cursor_epoch,
|
||||
DrmCursorData {
|
||||
let t = shared.transform.load(std::sync::atomic::Ordering::Acquire);
|
||||
if t == TRANSFORM_PENDING {
|
||||
pending_cursor = Some((id, width, height, hotx, hoty, raw));
|
||||
} else {
|
||||
pending_cursor = None;
|
||||
deliver_drm_cursor(
|
||||
display,
|
||||
cursor_epoch,
|
||||
id,
|
||||
width: width as i32,
|
||||
height: height as i32,
|
||||
width,
|
||||
height,
|
||||
hotx,
|
||||
hoty,
|
||||
colors: raw,
|
||||
},
|
||||
);
|
||||
raw,
|
||||
t,
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => break format!("cursor body: {err}"),
|
||||
}
|
||||
@@ -717,6 +904,56 @@ fn remove_drm_cursor(display: i32, epoch: u64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Unrotate a wire cursor into the session orientation and publish it. The compositor
|
||||
/// pre-rotates the bitmap it programs into the cursor plane, so over the unrotated video the
|
||||
/// cursor alone would stay turned and its hotspot transposed (review finding 11 on
|
||||
/// rustdesk#15889). The wire id hashes only the plane pixels and geometry, so a stream rebuilt
|
||||
/// under a new transform resends the SAME id and the client's by-id cursor cache would keep the
|
||||
/// old orientation: fold the transform in (the producer's own FNV step) so id and orientation
|
||||
/// can never disagree. The hidden sentinel must survive untouched.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn deliver_drm_cursor(
|
||||
display: i32,
|
||||
cursor_epoch: u64,
|
||||
id: u64,
|
||||
width: u32,
|
||||
height: u32,
|
||||
hotx: i32,
|
||||
hoty: i32,
|
||||
raw: Vec<u8>,
|
||||
t: i32,
|
||||
) {
|
||||
let (width, height, hotx, hoty, colors) = if t == 90 || t == 270 {
|
||||
let mut turned = Vec::new();
|
||||
unrotate_bgra(&raw, width as usize, height as usize, t, &mut turned);
|
||||
let (hx, hy) = unrotate_hotspot(t, width as i32, height as i32, hotx, hoty);
|
||||
(height as i32, width as i32, hx, hy, turned)
|
||||
} else {
|
||||
(width as i32, height as i32, hotx, hoty, raw)
|
||||
};
|
||||
let id = fold_cursor_id(id, t);
|
||||
set_drm_cursor(
|
||||
display,
|
||||
cursor_epoch,
|
||||
DrmCursorData {
|
||||
id,
|
||||
width,
|
||||
height,
|
||||
hotx,
|
||||
hoty,
|
||||
colors,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn fold_cursor_id(id: u64, t: i32) -> u64 {
|
||||
if id == scrap::drm_reader::HIDDEN_CURSOR_ID {
|
||||
id
|
||||
} else {
|
||||
(id ^ t as u32 as u64).wrapping_mul(1099511628211)
|
||||
}
|
||||
}
|
||||
|
||||
fn with_drm_cursor<T>(f: impl Fn(&DrmCursorData) -> T) -> Option<T> {
|
||||
let map = DRM_CURSOR.lock().unwrap();
|
||||
map.values()
|
||||
@@ -1183,12 +1420,22 @@ pub(super) fn display_count_and_any_demoted() -> Option<(usize, bool)> {
|
||||
}
|
||||
|
||||
// A multi-display portal stream cannot replace one demoted connector. Keep its index but mark it
|
||||
// offline; a single connector remains usable through the whole-desktop fallback.
|
||||
// offline; a single connector remains usable through the whole-desktop fallback - unless that
|
||||
// fallback itself was rejected on geometry, in which case advertising the lone display online
|
||||
// would restart-loop the video service against a stream nothing can serve.
|
||||
fn mark_demoted_displays(list: &[DrmDisplayInfo], infos: &mut [DisplayInfo]) {
|
||||
let health = DRM_DISPLAY_HEALTH.lock().unwrap();
|
||||
if list.len() <= 1 {
|
||||
if let (Some(display), Some(info)) = (list.first(), infos.first_mut()) {
|
||||
if health
|
||||
.get(&connector_key(display))
|
||||
.is_some_and(|health| health.demoted() && health.fallback_rejected)
|
||||
{
|
||||
info.online = false;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let health = DRM_DISPLAY_HEALTH.lock().unwrap();
|
||||
for (display, info) in list.iter().zip(infos.iter_mut()) {
|
||||
if health
|
||||
.get(&connector_key(display))
|
||||
@@ -1199,6 +1446,21 @@ fn mark_demoted_displays(list: &[DrmDisplayInfo], infos: &mut [DisplayInfo]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The PipeWire fallback for this display was rejected on geometry; recorded so the lone-display
|
||||
/// carve-out above stops advertising a display nothing can serve. Cleared by a delivered frame
|
||||
/// and by the demote-cooldown re-arm.
|
||||
pub(super) fn mark_fallback_rejected(display_idx: usize) {
|
||||
let Some(expected) = display_info_of(display_idx as i32) else {
|
||||
return;
|
||||
};
|
||||
DRM_DISPLAY_HEALTH
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(connector_key(&expected))
|
||||
.or_insert_with(DisplayHealth::new)
|
||||
.fallback_rejected = true;
|
||||
}
|
||||
|
||||
fn primary_index_from_assignment(assignment: &[Option<usize>], primary: usize) -> usize {
|
||||
assignment
|
||||
.iter()
|
||||
@@ -1266,18 +1528,36 @@ fn augment_with_wayland_geometry_from(
|
||||
if origin_only && drm.len() > 1 {
|
||||
return infos;
|
||||
}
|
||||
let identity = identity_matches(drm, &wl.displays);
|
||||
for (i, info) in infos.iter_mut().enumerate() {
|
||||
let Some(w) = matched[i].map(|j| &wl.displays[j]) else {
|
||||
continue;
|
||||
};
|
||||
info.x = w.x;
|
||||
info.y = w.y;
|
||||
// Rotated size before the origin-only cut: a lone rotated output still delivers rotated
|
||||
// frames, so it must advertise them; only the logical-scale adoption stays multi-output.
|
||||
// original_resolution follows in the same motion, or the client reads the transposed
|
||||
// current size against an untransposed original as a third-party resolution change.
|
||||
// Identity matches ONLY, the same rule the capturer's transform follows: swapping on a
|
||||
// layout-order guess advertises dimensions the capturer will not deliver.
|
||||
let is_identity = identity[i].is_some() && identity[i] == matched[i];
|
||||
if is_identity && (w.transform == 90 || w.transform == 270) {
|
||||
std::mem::swap(&mut info.width, &mut info.height);
|
||||
info.original_resolution = super::display_service::get_original_resolution(
|
||||
&drm[i].name,
|
||||
info.width as usize,
|
||||
info.height as usize,
|
||||
);
|
||||
}
|
||||
if origin_only {
|
||||
continue;
|
||||
}
|
||||
if let Some((lw, lh)) = w.logical_size {
|
||||
if lw > 0 && lh > 0 {
|
||||
info.scale = drm[i].width as f64 / lw as f64;
|
||||
// Post-swap width over logical width, which arrives already swapped when rotated:
|
||||
// the unrotated numerator made a rotated 1:1 monitor advertise scale 16/9.
|
||||
info.scale = info.width as f64 / lw as f64;
|
||||
info.original_resolution = super::display_service::get_original_resolution(
|
||||
&drm[i].name,
|
||||
lw as usize,
|
||||
@@ -1292,18 +1572,62 @@ fn augment_with_wayland_geometry_from(
|
||||
/// Each output goes to at most one connector; unmatched ones take the next free output of the same
|
||||
/// size, else the next free one in layout order, since leaving them unaugmented keeps them all at
|
||||
/// DRM's (0,0).
|
||||
fn assign_wayland_outputs(
|
||||
/// The identity half of the assignment (name, or unique resolution), same progressive `taken`
|
||||
/// as the full one. Rotation keys off THIS on both sides: swapping or turning on a layout-order
|
||||
/// guess splits the advertised dimensions from the delivered frames.
|
||||
/// Identity assignment in two GLOBAL passes: every exact name match is reserved first, then
|
||||
/// resolution pairing runs on the unmatched remainder, and only when it is forced - exactly one
|
||||
/// free output AND exactly one unmatched connector at that resolution. A resolution guess for an
|
||||
/// earlier connector must never steal an exact name match from a later one.
|
||||
fn identity_matches(
|
||||
drm: &[DrmDisplayInfo],
|
||||
wl: &[hbb_common::platform::linux::WaylandDisplayInfo],
|
||||
) -> Vec<Option<usize>> {
|
||||
let mut taken = vec![false; wl.len()];
|
||||
let mut matched: Vec<Option<usize>> = vec![None; drm.len()];
|
||||
for (i, d) in drm.iter().enumerate() {
|
||||
if let Some(j) = match_wayland_display(d, wl, &taken) {
|
||||
let dn = normalize_connector(&d.name);
|
||||
if let Some((j, _)) = wl
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(j, w)| !taken[*j] && normalize_connector(&w.name) == dn)
|
||||
{
|
||||
matched[i] = Some(j);
|
||||
taken[j] = true;
|
||||
}
|
||||
}
|
||||
for (i, d) in drm.iter().enumerate() {
|
||||
if matched[i].is_some() {
|
||||
continue;
|
||||
}
|
||||
let free_same: Vec<usize> = wl
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(j, w)| !taken[*j] && w.width == d.width as i32 && w.height == d.height as i32)
|
||||
.map(|(j, _)| j)
|
||||
.collect();
|
||||
let unmatched_same = drm
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(k, o)| matched[*k].is_none() && o.width == d.width && o.height == d.height)
|
||||
.count();
|
||||
if free_same.len() == 1 && unmatched_same == 1 {
|
||||
matched[i] = Some(free_same[0]);
|
||||
taken[free_same[0]] = true;
|
||||
}
|
||||
}
|
||||
matched
|
||||
}
|
||||
|
||||
fn assign_wayland_outputs(
|
||||
drm: &[DrmDisplayInfo],
|
||||
wl: &[hbb_common::platform::linux::WaylandDisplayInfo],
|
||||
) -> Vec<Option<usize>> {
|
||||
let mut matched = identity_matches(drm, wl);
|
||||
let mut taken = vec![false; wl.len()];
|
||||
for m in matched.iter().flatten() {
|
||||
taken[*m] = true;
|
||||
}
|
||||
for (i, d) in drm.iter().enumerate() {
|
||||
if matched[i].is_some() {
|
||||
continue;
|
||||
@@ -1329,30 +1653,6 @@ fn assign_wayland_outputs(
|
||||
matched
|
||||
}
|
||||
|
||||
fn match_wayland_display(
|
||||
d: &DrmDisplayInfo,
|
||||
wl: &[hbb_common::platform::linux::WaylandDisplayInfo],
|
||||
taken: &[bool],
|
||||
) -> Option<usize> {
|
||||
let dn = normalize_connector(&d.name);
|
||||
if let Some((j, _)) = wl
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(j, w)| !taken[*j] && normalize_connector(&w.name) == dn)
|
||||
{
|
||||
return Some(j);
|
||||
}
|
||||
let same_res: Vec<usize> = wl
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(j, w)| !taken[*j] && w.width == d.width as i32 && w.height == d.height as i32)
|
||||
.map(|(j, _)| j)
|
||||
.collect();
|
||||
if same_res.len() == 1 {
|
||||
return Some(same_res[0]);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// DRM inserts a single-letter type discriminator the compositor drops ("HDMI-A-1" -> "HDMI-1").
|
||||
/// Only a *letter* folds: a single *digit* is an MST port index, so "DP-1-2" is not "DP-2".
|
||||
@@ -1413,11 +1713,13 @@ pub(super) fn get_capturer_info(
|
||||
}
|
||||
h.zero_frame_streak = 0;
|
||||
h.since = Instant::now();
|
||||
// The cooldown re-arms DRM for this display, so the fallback verdict restarts too.
|
||||
h.fallback_rejected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Built FIRST: a transient `_drm` outage must NOT count toward the flap threshold below.
|
||||
let (capturer, displays, wire_idx) = IpcDrmCapturer::new(display_idx as i32, expected)?;
|
||||
let (capturer, displays, wire_idx, origin) = IpcDrmCapturer::new(display_idx as i32, expected)?;
|
||||
// The initial build counts 0, so demotion fires on the (RAPID_REBUILD_MAX + 1)-th in a window.
|
||||
if let Some(key) = key.clone() {
|
||||
let now = Instant::now();
|
||||
@@ -1445,16 +1747,14 @@ pub(super) fn get_capturer_info(
|
||||
.get(wire_idx)
|
||||
.ok_or_else(|| anyhow!("drm display index {wire_idx} out of range ({ndisplay})"))?
|
||||
.clone();
|
||||
// Publish the compositor's LOGICAL origin (what get_display_infos advertises) so the origin
|
||||
// matches the reported geometry; KEEP the raw PHYSICAL dimensions for the capture buffer.
|
||||
let origin = augment_with_wayland_geometry(&displays)
|
||||
.get(wire_idx)
|
||||
.map(|di| (di.x, di.y))
|
||||
.unwrap_or((d.x, d.y));
|
||||
// Origin and transform come from the ONE snapshot new() resolved, so both reflect the
|
||||
// same output assignment; dimensions stay PHYSICAL, rotated to frame orientation.
|
||||
let origin = origin.unwrap_or((d.x, d.y));
|
||||
let (cap_w, cap_h) = rotated_dims(capturer.transform, d.width as usize, d.height as usize);
|
||||
Ok(super::video_service::CapturerInfo {
|
||||
origin,
|
||||
width: d.width as usize,
|
||||
height: d.height as usize,
|
||||
width: cap_w,
|
||||
height: cap_h,
|
||||
ndisplay,
|
||||
current: display_idx,
|
||||
privacy_mode_id: 0,
|
||||
@@ -1482,11 +1782,14 @@ mod drm_capturer_tests {
|
||||
ended: None,
|
||||
}),
|
||||
cv: Condvar::new(),
|
||||
transform: std::sync::atomic::AtomicI32::new(0),
|
||||
}),
|
||||
stop: Arc::new(AtomicBool::new(false)),
|
||||
display: 0,
|
||||
connector,
|
||||
session_size: session,
|
||||
transform: 0,
|
||||
snapshot_gen: scrap::wayland::display::wayland_snapshot_generation(),
|
||||
cur: Vec::new(),
|
||||
cur_w: 0,
|
||||
cur_h: 0,
|
||||
@@ -1495,6 +1798,172 @@ mod drm_capturer_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// One BGRA pixel per label byte, so a rotation result reads as a matrix of labels.
|
||||
fn px_frame(labels: &[&[u8]], pad_bytes: usize) -> (Vec<u8>, usize, usize) {
|
||||
let h = labels.len();
|
||||
let w = labels[0].len();
|
||||
let mut buf = Vec::new();
|
||||
for row in labels {
|
||||
for &l in *row {
|
||||
buf.extend_from_slice(&[l, l, l, 255]);
|
||||
}
|
||||
buf.extend(std::iter::repeat(0u8).take(pad_bytes));
|
||||
}
|
||||
(buf, w, h)
|
||||
}
|
||||
|
||||
fn labels_of(buf: &[u8], w: usize, h: usize) -> Vec<Vec<u8>> {
|
||||
(0..h)
|
||||
.map(|y| (0..w).map(|x| buf[(y * w + x) * 4]).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lone_display_goes_offline_only_when_its_fallback_was_rejected() {
|
||||
// Unique name = unique health key; DRM_DISPLAY_HEALTH is process-wide.
|
||||
let list = vec![drm_display("TEST-lone-fallback", 1080, 1920)];
|
||||
let key = connector_key(&list[0]);
|
||||
let demoted = DisplayHealth {
|
||||
zero_frame_streak: DRM_GRAB_MAX_FAILURES,
|
||||
demotes: 1,
|
||||
..DisplayHealth::new()
|
||||
};
|
||||
// Demoted alone keeps the lone display online: the whole-desktop fallback is usable.
|
||||
DRM_DISPLAY_HEALTH.lock().unwrap().insert(key.clone(), demoted);
|
||||
let mut infos = vec![DisplayInfo {
|
||||
online: true,
|
||||
..Default::default()
|
||||
}];
|
||||
mark_demoted_displays(&list, &mut infos);
|
||||
assert!(infos[0].online, "the lone-display carve-out must survive");
|
||||
// A rejected fallback ends the carve-out: advertising online would restart-loop.
|
||||
DRM_DISPLAY_HEALTH
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_mut(&key)
|
||||
.expect("just inserted")
|
||||
.fallback_rejected = true;
|
||||
mark_demoted_displays(&list, &mut infos);
|
||||
assert!(!infos[0].online, "a rejected fallback must take the lone display offline");
|
||||
// Once the demotion cooldown lapses the display is no longer demoted, and online returns
|
||||
// even with the rejection still latched (the re-arm will clear it on the next build).
|
||||
DRM_DISPLAY_HEALTH
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_mut(&key)
|
||||
.expect("still there")
|
||||
.since = Instant::now() - demote_cooldown(1) - Duration::from_secs(1);
|
||||
infos[0].online = true;
|
||||
mark_demoted_displays(&list, &mut infos);
|
||||
assert!(infos[0].online, "past the cooldown the verdict is DRM's to retry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cursor_id_names_the_orientation_too() {
|
||||
// Same wire cursor under two transforms must publish as two ids, or the client's by-id
|
||||
// cache serves the previous orientation after a mid-session rotation.
|
||||
let wire = 0xDEAD_BEEF_u64;
|
||||
assert_ne!(fold_cursor_id(wire, 0), fold_cursor_id(wire, 90));
|
||||
assert_ne!(fold_cursor_id(wire, 90), fold_cursor_id(wire, 270));
|
||||
// Deterministic per (id, transform), so an unchanged cursor is still deduped.
|
||||
assert_eq!(fold_cursor_id(wire, 90), fold_cursor_id(wire, 90));
|
||||
// The hidden sentinel is compared by VALUE at the consumers, so it must pass unfolded.
|
||||
let hidden = scrap::drm_reader::HIDDEN_CURSOR_ID;
|
||||
assert_eq!(fold_cursor_id(hidden, 90), hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrotate_hotspot_follows_the_pixel_mapping() {
|
||||
// 3 wide x 2 tall, hotspot at (2,0) (top-right): after the 90 turn (left column to top
|
||||
// row) that pixel sits at (1,2) in the 2x3 result; 270 sends it to (0,0).
|
||||
assert_eq!(unrotate_hotspot(90, 3, 2, 2, 0), (1, 2));
|
||||
assert_eq!(unrotate_hotspot(270, 3, 2, 2, 0), (0, 0));
|
||||
assert_eq!(unrotate_hotspot(180, 3, 2, 2, 0), (0, 1));
|
||||
assert_eq!(unrotate_hotspot(0, 3, 2, 2, 0), (2, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_snapshot_generation_asks_for_a_rebuild_without_blaming_the_display() {
|
||||
let mut c = capturer_named(Some((64, 32)), Some("test:gen-rebuild"));
|
||||
c.snapshot_gen = c.snapshot_gen.wrapping_sub(1);
|
||||
put_frame(&c, 64, 32);
|
||||
let err = match c.frame(Duration::from_millis(50)) {
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("a stale generation must rebuild, not deliver"),
|
||||
};
|
||||
assert!(err.to_string().contains("layout changed"), "{err}");
|
||||
assert!(!c.got_frame);
|
||||
assert_eq!(
|
||||
zero_frame_streak_of(&c),
|
||||
0,
|
||||
"a layout rebuild must not count against display health"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrotate_90_maps_the_left_column_to_the_top_row() {
|
||||
// The measured anchor from rustdesk#15886: mutter transform=1 carries the panel bar down
|
||||
// the scanout's LEFT edge, and upright means that edge becomes the TOP row.
|
||||
let (src, w, h) = px_frame(&[&[1, 2, 3], &[4, 5, 6]], 0);
|
||||
let mut dst = Vec::new();
|
||||
unrotate_bgra(&src, w, h, 90, &mut dst);
|
||||
// src left column top-to-bottom = [1, 4]; clockwise puts it on the top row as [4, 1].
|
||||
assert_eq!(labels_of(&dst, h, w), vec![vec![4, 1], vec![5, 2], vec![6, 3]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrotate_270_is_the_inverse_of_90() {
|
||||
let (src, w, h) = px_frame(&[&[1, 2, 3], &[4, 5, 6]], 0);
|
||||
let mut once = Vec::new();
|
||||
unrotate_bgra(&src, w, h, 90, &mut once);
|
||||
let mut back = Vec::new();
|
||||
unrotate_bgra(&once, h, w, 270, &mut back);
|
||||
assert_eq!(back, src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrotate_180_reverses_both_axes() {
|
||||
let (src, w, h) = px_frame(&[&[1, 2, 3], &[4, 5, 6]], 0);
|
||||
let mut dst = Vec::new();
|
||||
unrotate_bgra(&src, w, h, 180, &mut dst);
|
||||
assert_eq!(labels_of(&dst, w, h), vec![vec![6, 5, 4], vec![3, 2, 1]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrotate_reads_padded_strides_and_writes_tight() {
|
||||
// Row stride is derived from len/h, so a padded source must not shear the result.
|
||||
let (src, w, h) = px_frame(&[&[1, 2, 3], &[4, 5, 6]], 8);
|
||||
let mut dst = Vec::new();
|
||||
unrotate_bgra(&src, w, h, 90, &mut dst);
|
||||
assert_eq!(dst.len(), w * h * 4);
|
||||
assert_eq!(labels_of(&dst, h, w), vec![vec![4, 1], vec![5, 2], vec![6, 3]]);
|
||||
let mut plain = Vec::new();
|
||||
unrotate_bgra(&src, w, h, 0, &mut plain);
|
||||
assert_eq!(labels_of(&plain, w, h), vec![vec![1, 2, 3], vec![4, 5, 6]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rotated_session_delivers_rotated_frames_and_guards_in_rotated_dims() {
|
||||
use scrap::TraitPixelBuffer;
|
||||
let mut c = capturer_with(Some((32, 64))); // rotated session of a 64x32 scanout
|
||||
c.transform = 90;
|
||||
put_frame(&c, 64, 32);
|
||||
match c.frame(Duration::from_millis(50)) {
|
||||
Ok(Frame::PixelBuffer(pb)) => {
|
||||
assert_eq!((pb.width(), pb.height()), (32, 64));
|
||||
}
|
||||
Ok(_) => panic!("expected a pixel-buffer frame"),
|
||||
Err(err) => panic!("expected a delivered frame, got {err}"),
|
||||
}
|
||||
// A scanout change still ends the session, reported in rotated dimensions.
|
||||
put_frame(&c, 32, 64);
|
||||
let err = match c.frame(Duration::from_millis(50)) {
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("a scanout change must end a rotated session too"),
|
||||
};
|
||||
assert!(err.to_string().contains("(32x64 -> 64x32)"), "{err}");
|
||||
}
|
||||
|
||||
fn zero_frame_streak_of(c: &IpcDrmCapturer) -> u32 {
|
||||
let key = c.connector.clone().expect("this check needs an identity");
|
||||
DRM_DISPLAY_HEALTH
|
||||
@@ -1525,6 +1994,7 @@ mod drm_capturer_tests {
|
||||
h.rapid_builds = 3;
|
||||
h.last_build = Some(Instant::now());
|
||||
h.prefer_cpu = true;
|
||||
h.fallback_rejected = true;
|
||||
}
|
||||
put_frame(&c, 64, 32);
|
||||
assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_)));
|
||||
@@ -1537,6 +2007,10 @@ mod drm_capturer_tests {
|
||||
};
|
||||
assert_eq!(h.zero_frame_streak, 0, "a delivered frame refutes the zero-frame streak");
|
||||
assert_eq!(h.demotes, 0, "and the demotion count that streak drove");
|
||||
assert!(
|
||||
!h.fallback_rejected,
|
||||
"a delivered frame also refutes the rejected-fallback verdict"
|
||||
);
|
||||
assert_eq!(
|
||||
h.rapid_builds, 3,
|
||||
"but it says NOTHING about the rebuild cadence: keeping it is what lets the flap guard \
|
||||
@@ -1642,9 +2116,53 @@ mod drm_capturer_tests {
|
||||
height: h,
|
||||
logical_size: Some((w, h)),
|
||||
refresh_rate: 60,
|
||||
transform: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lone_rotated_output_advertises_delivered_dimensions() {
|
||||
// Fix for the origin-only cut: one connector, one rotated output. The capturer will
|
||||
// deliver rotated frames, so the advertised size must swap even in the origin-only case,
|
||||
// while the logical scale is still not adopted (stays 1.0).
|
||||
let drm = [drm_display("HDMI-A-1", 1920, 1080)];
|
||||
let mut out = wl_display("HDMI-1", 0, 0, 1920, 1080);
|
||||
out.transform = 90;
|
||||
let wl = scrap::wayland::display::Displays {
|
||||
primary: 0,
|
||||
displays: vec![out],
|
||||
};
|
||||
let assignment = assign_wayland_outputs(&drm, &wl.displays);
|
||||
let infos = augment_with_wayland_geometry_from(&drm, &wl, &assignment);
|
||||
assert_eq!((infos[0].width, infos[0].height), (1080, 1920));
|
||||
assert_eq!(infos[0].scale, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_and_origin_come_from_the_same_snapshot() {
|
||||
// Both derive from ONE Displays snapshot: the rotated output's transform and its origin
|
||||
// must belong to the same assignment, and the multi-connector one-output guard zeroes
|
||||
// both rather than mixing a guessed origin with a real transform.
|
||||
let drm = [
|
||||
drm_display("HDMI-A-1", 1920, 1080),
|
||||
drm_display("DP-1", 2560, 1440),
|
||||
];
|
||||
let mut rotated = wl_display("DP-1", 1920, 0, 2560, 1440);
|
||||
rotated.transform = 270;
|
||||
let wl = scrap::wayland::display::Displays {
|
||||
primary: 0,
|
||||
displays: vec![rotated, wl_display("HDMI-1", 0, 0, 1920, 1080)],
|
||||
};
|
||||
let (t, origin) = transform_and_origin(&drm, 1, &wl);
|
||||
assert_eq!(t, 270);
|
||||
assert_eq!(origin, Some((1920, 0)));
|
||||
let lone = scrap::wayland::display::Displays {
|
||||
primary: 0,
|
||||
displays: vec![wl_display("HDMI-1", 0, 0, 1920, 1080)],
|
||||
};
|
||||
assert_eq!(transform_and_origin(&drm, 1, &lone), (0, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_connector_assignment_drives_geometry_and_primary() {
|
||||
let drm = [
|
||||
@@ -1725,6 +2243,32 @@ mod drm_capturer_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resolution_guess_never_steals_an_exact_name_match() {
|
||||
// The review's scenario: an earlier connector with an unmatchable name shares the
|
||||
// resolution of a later connector's exact name match. Names reserve globally first.
|
||||
let drm = vec![
|
||||
drm_display("DSI-1", 1920, 1080),
|
||||
drm_display("HDMI-A-1", 1920, 1080),
|
||||
];
|
||||
let wl = vec![
|
||||
wl_display("HDMI-1", 0, 0, 1920, 1080),
|
||||
wl_display("Unknown-9", 1920, 0, 2560, 1440),
|
||||
];
|
||||
let m = identity_matches(&drm, &wl);
|
||||
assert_eq!(m[1], Some(0), "the exact name match must win globally");
|
||||
assert_eq!(m[0], None, "the leftover pairing is not forced, so no identity");
|
||||
// Two unmatched connectors at the lone free resolution: ambiguous on the DRM side too,
|
||||
// so rotation must not be pinned on either.
|
||||
let drm2 = vec![
|
||||
drm_display("DSI-1", 1920, 1080),
|
||||
drm_display("DSI-2", 1920, 1080),
|
||||
];
|
||||
let wl2 = vec![wl_display("HDMI-1", 0, 0, 1920, 1080)];
|
||||
let m2 = identity_matches(&drm2, &wl2);
|
||||
assert!(m2[0].is_none() && m2[1].is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outputs_are_matched_by_name_across_the_drm_naming_difference() {
|
||||
let drm = [drm_display("HDMI-A-1", 1920, 1080), drm_display("DP-1", 2560, 1440)];
|
||||
|
||||
@@ -108,7 +108,8 @@ struct CapDisplayInfo {
|
||||
}
|
||||
|
||||
/// Uinput desktop rect from the DRM display list, for a login screen where no compositor can be
|
||||
/// asked. `(minx, maxx, miny, maxy)`, in scanout pixels: no compositor here applied a scale, so
|
||||
/// asked. `(minx, maxx, miny, maxy)`, in delivered-orientation physical pixels (a rotated
|
||||
/// output counts transposed, matching its frames): no compositor here applied a scale, so
|
||||
/// unlike `desktop_rect_of` there is no logical size to handle.
|
||||
#[cfg(feature = "drm")]
|
||||
fn drm_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> {
|
||||
@@ -521,11 +522,13 @@ pub(super) fn get_capturer_for_display(
|
||||
// (scrap `common/wayland.rs`), i.e. `PipeWireCapturable.physical_size`.
|
||||
// `try_fix_logical_size` only repairs the capturable's SEPARATE
|
||||
// `logical_size` field and never touches `physical_size`, so the rect is not
|
||||
// logical. The advertised DRM geometry is physical too
|
||||
// (`augment_with_wayland_geometry` sets x/y/scale and deliberately leaves
|
||||
// width/height as the DRM mode). Dividing one side by the scale therefore
|
||||
// compares logical against physical and rejects the valid stream on exactly
|
||||
// the scaled outputs it was meant to rescue.
|
||||
// logical. The advertised DRM geometry is physical too, in DELIVERED
|
||||
// orientation: `augment_with_wayland_geometry` transposes width/height for a
|
||||
// 90/270 output (rustdesk#15886). Whether the portal's caps arrive rotated
|
||||
// is UNMEASURED on a rotated display (pipewiresrc does not apply
|
||||
// SPA_META_VideoTransform), so the size half accepts either orientation
|
||||
// rather than gambling a permanent offline on one of them. Dividing a side
|
||||
// by the scale would still be wrong: logical against physical.
|
||||
//
|
||||
// The size check is what tells one connector apart from the whole-desktop
|
||||
// rect the portal usually exposes. It is skipped only when BOTH sides say
|
||||
@@ -537,15 +540,35 @@ pub(super) fn get_capturer_for_display(
|
||||
// a monitor on a card the service cannot open is missing from the DRM list
|
||||
// while the compositor still drives it.
|
||||
let single_display = single_display && cap_display_info.num == 1;
|
||||
// Exact orientation only: a transposed stream would be encoded at the
|
||||
// PipeWire dimensions while the client keeps the advertised (rotated) ones,
|
||||
// and no wayland path ever reconciles the two, so every frame would be
|
||||
// rejected client-side. Falling into the bail instead advertises the display
|
||||
// offline, which the client recovers from by re-enumerating.
|
||||
let size_matches = advertised.width as usize == rect.1
|
||||
&& advertised.height as usize == rect.2;
|
||||
let transposed = advertised.width as usize == rect.2
|
||||
&& advertised.height as usize == rect.1;
|
||||
// The single-display carve-out forgives a size DIFFERENCE (a Full Workspace
|
||||
// stream may report the workspace, not the mode), but never a transposed
|
||||
// pair: that is the same served-vs-advertised orientation split as above,
|
||||
// and it blanks the client the same way.
|
||||
let consistent = advertised.x == rect.0 .0
|
||||
&& advertised.y == rect.0 .1
|
||||
&& (single_display
|
||||
|| (advertised.width as usize == rect.1
|
||||
&& advertised.height as usize == rect.2));
|
||||
&& (size_matches || (single_display && !transposed));
|
||||
if !consistent {
|
||||
// Recorded so the lone-display carve-out in `mark_demoted_displays` makes
|
||||
// the "advertised offline" below true for a single display too, instead of
|
||||
// restart-looping against a stream nothing can serve.
|
||||
super::drm_capturer::mark_fallback_rejected(display_idx);
|
||||
bail!(
|
||||
"drm display {} demoted with no geometry-consistent PipeWire stream (advertised {}x{}+{}+{} vs stream {}x{}+{}+{}); advertised offline",
|
||||
"drm display {} demoted with no geometry-consistent PipeWire stream{} (advertised {}x{}+{}+{} vs stream {}x{}+{}+{}); advertised offline",
|
||||
display_idx,
|
||||
if transposed {
|
||||
" - stream is transposed vs advertised"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
advertised.width,
|
||||
advertised.height,
|
||||
advertised.x,
|
||||
|
||||
Reference in New Issue
Block a user