Compare commits
1 commit
master
...
keyboard-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04faf21c78 |
119 changed files with 2795 additions and 7780 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -55,4 +55,6 @@ examples/**/target/
|
|||
vcpkg_installed
|
||||
flutter/lib/generated_plugin_registrant.dart
|
||||
libsciter.dylib
|
||||
flutter/web/
|
||||
flutter/web/
|
||||
# Local git worktrees
|
||||
.worktrees/
|
||||
|
|
|
|||
24
AGENTS.md
24
AGENTS.md
|
|
@ -53,6 +53,30 @@
|
|||
* Use `spawn_blocking` or dedicated threads for blocking work.
|
||||
* Do not use `std::thread::sleep()` in async code.
|
||||
|
||||
## Flutter Rust Bridge
|
||||
|
||||
* Do **not** run `flutter_rust_bridge_codegen` — it requires a specific pinned version that is not easy to set up locally.
|
||||
* When adding new FFI functions in `src/flutter_ffi.rs`, hand-write the corresponding Dart wrappers instead of regenerating.
|
||||
* Web bridge (committed): edit `flutter/lib/web/bridge.dart` directly. Follow the existing patterns there for `SyncReturn<T>` / `Future<T>` and the `dart:js` glue.
|
||||
* Native bridge (`flutter/lib/generated_bridge.dart`, `src/bridge_generated.rs`, `src/bridge_generated.io.rs`): these are gitignored and regenerated by the project's CI codegen. Manually editing them locally is fine for development testing, but those edits do not persist into commits.
|
||||
|
||||
## Web (Flutter Web) Architecture
|
||||
|
||||
Flutter Web in this repo is **not** "Dart compiled to JS via Flutter alone". The runtime is split:
|
||||
|
||||
* **Native targets (Win/Mac/Linux/Android/iOS)**: Rust drives sessions via `flutter_rust_bridge`; Dart only renders UI.
|
||||
* **Web target**: Rust does **not** run. There is a separate hand-written TypeScript / JavaScript client at `flutter/web/js/` (gitignored — not present in this repo, lives in the maintainer's local tree). It owns connection, codec, keyboard, clipboard, etc. — basically a JS port of the Rust client. The Dart UI talks to it through `flutter/lib/web/bridge.dart`, which uses `dart:js` to call JS-side functions and to register Dart-side callbacks on `window.*`.
|
||||
|
||||
Implications when adding any session-runtime feature (keyboard, clipboard, audio, …):
|
||||
|
||||
* The Rust implementation in `src/` is for **native only**. Don't try to compile it to wasm.
|
||||
* The matching Web-side logic must be written in TS/JS under `flutter/web/js/src/`. It's a translation of the Rust logic, usually simpler — Web is single-window, so any per-session-id plumbing in Rust collapses to a single global on Web.
|
||||
* `flutter/lib/web/bridge.dart` is the only place where Dart sees JS. Other Dart code stays platform-agnostic and goes through `bind`. Don't sprinkle `if (isWeb)` runtime branches in shared Dart files to call Web-specific logic — put the platform divergence in the bridge.
|
||||
* For JS → Dart events (e.g., a Web matcher firing), the convention is: Dart sets `js.context['onFooBar'] = (...) {...}` once at startup (typically in `mainInit`); the JS side calls `window.onFooBar(...)`. See `onLoadAbFinished`, `onLoadGroupFinished` for reference.
|
||||
* The maintainer cannot easily run `flutter_rust_bridge_codegen`, so when a new FFI function lands in `src/flutter_ffi.rs`:
|
||||
1. add the Web counterpart to `flutter/lib/web/bridge.dart` by hand;
|
||||
2. note that on the Web target it may need to be a no-op or a JS bridge call rather than a real Rust invocation.
|
||||
|
||||
## Editing Hygiene
|
||||
|
||||
* Change only what is required.
|
||||
|
|
|
|||
4
Cargo.lock
generated
4
Cargo.lock
generated
|
|
@ -5996,8 +5996,8 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "parity-tokio-ipc"
|
||||
version = "0.7.3-6"
|
||||
source = "git+https://github.com/rustdesk-org/parity-tokio-ipc#d0ae39bffe5d5a3e8d82a1b6bcb1ca5a9b2f1c01"
|
||||
version = "0.7.3-5"
|
||||
source = "git+https://github.com/rustdesk-org/parity-tokio-ipc#c8c8bbcbabf9be1201c53afb0269b92b9b02d291"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"libc",
|
||||
|
|
|
|||
|
|
@ -716,17 +716,6 @@ closeConnection({String? id}) {
|
|||
stateGlobal.isInMainPage = true;
|
||||
} else {
|
||||
final controller = Get.find<DesktopTabController>();
|
||||
if (controller.tabType == DesktopTabType.terminal &&
|
||||
controller.onCloseWindow != null) {
|
||||
// Terminal windows are scoped to one peer. The optional id passed to
|
||||
// closeConnection() is that peer id, not a terminal tab key
|
||||
// (${peerId}_${terminalId}). Closing from terminal dialogs should close
|
||||
// the peer's whole terminal window, including all terminal tabs.
|
||||
unawaited(controller.onCloseWindow!().catchError((e, _) {
|
||||
debugPrint('[closeConnection] Failed to close terminal window: $e');
|
||||
}));
|
||||
return;
|
||||
}
|
||||
controller.closeBy(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -4190,7 +4179,8 @@ Widget? buildAvatarWidget({
|
|||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => fallback ?? SizedBox.shrink(),
|
||||
errorBuilder: (_, __, ___) =>
|
||||
fallback ?? SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
65
flutter/lib/common/widgets/keyboard_shortcuts/display.dart
Normal file
65
flutter/lib/common/widgets/keyboard_shortcuts/display.dart
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// flutter/lib/common/widgets/keyboard_shortcuts/display.dart
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../../../consts.dart';
|
||||
import '../../../models/platform_model.dart';
|
||||
|
||||
/// Read the bindings JSON and produce a human-readable shortcut string for
|
||||
/// `actionId`, formatted for the current OS. Returns null if unbound.
|
||||
class ShortcutDisplay {
|
||||
static String? formatFor(String actionId) {
|
||||
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
|
||||
if (raw.isEmpty) return null;
|
||||
final Map<String, dynamic> parsed;
|
||||
try {
|
||||
parsed = jsonDecode(raw) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
if (parsed['enabled'] != true) return null;
|
||||
final list = (parsed['bindings'] as List? ?? []).cast<Map<String, dynamic>>();
|
||||
final found = list.firstWhere(
|
||||
(b) => b['action'] == actionId,
|
||||
orElse: () => {},
|
||||
);
|
||||
if (found.isEmpty) return null;
|
||||
|
||||
// Guard against a hand-edited / corrupt config where `key` is missing or
|
||||
// not a string — silently treat the binding as unbound rather than
|
||||
// crashing the toolbar render.
|
||||
final keyValue = found['key'];
|
||||
if (keyValue is! String) return null;
|
||||
|
||||
final isMac = defaultTargetPlatform == TargetPlatform.macOS ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS;
|
||||
// `mods` similarly may be malformed; treat a non-list as no modifiers.
|
||||
final modsRaw = found['mods'];
|
||||
final mods = modsRaw is List
|
||||
? modsRaw.whereType<String>().toList()
|
||||
: const <String>[];
|
||||
final parts = <String>[];
|
||||
for (final m in ['primary', 'alt', 'shift']) {
|
||||
if (!mods.contains(m)) continue;
|
||||
switch (m) {
|
||||
case 'primary': parts.add(isMac ? '⌘' : 'Ctrl'); break;
|
||||
case 'alt': parts.add(isMac ? '⌥' : 'Alt'); break;
|
||||
case 'shift': parts.add(isMac ? '⇧' : 'Shift'); break;
|
||||
}
|
||||
}
|
||||
parts.add(_keyDisplay(keyValue, isMac));
|
||||
return isMac ? parts.join('') : parts.join('+');
|
||||
}
|
||||
|
||||
static String _keyDisplay(String key, bool isMac) {
|
||||
switch (key) {
|
||||
case 'delete': return isMac ? '⌫' : 'Del';
|
||||
case 'enter': return isMac ? '⏎' : 'Enter';
|
||||
case 'arrow_left': return '←';
|
||||
case 'arrow_right':return '→';
|
||||
case 'arrow_up': return '↑';
|
||||
case 'arrow_down': return '↓';
|
||||
}
|
||||
if (key.startsWith('digit')) return key.substring(5);
|
||||
return key.toUpperCase();
|
||||
}
|
||||
}
|
||||
490
flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart
Normal file
490
flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
// flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart
|
||||
//
|
||||
// Shared body widget for the Keyboard Shortcuts configuration page. Both the
|
||||
// desktop (`desktop/pages/desktop_keyboard_shortcuts_page.dart`) and mobile
|
||||
// (`mobile/pages/mobile_keyboard_shortcuts_page.dart`) pages render this
|
||||
// widget inside their own platform-styled Scaffold + AppBar shell.
|
||||
//
|
||||
// The body owns:
|
||||
// * the top-level enable/disable toggle (mirrors the General-tab toggle —
|
||||
// same JSON key, same semantics);
|
||||
// * a grouped list of actions, each with its current binding plus
|
||||
// edit / clear icons;
|
||||
// * the JSON read/write helpers under [kShortcutLocalConfigKey] in the
|
||||
// canonical {enabled, bindings:[{action,mods,key}]} shape;
|
||||
// * the recording-dialog round-trip and conflict-replace bookkeeping;
|
||||
// * "Reset to defaults" (called from the platform AppBar).
|
||||
//
|
||||
// Platform shells supply only:
|
||||
// * the AppBar (with a "Reset to defaults" action that calls
|
||||
// [KeyboardShortcutsPageBodyState.resetToDefaultsWithConfirm]);
|
||||
// * surrounding padding / list-tile vs. dense-row visuals via the
|
||||
// [compact] flag.
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../common.dart';
|
||||
import '../../../consts.dart';
|
||||
import '../../../models/platform_model.dart';
|
||||
import '../../../models/shortcut_model.dart';
|
||||
import 'recording_dialog.dart';
|
||||
|
||||
/// One configurable action — id + i18n key for its label.
|
||||
class KeyboardShortcutActionEntry {
|
||||
final String id;
|
||||
final String labelKey;
|
||||
const KeyboardShortcutActionEntry(this.id, this.labelKey);
|
||||
}
|
||||
|
||||
/// A named group of actions (e.g. "Session Control").
|
||||
class KeyboardShortcutActionGroup {
|
||||
final String titleKey;
|
||||
final List<KeyboardShortcutActionEntry> actions;
|
||||
const KeyboardShortcutActionGroup(this.titleKey, this.actions);
|
||||
}
|
||||
|
||||
/// Canonical action group definitions used by both the desktop and mobile
|
||||
/// configuration pages. The order of groups and entries here is the order
|
||||
/// the user sees in the UI. (Not `const` because the per-tab ids come from
|
||||
/// the `kShortcutActionSwitchTab(n)` helper in `consts.dart`.)
|
||||
final List<KeyboardShortcutActionGroup> kKeyboardShortcutActionGroups = [
|
||||
KeyboardShortcutActionGroup('Session Control', [
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionSendCtrlAltDel, 'Insert Ctrl + Alt + Del'),
|
||||
KeyboardShortcutActionEntry(kShortcutActionInsertLock, 'Insert Lock'),
|
||||
KeyboardShortcutActionEntry(kShortcutActionRefresh, 'Refresh'),
|
||||
KeyboardShortcutActionEntry(kShortcutActionSwitchSides, 'Switch Sides'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionToggleRecording, 'Toggle Recording'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionToggleBlockInput, 'Toggle Block User Input'),
|
||||
]),
|
||||
KeyboardShortcutActionGroup('Display', [
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionToggleFullscreen, 'Toggle Fullscreen'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionSwitchDisplayNext, 'Switch to next display'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionSwitchDisplayPrev, 'Switch to previous display'),
|
||||
KeyboardShortcutActionEntry(kShortcutActionViewMode1to1, 'View Mode 1:1'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionViewModeShrink, 'View Mode Shrink'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionViewModeStretch, 'View Mode Stretch'),
|
||||
]),
|
||||
KeyboardShortcutActionGroup('Other', [
|
||||
KeyboardShortcutActionEntry(kShortcutActionScreenshot, 'Take Screenshot'),
|
||||
KeyboardShortcutActionEntry(kShortcutActionToggleAudio, 'Toggle Audio'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionTogglePrivacyMode, 'Toggle Privacy Mode'),
|
||||
for (var n = 1; n <= 9; n++)
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionSwitchTab(n), 'Switch Tab $n'),
|
||||
]),
|
||||
];
|
||||
|
||||
/// The shared body widget. Render this inside a platform-styled Scaffold.
|
||||
///
|
||||
/// [compact] toggles the desktop dense-row layout (`true`) versus the mobile
|
||||
/// touch-friendly ListTile layout (`false`).
|
||||
///
|
||||
/// [editButtonHint] is shown as the tooltip on the Edit icon. Mobile shells
|
||||
/// use this to clarify that recording requires a physical keyboard.
|
||||
///
|
||||
/// [headerBanner] is an optional widget rendered above the toggle. Mobile
|
||||
/// uses this to show the "Recording requires a physical keyboard" hint.
|
||||
class KeyboardShortcutsPageBody extends StatefulWidget {
|
||||
final bool compact;
|
||||
final String? editButtonHint;
|
||||
final Widget? headerBanner;
|
||||
|
||||
const KeyboardShortcutsPageBody({
|
||||
Key? key,
|
||||
this.compact = true,
|
||||
this.editButtonHint,
|
||||
this.headerBanner,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<KeyboardShortcutsPageBody> createState() =>
|
||||
KeyboardShortcutsPageBodyState();
|
||||
}
|
||||
|
||||
/// Public state so platform shells can call [resetToDefaultsWithConfirm] from
|
||||
/// their AppBar action.
|
||||
class KeyboardShortcutsPageBodyState extends State<KeyboardShortcutsPageBody> {
|
||||
// ----- Persistence helpers -----
|
||||
|
||||
Map<String, dynamic> _readJson() {
|
||||
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
|
||||
if (raw.isEmpty) return {'enabled': false, 'bindings': <dynamic>[]};
|
||||
try {
|
||||
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
||||
parsed['bindings'] ??= <dynamic>[];
|
||||
parsed['enabled'] ??= false;
|
||||
return parsed;
|
||||
} catch (_) {
|
||||
return {'enabled': false, 'bindings': <dynamic>[]};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _writeJson(Map<String, dynamic> json) async {
|
||||
await bind.mainSetLocalOption(
|
||||
key: kShortcutLocalConfigKey, value: jsonEncode(json));
|
||||
// Refresh the matcher cache so writes take effect immediately. On native
|
||||
// this hits the Rust matcher; on Web the bridge forwards to the JS-side
|
||||
// matcher in flutter/web/js/.
|
||||
bind.mainReloadKeyboardShortcuts();
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
/// Replace the bindings entry for [actionId] with [binding]. If [binding]
|
||||
/// is null, removes the existing entry. If the user is replacing a
|
||||
/// conflicting binding, [clearActionId] points at the action whose
|
||||
/// (now-stale) binding should be removed in the same write.
|
||||
Future<void> _setBinding(
|
||||
String actionId, {
|
||||
Map<String, dynamic>? binding,
|
||||
String? clearActionId,
|
||||
}) async {
|
||||
final json = _readJson();
|
||||
final list = ((json['bindings'] as List?) ?? <dynamic>[])
|
||||
.cast<Map<String, dynamic>>()
|
||||
.toList();
|
||||
list.removeWhere((b) {
|
||||
final a = b['action'];
|
||||
return a == actionId || (clearActionId != null && a == clearActionId);
|
||||
});
|
||||
if (binding != null) {
|
||||
list.add(binding);
|
||||
}
|
||||
json['bindings'] = list;
|
||||
await _writeJson(json);
|
||||
}
|
||||
|
||||
Future<void> _setEnabled(bool v) async {
|
||||
final json = _readJson();
|
||||
json['enabled'] = v;
|
||||
// First-time enable: seed defaults if the user has never bound anything.
|
||||
final list = (json['bindings'] as List?) ?? const [];
|
||||
if (v && list.isEmpty) {
|
||||
json['bindings'] = jsonDecode(bind.mainGetDefaultKeyboardShortcuts());
|
||||
}
|
||||
await _writeJson(json);
|
||||
}
|
||||
|
||||
Future<void> _resetToDefaults() async {
|
||||
final json = _readJson();
|
||||
json['bindings'] = jsonDecode(bind.mainGetDefaultKeyboardShortcuts());
|
||||
await _writeJson(json);
|
||||
}
|
||||
|
||||
String _labelFor(String actionId) {
|
||||
for (final g in kKeyboardShortcutActionGroups) {
|
||||
for (final a in g.actions) {
|
||||
if (a.id == actionId) return translate(a.labelKey);
|
||||
}
|
||||
}
|
||||
return actionId;
|
||||
}
|
||||
|
||||
// ----- UI handlers -----
|
||||
|
||||
Future<void> _onEdit(KeyboardShortcutActionEntry entry) async {
|
||||
final json = _readJson();
|
||||
final bindings = ((json['bindings'] as List?) ?? <dynamic>[])
|
||||
.cast<Map<String, dynamic>>();
|
||||
final result = await showRecordingDialog(
|
||||
context: context,
|
||||
actionId: entry.id,
|
||||
actionLabel: translate(entry.labelKey),
|
||||
existingBindings: bindings,
|
||||
actionLabelLookup: _labelFor,
|
||||
);
|
||||
if (result == null) return;
|
||||
await _setBinding(
|
||||
entry.id,
|
||||
binding: result.binding,
|
||||
clearActionId: result.clearActionId,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onClear(KeyboardShortcutActionEntry entry) async {
|
||||
await _setBinding(entry.id, binding: null);
|
||||
}
|
||||
|
||||
/// Public — invoked from the platform AppBar action.
|
||||
Future<void> resetToDefaultsWithConfirm() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(translate('Reset to defaults')),
|
||||
content: Text(translate('shortcut-reset-confirm-tip')),
|
||||
actions: [
|
||||
dialogButton('Cancel',
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
isOutline: true),
|
||||
dialogButton('OK', onPressed: () => Navigator.of(ctx).pop(true)),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await _resetToDefaults();
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Build -----
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final enabled = ShortcutModel.isEnabled();
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (widget.headerBanner != null) ...[
|
||||
widget.headerBanner!,
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
// Top toggle — mirrors the General-tab _OptionCheckBox semantics.
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: enabled,
|
||||
onChanged: (v) async {
|
||||
if (v == null) return;
|
||||
await _setEnabled(v);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _setEnabled(!enabled),
|
||||
child: Text(
|
||||
translate('Enable keyboard shortcuts in remote session'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
translate('shortcut-page-description'),
|
||||
style: TextStyle(color: theme.hintColor),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Disabled visual state when toggle is off — but still scrollable.
|
||||
Opacity(
|
||||
opacity: enabled ? 1.0 : 0.5,
|
||||
child: AbsorbPointer(
|
||||
absorbing: !enabled,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final group in kKeyboardShortcutActionGroups)
|
||||
_buildGroup(context, group),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGroup(BuildContext context, KeyboardShortcutActionGroup group) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
translate(group.titleKey),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(
|
||||
child: Divider(thickness: 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
for (final action in group.actions)
|
||||
widget.compact
|
||||
? _buildCompactRow(context, action)
|
||||
: _buildTouchRow(context, action),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Desktop dense row: label | shortcut | edit | clear, all in one Row.
|
||||
Widget _buildCompactRow(
|
||||
BuildContext context, KeyboardShortcutActionEntry entry) {
|
||||
final shortcut = ShortcutDisplayForActionId.format(entry.id);
|
||||
final hasBinding = shortcut != null;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Text(translate(entry.labelKey)),
|
||||
),
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Text(
|
||||
shortcut ?? '—',
|
||||
style: TextStyle(
|
||||
fontFamily: defaultTargetPlatform == TargetPlatform.windows
|
||||
? 'Consolas'
|
||||
: 'monospace',
|
||||
color: hasBinding ? null : Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: widget.editButtonHint ?? translate('Edit'),
|
||||
onPressed: () => _onEdit(entry),
|
||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||
),
|
||||
SizedBox(
|
||||
width: 40,
|
||||
child: hasBinding
|
||||
? IconButton(
|
||||
tooltip: translate('Clear'),
|
||||
onPressed: () => _onClear(entry),
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Mobile touch row: ListTile with title + subtitle + trailing icons.
|
||||
Widget _buildTouchRow(
|
||||
BuildContext context, KeyboardShortcutActionEntry entry) {
|
||||
final shortcut = ShortcutDisplayForActionId.format(entry.id);
|
||||
final hasBinding = shortcut != null;
|
||||
return ListTile(
|
||||
dense: false,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
title: Text(translate(entry.labelKey)),
|
||||
subtitle: Text(
|
||||
shortcut ?? '—',
|
||||
style: TextStyle(
|
||||
fontFamily: defaultTargetPlatform == TargetPlatform.windows
|
||||
? 'Consolas'
|
||||
: 'monospace',
|
||||
color: hasBinding ? null : Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: widget.editButtonHint ?? translate('Edit'),
|
||||
onPressed: () => _onEdit(entry),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
),
|
||||
if (hasBinding)
|
||||
IconButton(
|
||||
tooltip: translate('Clear'),
|
||||
onPressed: () => _onClear(entry),
|
||||
icon: const Icon(Icons.close),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 48),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Thin wrapper around [ShortcutDisplay.formatFor] that ignores the
|
||||
/// `enabled` flag so the configuration page can always show the user what
|
||||
/// they have bound, even when the feature is currently disabled.
|
||||
class ShortcutDisplayForActionId {
|
||||
static String? format(String actionId) {
|
||||
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
|
||||
if (raw.isEmpty) return null;
|
||||
final Map<String, dynamic> parsed;
|
||||
try {
|
||||
parsed = jsonDecode(raw) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
final list = (parsed['bindings'] as List? ?? const [])
|
||||
.cast<Map<String, dynamic>>();
|
||||
final found = list.firstWhere(
|
||||
(b) => b['action'] == actionId,
|
||||
orElse: () => {},
|
||||
);
|
||||
if (found.isEmpty) return null;
|
||||
|
||||
// Guard against a hand-edited / corrupt config where `key` is missing or
|
||||
// not a string — render the row as unbound instead of crashing the
|
||||
// settings page.
|
||||
final keyValue = found['key'];
|
||||
if (keyValue is! String) return null;
|
||||
|
||||
final isMac = defaultTargetPlatform == TargetPlatform.macOS ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS;
|
||||
// `mods` similarly may be malformed; treat a non-list as no modifiers.
|
||||
final modsRaw = found['mods'];
|
||||
final mods = modsRaw is List
|
||||
? modsRaw.whereType<String>().toList()
|
||||
: const <String>[];
|
||||
final parts = <String>[];
|
||||
for (final m in ['primary', 'alt', 'shift']) {
|
||||
if (!mods.contains(m)) continue;
|
||||
switch (m) {
|
||||
case 'primary':
|
||||
parts.add(isMac ? '⌘' : 'Ctrl');
|
||||
break;
|
||||
case 'alt':
|
||||
parts.add(isMac ? '⌥' : 'Alt');
|
||||
break;
|
||||
case 'shift':
|
||||
parts.add(isMac ? '⇧' : 'Shift');
|
||||
break;
|
||||
}
|
||||
}
|
||||
parts.add(_keyDisplay(keyValue, isMac));
|
||||
return isMac ? parts.join('') : parts.join('+');
|
||||
}
|
||||
|
||||
static String _keyDisplay(String key, bool isMac) {
|
||||
switch (key) {
|
||||
case 'delete':
|
||||
return isMac ? '⌫' : 'Del';
|
||||
case 'enter':
|
||||
return isMac ? '⏎' : 'Enter';
|
||||
case 'arrow_left':
|
||||
return '←';
|
||||
case 'arrow_right':
|
||||
return '→';
|
||||
case 'arrow_up':
|
||||
return '↑';
|
||||
case 'arrow_down':
|
||||
return '↓';
|
||||
}
|
||||
if (key.startsWith('digit')) return key.substring(5);
|
||||
return key.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,371 @@
|
|||
// flutter/lib/common/widgets/keyboard_shortcuts/recording_dialog.dart
|
||||
//
|
||||
// Modal dialog used by the Keyboard Shortcuts settings page to capture a new
|
||||
// key combination for a given action. The dialog listens for KeyDown events,
|
||||
// extracts the modifier set + non-modifier key, validates against the
|
||||
// "must include Ctrl+Alt+Shift (Cmd+Option+Shift on macOS)" rule, and reports
|
||||
// any conflict with another already-bound action.
|
||||
//
|
||||
// On Save, returns the new binding map ({action, mods, key}) plus the
|
||||
// optional id of the action whose binding should be cleared (the conflict
|
||||
// "Replace" path). On Cancel, returns null.
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../../common.dart';
|
||||
|
||||
/// Result of the recording dialog.
|
||||
class RecordingResult {
|
||||
/// The new binding map to write: {action, mods, key}.
|
||||
final Map<String, dynamic> binding;
|
||||
|
||||
/// If the chosen combo conflicted with another action, the user chose
|
||||
/// "Replace" — the caller must clear this action's binding before writing
|
||||
/// the new one.
|
||||
final String? clearActionId;
|
||||
|
||||
RecordingResult(this.binding, this.clearActionId);
|
||||
}
|
||||
|
||||
/// Show the recording dialog.
|
||||
///
|
||||
/// [actionId] is the action being edited (used for the title and to detect
|
||||
/// "binding to itself" — that's not a conflict).
|
||||
/// [actionLabel] is the translated, user-facing action name.
|
||||
/// [existingBindings] is the current bindings list (used for conflict detection).
|
||||
/// [actionLabelLookup] resolves an actionId to its translated label, used in
|
||||
/// the conflict warning.
|
||||
Future<RecordingResult?> showRecordingDialog({
|
||||
required BuildContext context,
|
||||
required String actionId,
|
||||
required String actionLabel,
|
||||
required List<Map<String, dynamic>> existingBindings,
|
||||
required String Function(String) actionLabelLookup,
|
||||
}) {
|
||||
return showDialog<RecordingResult>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => _RecordingDialog(
|
||||
actionId: actionId,
|
||||
actionLabel: actionLabel,
|
||||
existingBindings: existingBindings,
|
||||
actionLabelLookup: actionLabelLookup,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _RecordingDialog extends StatefulWidget {
|
||||
final String actionId;
|
||||
final String actionLabel;
|
||||
final List<Map<String, dynamic>> existingBindings;
|
||||
final String Function(String) actionLabelLookup;
|
||||
|
||||
const _RecordingDialog({
|
||||
required this.actionId,
|
||||
required this.actionLabel,
|
||||
required this.existingBindings,
|
||||
required this.actionLabelLookup,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_RecordingDialog> createState() => _RecordingDialogState();
|
||||
}
|
||||
|
||||
class _RecordingDialogState extends State<_RecordingDialog> {
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
|
||||
// Captured combo. null until the user presses something with a non-modifier.
|
||||
Set<String> _mods = {};
|
||||
String? _key;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_focusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _isMac =>
|
||||
defaultTargetPlatform == TargetPlatform.macOS ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS;
|
||||
|
||||
/// True when the captured combo includes the required Ctrl+Alt+Shift
|
||||
/// (Cmd+Option+Shift on macOS) prefix and a non-modifier key.
|
||||
bool get _hasRequiredPrefix =>
|
||||
_mods.contains('primary') &&
|
||||
_mods.contains('alt') &&
|
||||
_mods.contains('shift');
|
||||
|
||||
/// Return the actionId that this combo currently conflicts with, or null.
|
||||
/// The action being edited is not a conflict with itself.
|
||||
String? get _conflictActionId {
|
||||
if (_key == null || !_hasRequiredPrefix) return null;
|
||||
for (final b in widget.existingBindings) {
|
||||
final otherAction = b['action'] as String?;
|
||||
if (otherAction == null || otherAction == widget.actionId) continue;
|
||||
final otherKey = b['key'] as String?;
|
||||
final otherMods =
|
||||
((b['mods'] as List?) ?? const []).cast<String>().toSet();
|
||||
if (otherKey == _key &&
|
||||
otherMods.length == _mods.length &&
|
||||
otherMods.containsAll(_mods)) {
|
||||
return otherAction;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
Navigator.of(context).pop();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (event is! KeyDownEvent) return KeyEventResult.handled;
|
||||
|
||||
// Ignore modifier-only KeyDowns: don't lock in a partial combo.
|
||||
final logical = event.logicalKey;
|
||||
final keyName = _logicalToKeyName(logical);
|
||||
|
||||
final mods = <String>{};
|
||||
if (HardwareKeyboard.instance.isAltPressed) mods.add('alt');
|
||||
if (HardwareKeyboard.instance.isShiftPressed) mods.add('shift');
|
||||
final primary = _isMac
|
||||
? HardwareKeyboard.instance.isMetaPressed
|
||||
: HardwareKeyboard.instance.isControlPressed;
|
||||
if (primary) mods.add('primary');
|
||||
|
||||
setState(() {
|
||||
_mods = mods;
|
||||
// Only lock in the key when it's a non-modifier we recognize.
|
||||
// Modifier-only KeyDowns (Shift, Ctrl, etc.) leave the captured key
|
||||
// untouched, so the user can adjust modifiers after the fact.
|
||||
if (keyName != null) {
|
||||
_key = keyName;
|
||||
}
|
||||
});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
void _onSave() {
|
||||
if (_key == null || !_hasRequiredPrefix) return;
|
||||
// Sort mods to match the canonical order used by Rust default_bindings:
|
||||
// primary, alt, shift.
|
||||
final ordered = <String>[
|
||||
if (_mods.contains('primary')) 'primary',
|
||||
if (_mods.contains('alt')) 'alt',
|
||||
if (_mods.contains('shift')) 'shift',
|
||||
];
|
||||
final binding = <String, dynamic>{
|
||||
'action': widget.actionId,
|
||||
'mods': ordered,
|
||||
'key': _key!,
|
||||
};
|
||||
Navigator.of(context).pop(RecordingResult(binding, _conflictActionId));
|
||||
}
|
||||
|
||||
String _formatPrefix() {
|
||||
if (_isMac) return 'Cmd+Option+Shift';
|
||||
return 'Ctrl+Alt+Shift';
|
||||
}
|
||||
|
||||
String _formatCombo() {
|
||||
final parts = <String>[];
|
||||
for (final m in ['primary', 'alt', 'shift']) {
|
||||
if (!_mods.contains(m)) continue;
|
||||
switch (m) {
|
||||
case 'primary':
|
||||
parts.add(_isMac ? '⌘' : 'Ctrl');
|
||||
break;
|
||||
case 'alt':
|
||||
parts.add(_isMac ? '⌥' : 'Alt');
|
||||
break;
|
||||
case 'shift':
|
||||
parts.add(_isMac ? '⇧' : 'Shift');
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (_key != null) {
|
||||
parts.add(_keyDisplay(_key!));
|
||||
}
|
||||
if (parts.isEmpty) return translate('shortcut-recording-press-keys-tip');
|
||||
return _isMac ? parts.join('') : parts.join('+');
|
||||
}
|
||||
|
||||
String _keyDisplay(String key) {
|
||||
switch (key) {
|
||||
case 'delete':
|
||||
return _isMac ? '⌫' : 'Del';
|
||||
case 'enter':
|
||||
return _isMac ? '⏎' : 'Enter';
|
||||
case 'arrow_left':
|
||||
return '←';
|
||||
case 'arrow_right':
|
||||
return '→';
|
||||
case 'arrow_up':
|
||||
return '↑';
|
||||
case 'arrow_down':
|
||||
return '↓';
|
||||
}
|
||||
if (key.startsWith('digit')) return key.substring(5);
|
||||
return key.toUpperCase();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasKey = _key != null;
|
||||
final conflictId = _conflictActionId;
|
||||
final hasConflict = conflictId != null;
|
||||
final canSave = hasKey && _hasRequiredPrefix;
|
||||
|
||||
Widget statusLine;
|
||||
if (!hasKey) {
|
||||
statusLine = Text(
|
||||
translate('shortcut-recording-press-keys-tip'),
|
||||
style: TextStyle(color: Theme.of(context).hintColor),
|
||||
);
|
||||
} else if (!_hasRequiredPrefix) {
|
||||
statusLine = Row(
|
||||
children: [
|
||||
Icon(Icons.close, size: 16, color: Colors.red),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'${translate('shortcut-must-include-prefix')} ${_formatPrefix()}',
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (hasConflict) {
|
||||
final otherLabel = widget.actionLabelLookup(conflictId);
|
||||
statusLine = Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_outlined,
|
||||
size: 16, color: Colors.orange.shade700),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'${translate('shortcut-already-bound-to')} "$otherLabel"',
|
||||
style: TextStyle(color: Colors.orange.shade700),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
statusLine = Row(
|
||||
children: [
|
||||
const Icon(Icons.check, size: 16, color: Colors.green),
|
||||
const SizedBox(width: 6),
|
||||
Text(translate('Valid'),
|
||||
style: const TextStyle(color: Colors.green)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final saveLabel = hasConflict ? 'Replace' : 'Save';
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
'${translate('Set Shortcut')}: ${widget.actionLabel}',
|
||||
),
|
||||
content: Focus(
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKeyEvent,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 380),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(translate('shortcut-recording-instruction')),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 18, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).dividerColor),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
_formatCombo(),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: hasKey
|
||||
? Theme.of(context).textTheme.titleLarge?.color
|
||||
: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
statusLine,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
dialogButton('Cancel',
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
isOutline: true),
|
||||
dialogButton(saveLabel, onPressed: canSave ? _onSave : null),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirror of `event_to_key_name` in `src/keyboard/shortcuts.rs` and
|
||||
/// `logicalToKeyName` in `flutter/web/js/src/shortcut_matcher.ts` — keep
|
||||
/// the three in lockstep. Returns null for modifier-only or unsupported keys.
|
||||
static String? _logicalToKeyName(LogicalKeyboardKey k) {
|
||||
if (k == LogicalKeyboardKey.delete) return 'delete';
|
||||
if (k == LogicalKeyboardKey.enter ||
|
||||
k == LogicalKeyboardKey.numpadEnter) return 'enter';
|
||||
if (k == LogicalKeyboardKey.arrowLeft) return 'arrow_left';
|
||||
if (k == LogicalKeyboardKey.arrowRight) return 'arrow_right';
|
||||
if (k == LogicalKeyboardKey.arrowUp) return 'arrow_up';
|
||||
if (k == LogicalKeyboardKey.arrowDown) return 'arrow_down';
|
||||
|
||||
final letters = <LogicalKeyboardKey, String>{
|
||||
LogicalKeyboardKey.keyA: 'a', LogicalKeyboardKey.keyB: 'b',
|
||||
LogicalKeyboardKey.keyC: 'c', LogicalKeyboardKey.keyD: 'd',
|
||||
LogicalKeyboardKey.keyE: 'e', LogicalKeyboardKey.keyF: 'f',
|
||||
LogicalKeyboardKey.keyG: 'g', LogicalKeyboardKey.keyH: 'h',
|
||||
LogicalKeyboardKey.keyI: 'i', LogicalKeyboardKey.keyJ: 'j',
|
||||
LogicalKeyboardKey.keyK: 'k', LogicalKeyboardKey.keyL: 'l',
|
||||
LogicalKeyboardKey.keyM: 'm', LogicalKeyboardKey.keyN: 'n',
|
||||
LogicalKeyboardKey.keyO: 'o', LogicalKeyboardKey.keyP: 'p',
|
||||
LogicalKeyboardKey.keyQ: 'q', LogicalKeyboardKey.keyR: 'r',
|
||||
LogicalKeyboardKey.keyS: 's', LogicalKeyboardKey.keyT: 't',
|
||||
LogicalKeyboardKey.keyU: 'u', LogicalKeyboardKey.keyV: 'v',
|
||||
LogicalKeyboardKey.keyW: 'w', LogicalKeyboardKey.keyX: 'x',
|
||||
LogicalKeyboardKey.keyY: 'y', LogicalKeyboardKey.keyZ: 'z',
|
||||
};
|
||||
if (letters.containsKey(k)) return letters[k];
|
||||
|
||||
final digits = <LogicalKeyboardKey, String>{
|
||||
LogicalKeyboardKey.digit1: 'digit1',
|
||||
LogicalKeyboardKey.digit2: 'digit2',
|
||||
LogicalKeyboardKey.digit3: 'digit3',
|
||||
LogicalKeyboardKey.digit4: 'digit4',
|
||||
LogicalKeyboardKey.digit5: 'digit5',
|
||||
LogicalKeyboardKey.digit6: 'digit6',
|
||||
LogicalKeyboardKey.digit7: 'digit7',
|
||||
LogicalKeyboardKey.digit8: 'digit8',
|
||||
LogicalKeyboardKey.digit9: 'digit9',
|
||||
};
|
||||
if (digits.containsKey(k)) return digits[k];
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -16,22 +16,18 @@ import 'package:get/get.dart';
|
|||
|
||||
bool isEditOsPassword = false;
|
||||
|
||||
// macOS privacy mode blacks out all online displays, so switching the remote
|
||||
// display does not weaken the local privacy protection.
|
||||
bool allowDisplaySwitchInPrivacyMode(PeerInfo pi) {
|
||||
return pi.platform == kPeerPlatformMacOS;
|
||||
}
|
||||
|
||||
class TTextMenu {
|
||||
final Widget child;
|
||||
final VoidCallback? onPressed;
|
||||
Widget? trailingIcon;
|
||||
bool divider;
|
||||
final String? actionId;
|
||||
TTextMenu(
|
||||
{required this.child,
|
||||
required this.onPressed,
|
||||
this.trailingIcon,
|
||||
this.divider = false});
|
||||
this.divider = false,
|
||||
this.actionId});
|
||||
|
||||
Widget getChild() {
|
||||
if (trailingIcon != null) {
|
||||
|
|
@ -235,7 +231,8 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
|||
v.add(
|
||||
TTextMenu(
|
||||
child: Text('${translate("Insert Ctrl + Alt + Del")}'),
|
||||
onPressed: () => bind.sessionCtrlAltDel(sessionId: sessionId)),
|
||||
onPressed: () => bind.sessionCtrlAltDel(sessionId: sessionId),
|
||||
actionId: kShortcutActionSendCtrlAltDel),
|
||||
);
|
||||
}
|
||||
// restart
|
||||
|
|
@ -256,7 +253,8 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
|||
v.add(
|
||||
TTextMenu(
|
||||
child: Text(translate('Insert Lock')),
|
||||
onPressed: () => bind.sessionLockScreen(sessionId: sessionId)),
|
||||
onPressed: () => bind.sessionLockScreen(sessionId: sessionId),
|
||||
actionId: kShortcutActionInsertLock),
|
||||
);
|
||||
}
|
||||
// blockUserInput
|
||||
|
|
@ -274,7 +272,8 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
|||
sessionId: sessionId,
|
||||
value: '${blockInput.value ? 'un' : ''}block-input');
|
||||
blockInput.value = !blockInput.value;
|
||||
}));
|
||||
},
|
||||
actionId: kShortcutActionToggleBlockInput));
|
||||
}
|
||||
// switchSides
|
||||
if (isDefaultConn &&
|
||||
|
|
@ -286,13 +285,15 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
|||
v.add(TTextMenu(
|
||||
child: Text(translate('Switch Sides')),
|
||||
onPressed: () =>
|
||||
showConfirmSwitchSidesDialog(sessionId, id, ffi.dialogManager)));
|
||||
showConfirmSwitchSidesDialog(sessionId, id, ffi.dialogManager),
|
||||
actionId: kShortcutActionSwitchSides));
|
||||
}
|
||||
// refresh
|
||||
if (pi.version.isNotEmpty) {
|
||||
v.add(TTextMenu(
|
||||
child: Text(translate('Refresh')),
|
||||
onPressed: () => sessionRefreshVideo(sessionId, pi),
|
||||
actionId: kShortcutActionRefresh,
|
||||
));
|
||||
}
|
||||
// record
|
||||
|
|
@ -314,7 +315,8 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
|||
)
|
||||
],
|
||||
),
|
||||
onPressed: () => ffi.recordingModel.toggle()));
|
||||
onPressed: () => ffi.recordingModel.toggle(),
|
||||
actionId: kShortcutActionToggleRecording));
|
||||
}
|
||||
|
||||
// to-do:
|
||||
|
|
@ -348,6 +350,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
|||
});
|
||||
}
|
||||
},
|
||||
actionId: kShortcutActionScreenshot,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -358,6 +361,13 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
|||
onPressed: () => onCopyFingerprint(FingerprintState.find(id).value),
|
||||
));
|
||||
}
|
||||
// Register tagged callbacks with the shortcut model so global keyboard
|
||||
// shortcuts can dispatch the same actions as the toolbar menu items.
|
||||
for (final menu in v) {
|
||||
if (menu.actionId != null && menu.onPressed != null) {
|
||||
ffi.shortcutModel.register(menu.actionId!, menu.onPressed!);
|
||||
}
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
|
|
@ -690,9 +700,8 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
|||
child: Text(translate('Lock after session end'))));
|
||||
}
|
||||
|
||||
final privacyModeState = PrivacyModeState.find(id);
|
||||
if (pi.isSupportMultiDisplay &&
|
||||
(privacyModeState.isEmpty || allowDisplaySwitchInPrivacyMode(pi)) &&
|
||||
PrivacyModeState.find(id).isEmpty &&
|
||||
pi.displaysCount.value > 1 &&
|
||||
bind.mainGetUserDefaultOption(key: kKeyShowMonitorsToolbar) == 'Y') {
|
||||
final value =
|
||||
|
|
@ -766,25 +775,15 @@ List<TToggleMenu> toolbarPrivacyMode(
|
|||
final ffiModel = ffi.ffiModel;
|
||||
final pi = ffiModel.pi;
|
||||
final sessionId = ffi.sessionId;
|
||||
final hasPrivacyModePermission = ffiModel.permissions['privacy_mode'] != false;
|
||||
|
||||
// Backend revocation already attempts to turn privacy mode off.
|
||||
// Still keep this menu when privacy mode is active, so users can turn it off
|
||||
// if there is a sync delay, version mismatch, or off attempt failure.
|
||||
if (!hasPrivacyModePermission && privacyModeState.isEmpty) {
|
||||
return []; // No permission and not active, hide options.
|
||||
}
|
||||
|
||||
getDefaultMenu(Future<void> Function(SessionID sid, String opt) toggleFunc) {
|
||||
final enabled =
|
||||
!ffiModel.viewOnly && (hasPrivacyModePermission || privacyModeState.isNotEmpty);
|
||||
final enabled = !ffi.ffiModel.viewOnly;
|
||||
return TToggleMenu(
|
||||
value: privacyModeState.isNotEmpty,
|
||||
onChanged: enabled
|
||||
? (value) {
|
||||
if (value == null) return;
|
||||
if (!allowDisplaySwitchInPrivacyMode(pi) &&
|
||||
ffiModel.pi.currentDisplay != 0 &&
|
||||
if (ffiModel.pi.currentDisplay != 0 &&
|
||||
ffiModel.pi.currentDisplay != kAllDisplayValue) {
|
||||
msgBox(
|
||||
sessionId,
|
||||
|
|
@ -827,29 +826,18 @@ List<TToggleMenu> toolbarPrivacyMode(
|
|||
})
|
||||
];
|
||||
} else {
|
||||
final visibleImpls = hasPrivacyModePermission
|
||||
? privacyModeImpls
|
||||
: privacyModeImpls.where((e) {
|
||||
final implKey = (e as List<dynamic>)[0] as String;
|
||||
return privacyModeState.value == implKey;
|
||||
}).toList();
|
||||
return visibleImpls.map((e) {
|
||||
return privacyModeImpls.map((e) {
|
||||
final implKey = (e as List<dynamic>)[0] as String;
|
||||
final implName = (e)[1] as String;
|
||||
final enabled = !ffiModel.viewOnly &&
|
||||
(hasPrivacyModePermission || privacyModeState.value == implKey);
|
||||
return TToggleMenu(
|
||||
child: Text(translate(implName)),
|
||||
value: privacyModeState.value == implKey,
|
||||
onChanged: enabled
|
||||
? (value) {
|
||||
if (value == null) return;
|
||||
if (value && !hasPrivacyModePermission) return;
|
||||
togglePrivacyModeTime = DateTime.now();
|
||||
bind.sessionTogglePrivacyMode(
|
||||
sessionId: sessionId, implKey: implKey, on: value);
|
||||
}
|
||||
: null);
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
togglePrivacyModeTime = DateTime.now();
|
||||
bind.sessionTogglePrivacyMode(
|
||||
sessionId: sessionId, implKey: implKey, on: value);
|
||||
});
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,9 +114,6 @@ const String kOptionTerminalPersistent = "terminal-persistent";
|
|||
const String kOptionEnableTunnel = "enable-tunnel";
|
||||
const String kOptionEnableRemoteRestart = "enable-remote-restart";
|
||||
const String kOptionEnableBlockInput = "enable-block-input";
|
||||
const String kOptionEnablePrivacyMode = "enable-privacy-mode";
|
||||
const String kOptionEnablePermChangeInAcceptWindow =
|
||||
"enable-perm-change-in-accept-window";
|
||||
const String kOptionAllowRemoteConfigModification =
|
||||
"allow-remote-config-modification";
|
||||
const String kOptionVerificationMethod = "verification-method";
|
||||
|
|
@ -142,10 +139,6 @@ const String kOptionSwapLeftRightMouse = "swap-left-right-mouse";
|
|||
const String kOptionCodecPreference = "codec-preference";
|
||||
const String kOptionRemoteMenubarDragLeft = "remote-menubar-drag-left";
|
||||
const String kOptionRemoteMenubarDragRight = "remote-menubar-drag-right";
|
||||
const String kOptionRemoteMenubarEdge = "remote-menubar-edge";
|
||||
const String kOptionRemoteMenubarFraction = "remote-menubar-frac";
|
||||
const String kOptionAllowMultiEdgeToolbarDock =
|
||||
"allow-multi-edge-toolbar-dock";
|
||||
const String kOptionHideAbTagsPanel = "hideAbTagsPanel";
|
||||
const String kOptionRemoteMenubarState = "remoteMenubarState";
|
||||
const String kOptionPeerSorting = "peer-sorting";
|
||||
|
|
@ -693,3 +686,24 @@ extension WindowsTargetExt on int {
|
|||
}
|
||||
|
||||
const kCheckSoftwareUpdateFinish = 'check_software_update_finish';
|
||||
|
||||
// Keyboard shortcut Action IDs - must match src/keyboard/shortcuts.rs::action_id.
|
||||
const kShortcutActionSendCtrlAltDel = 'send_ctrl_alt_del';
|
||||
const kShortcutActionToggleFullscreen = 'toggle_fullscreen';
|
||||
const kShortcutActionSwitchDisplayNext = 'switch_display_next';
|
||||
const kShortcutActionSwitchDisplayPrev = 'switch_display_prev';
|
||||
const kShortcutActionScreenshot = 'screenshot';
|
||||
const kShortcutActionInsertLock = 'insert_lock';
|
||||
const kShortcutActionRefresh = 'refresh';
|
||||
const kShortcutActionToggleAudio = 'toggle_audio';
|
||||
const kShortcutActionToggleBlockInput = 'toggle_block_input';
|
||||
const kShortcutActionToggleRecording = 'toggle_recording';
|
||||
const kShortcutActionTogglePrivacyMode = 'toggle_privacy_mode';
|
||||
const kShortcutActionViewMode1to1 = 'view_mode_1_to_1';
|
||||
const kShortcutActionViewModeShrink = 'view_mode_shrink';
|
||||
const kShortcutActionViewModeStretch = 'view_mode_stretch';
|
||||
const kShortcutActionSwitchSides = 'switch_sides';
|
||||
String kShortcutActionSwitchTab(int n) => 'switch_tab_$n';
|
||||
|
||||
const kShortcutLocalConfigKey = 'keyboard-shortcuts';
|
||||
const kShortcutEventName = 'shortcut_triggered';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
// flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart
|
||||
//
|
||||
// Desktop shell for the Keyboard Shortcuts configuration page. Users land
|
||||
// here from the General settings tab. The page exposes:
|
||||
// * A top-level enable/disable toggle (mirrors the General-tab toggle —
|
||||
// same JSON key, same semantics).
|
||||
// * A grouped, scrollable list of actions, each with a current binding and
|
||||
// edit / clear icons.
|
||||
// * An AppBar "Reset to defaults" action with a confirmation dialog.
|
||||
//
|
||||
// All edits write back to LocalConfig under [kShortcutLocalConfigKey] in the
|
||||
// canonical {enabled, bindings:[{action,mods,key}]} shape that the Rust and
|
||||
// Web matchers consume.
|
||||
//
|
||||
// The body — group definitions, JSON I/O, conflict-replace flow,
|
||||
// recording-dialog round-trip — lives in
|
||||
// `common/widgets/keyboard_shortcuts/page_body.dart` and is shared with the
|
||||
// mobile shell at `mobile/pages/mobile_keyboard_shortcuts_page.dart`.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../common/widgets/keyboard_shortcuts/page_body.dart';
|
||||
|
||||
class DesktopKeyboardShortcutsPage extends StatefulWidget {
|
||||
const DesktopKeyboardShortcutsPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<DesktopKeyboardShortcutsPage> createState() =>
|
||||
_DesktopKeyboardShortcutsPageState();
|
||||
}
|
||||
|
||||
class _DesktopKeyboardShortcutsPageState
|
||||
extends State<DesktopKeyboardShortcutsPage> {
|
||||
final GlobalKey<KeyboardShortcutsPageBodyState> _bodyKey = GlobalKey();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(translate('Keyboard Shortcuts')),
|
||||
actions: [
|
||||
TextButton.icon(
|
||||
onPressed: () =>
|
||||
_bodyKey.currentState?.resetToDefaultsWithConfirm(),
|
||||
icon: const Icon(Icons.restore),
|
||||
label: Text(translate('Reset to defaults')),
|
||||
).marginOnly(right: 12),
|
||||
],
|
||||
),
|
||||
body: KeyboardShortcutsPageBody(
|
||||
key: _bodyKey,
|
||||
compact: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,12 +10,14 @@ import 'package:flutter_hbb/common/widgets/audio_input.dart';
|
|||
import 'package:flutter_hbb/common/widgets/setting_widgets.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/desktop_keyboard_shortcuts_page.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
|
||||
import 'package:flutter_hbb/mobile/widgets/dialog.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/printer_model.dart';
|
||||
import 'package:flutter_hbb/models/server_model.dart';
|
||||
import 'package:flutter_hbb/models/shortcut_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:flutter_hbb/plugin/manager.dart';
|
||||
import 'package:flutter_hbb/plugin/widgets/desktop_settings.dart';
|
||||
|
|
@ -421,11 +423,57 @@ class _GeneralState extends State<_General> {
|
|||
if (!isWeb) audio(context),
|
||||
if (!isWeb) record(context),
|
||||
if (!isWeb) WaylandCard(),
|
||||
other()
|
||||
other(),
|
||||
if (!bind.isIncomingOnly()) keyboardShortcuts(),
|
||||
],
|
||||
).marginOnly(bottom: _kListViewBottomMargin);
|
||||
}
|
||||
|
||||
Widget keyboardShortcuts() {
|
||||
// The bindings JSON (LocalConfig key `keyboard-shortcuts`) is the single
|
||||
// source of truth — it embeds an `enabled` boolean alongside the bindings
|
||||
// list. We mutate the JSON in place via _OptionCheckBox's optGetter /
|
||||
// optSetter hooks rather than introducing a parallel boolean key, so the
|
||||
// Rust matcher and the Web matcher both read the same flag without drift.
|
||||
return _Card(title: 'Keyboard Shortcuts', children: [
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Enable keyboard shortcuts in remote session',
|
||||
kShortcutLocalConfigKey,
|
||||
isServer: false,
|
||||
optGetter: ShortcutModel.isEnabled,
|
||||
optSetter: (k, v) async {
|
||||
final raw = bind.mainGetLocalOption(key: k);
|
||||
Map<String, dynamic> parsed = {};
|
||||
if (raw.isNotEmpty) {
|
||||
try {
|
||||
parsed = jsonDecode(raw) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
parsed = {};
|
||||
}
|
||||
}
|
||||
parsed['enabled'] = v;
|
||||
parsed['bindings'] ??= <dynamic>[];
|
||||
// Seed defaults the first time the user enables shortcuts so the
|
||||
// common combos (Ctrl+Alt+Shift+Enter for fullscreen, etc.) work
|
||||
// out of the box. Mirrors the same logic on the dedicated config
|
||||
// page.
|
||||
final list = (parsed['bindings'] as List?) ?? const [];
|
||||
if (v && list.isEmpty) {
|
||||
parsed['bindings'] =
|
||||
jsonDecode(bind.mainGetDefaultKeyboardShortcuts());
|
||||
}
|
||||
await bind.mainSetLocalOption(key: k, value: jsonEncode(parsed));
|
||||
// Refresh the matcher cache so the new flag / bindings take effect
|
||||
// immediately. On native this hits the Rust matcher; on Web the
|
||||
// bridge forwards to the JS-side matcher in flutter/web/js/.
|
||||
bind.mainReloadKeyboardShortcuts();
|
||||
},
|
||||
),
|
||||
_ShortcutsConfigureRow(),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget theme() {
|
||||
final current = MyTheme.getThemeModePreference().toShortString();
|
||||
onChanged(String value) async {
|
||||
|
|
@ -488,16 +536,6 @@ class _GeneralState extends State<_General> {
|
|||
_OptionCheckBox(context, 'Confirm before closing multiple tabs',
|
||||
kOptionEnableConfirmClosingTabs,
|
||||
isServer: false),
|
||||
if (!bind.isIncomingOnly())
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'allow-remote-toolbar-docking-any-edge',
|
||||
kOptionAllowMultiEdgeToolbarDock,
|
||||
isServer: false,
|
||||
update: (_) {
|
||||
reloadAllWindows();
|
||||
},
|
||||
),
|
||||
_OptionCheckBox(context, 'Adaptive bitrate', kOptionEnableAbr),
|
||||
if (!isWeb) wallpaper(),
|
||||
if (!isWeb && !bind.isIncomingOnly()) ...[
|
||||
|
|
@ -1072,10 +1110,6 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
|||
_OptionCheckBox(context, 'Enable blocking user input',
|
||||
kOptionEnableBlockInput,
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
if (bind.mainSupportedPrivacyModeImpls() != '[]')
|
||||
_OptionCheckBox(
|
||||
context, 'Enable privacy mode', kOptionEnablePrivacyMode,
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
_OptionCheckBox(context, 'Enable remote configuration modification',
|
||||
kOptionAllowRemoteConfigModification,
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
|
|
@ -2960,6 +2994,37 @@ class _CountDownButtonState extends State<_CountDownButton> {
|
|||
}
|
||||
}
|
||||
|
||||
// Tappable row that pushes the shortcut configuration page.
|
||||
class _ShortcutsConfigureRow extends StatelessWidget {
|
||||
// ignore: unused_element
|
||||
const _ShortcutsConfigureRow({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => const DesktopKeyboardShortcutsPage(),
|
||||
));
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(translate('Configure shortcuts...')),
|
||||
),
|
||||
Icon(Icons.arrow_forward_ios,
|
||||
size: 16, color: disabledTextColor(context, true))
|
||||
.marginOnly(right: 4),
|
||||
],
|
||||
).marginOnly(
|
||||
left: _kCheckBoxLeftMargin,
|
||||
top: 6,
|
||||
bottom: 6,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region dialogs
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import '../../common/widgets/toolbar.dart';
|
|||
import '../../models/model.dart';
|
||||
import '../../models/input_model.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../../models/shortcut_model.dart';
|
||||
import '../../common/shared_state.dart';
|
||||
import '../../utils/image.dart';
|
||||
import '../widgets/remote_toolbar.dart';
|
||||
|
|
@ -126,6 +127,19 @@ class _RemotePageState extends State<RemotePage>
|
|||
_ffi.ffiModel.pi.platform, _ffi.dialogManager);
|
||||
_ffi.recordingModel
|
||||
.updateStatus(bind.sessionGetIsRecording(sessionId: _ffi.sessionId));
|
||||
// Seed shortcut action callbacks once the session is ready, so that
|
||||
// global keyboard shortcuts work even if the user never opens the
|
||||
// toolbar menu. The returned list is intentionally discarded — the
|
||||
// side effect of registering callbacks (inside toolbarControls) is
|
||||
// what we want here.
|
||||
if (mounted) {
|
||||
toolbarControls(context, widget.id, _ffi);
|
||||
// Register the default-bound actions that `toolbarControls` doesn't
|
||||
// own (fullscreen, switch display, switch tab). Done in addition,
|
||||
// not instead of, the toolbar registration above.
|
||||
registerSessionShortcutActions(_ffi,
|
||||
tabController: widget.tabController);
|
||||
}
|
||||
});
|
||||
_ffi.canvasModel.initializeEdgeScrollFallback(this);
|
||||
_ffi.start(
|
||||
|
|
|
|||
|
|
@ -610,24 +610,19 @@ class _PrivilegeBoard extends StatefulWidget {
|
|||
class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
late final client = widget.client;
|
||||
Widget buildPermissionIcon(bool enabled, IconData iconData,
|
||||
Function(bool)? onTap, String tooltipText,
|
||||
{required bool canModify}) {
|
||||
Function(bool)? onTap, String tooltipText) {
|
||||
return Tooltip(
|
||||
message: "$tooltipText: ${enabled ? "ON" : "OFF"}",
|
||||
waitDuration: Duration.zero,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: enabled
|
||||
? (canModify ? MyTheme.accent : MyTheme.accent.withOpacity(0.6))
|
||||
: Colors.grey[700],
|
||||
color: enabled ? MyTheme.accent : Colors.grey[700],
|
||||
borderRadius: BorderRadius.circular(10.0),
|
||||
),
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: InkWell(
|
||||
onTap: canModify
|
||||
? () =>
|
||||
checkClickTime(widget.client.id, () => onTap?.call(!enabled))
|
||||
: null,
|
||||
onTap: () =>
|
||||
checkClickTime(widget.client.id, () => onTap?.call(!enabled)),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
|
|
@ -648,9 +643,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||
Widget build(BuildContext context) {
|
||||
final crossAxisCount = 4;
|
||||
final spacing = 10.0;
|
||||
final canModifyPermission =
|
||||
bind.mainGetBuildinOption(key: kOptionEnablePermChangeInAcceptWindow) !=
|
||||
'N';
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 160.0,
|
||||
|
|
@ -697,7 +689,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||
});
|
||||
},
|
||||
translate('Enable audio'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.recording,
|
||||
|
|
@ -712,7 +703,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||
});
|
||||
},
|
||||
translate('Enable recording session'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
]
|
||||
: [
|
||||
|
|
@ -729,7 +719,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||
});
|
||||
},
|
||||
translate('Enable keyboard/mouse'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.clipboard,
|
||||
|
|
@ -744,7 +733,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||
});
|
||||
},
|
||||
translate('Enable clipboard'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.audio,
|
||||
|
|
@ -759,7 +747,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||
});
|
||||
},
|
||||
translate('Enable audio'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.file,
|
||||
|
|
@ -774,7 +761,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||
});
|
||||
},
|
||||
translate('Enable file copy and paste'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.restart,
|
||||
|
|
@ -789,7 +775,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||
});
|
||||
},
|
||||
translate('Enable remote restart'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.recording,
|
||||
|
|
@ -804,7 +789,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||
});
|
||||
},
|
||||
translate('Enable recording session'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
// only windows support block input
|
||||
if (isWindows)
|
||||
|
|
@ -821,23 +805,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
|||
});
|
||||
},
|
||||
translate('Enable blocking user input'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
if (bind.mainSupportedPrivacyModeImpls() != '[]')
|
||||
buildPermissionIcon(
|
||||
client.privacyMode,
|
||||
Icons.visibility_off,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "privacy_mode",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.privacyMode = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable privacy mode'),
|
||||
canModify: canModifyPermission,
|
||||
)
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ class TerminalPage extends StatefulWidget {
|
|||
final bool? isSharedPassword;
|
||||
final String? connToken;
|
||||
final int terminalId;
|
||||
|
||||
/// Tab key for focus management, passed from parent to avoid duplicate construction
|
||||
final String tabKey;
|
||||
final SimpleWrapper<State<TerminalPage>?> _lastState = SimpleWrapper(null);
|
||||
|
|
@ -44,9 +43,6 @@ class TerminalPage extends StatefulWidget {
|
|||
|
||||
class _TerminalPageState extends State<TerminalPage>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
static const EdgeInsets _defaultTerminalPadding =
|
||||
EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
|
||||
|
||||
late FFI _ffi;
|
||||
late TerminalModel _terminalModel;
|
||||
double? _cellHeight;
|
||||
|
|
@ -159,27 +155,13 @@ class _TerminalPageState extends State<TerminalPage>
|
|||
// extra space left after dividing the available height by the height of a single
|
||||
// terminal row (`_cellHeight`) and distributing it evenly as top and bottom padding.
|
||||
EdgeInsets _calculatePadding(double heightPx) {
|
||||
final cellHeight = _cellHeight;
|
||||
if (!heightPx.isFinite ||
|
||||
heightPx <= 0 ||
|
||||
cellHeight == null ||
|
||||
!cellHeight.isFinite ||
|
||||
cellHeight <= 0) {
|
||||
return _defaultTerminalPadding;
|
||||
}
|
||||
final rows = (heightPx / cellHeight).floor();
|
||||
if (rows <= 0) {
|
||||
return _defaultTerminalPadding;
|
||||
}
|
||||
final extraSpace = heightPx - rows * cellHeight;
|
||||
if (!extraSpace.isFinite || extraSpace < 0) {
|
||||
return _defaultTerminalPadding;
|
||||
if (_cellHeight == null) {
|
||||
return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
|
||||
}
|
||||
final rows = (heightPx / _cellHeight!).floor();
|
||||
final extraSpace = heightPx - rows * _cellHeight!;
|
||||
final topBottom = extraSpace / 2.0;
|
||||
return EdgeInsets.symmetric(
|
||||
horizontal: _defaultTerminalPadding.horizontal / 2,
|
||||
vertical: topBottom,
|
||||
);
|
||||
return EdgeInsets.symmetric(horizontal: 5.0, vertical: topBottom);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -46,7 +46,6 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||
.setTitle(getWindowNameWithId(id));
|
||||
};
|
||||
tabController.onRemoved = (_, id) => onRemoveId(id);
|
||||
tabController.onCloseWindow = _closeWindowFromConnection;
|
||||
final terminalId = params['terminalId'] ?? _nextTerminalId++;
|
||||
tabController.add(_createTerminalTab(
|
||||
peerId: params['id'],
|
||||
|
|
@ -145,8 +144,6 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||
_windowClosing = true;
|
||||
final tabKeys = tabController.state.value.tabs.map((t) => t.key).toList();
|
||||
// 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.
|
||||
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.
|
||||
|
|
@ -371,34 +368,8 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||
final persistentSessions =
|
||||
args['persistent_sessions'] as List<dynamic>? ?? [];
|
||||
final sortedSessions = persistentSessions.whereType<int>().toList()..sort();
|
||||
var peerId = args['peer_id'] as String? ?? '';
|
||||
if (peerId.isEmpty) {
|
||||
if (tabController.state.value.tabs.isEmpty ||
|
||||
tabController.state.value.selected >=
|
||||
tabController.state.value.tabs.length) {
|
||||
debugPrint('[TerminalTabPage] Skip restore: no selected tab');
|
||||
return;
|
||||
}
|
||||
final currentTab = tabController.state.value.selectedTabInfo;
|
||||
final parsed = _parseTabKey(currentTab.key);
|
||||
if (parsed == null) return;
|
||||
peerId = parsed.$1;
|
||||
}
|
||||
final existingTerminalIds = tabController.state.value.tabs
|
||||
.map((tab) => _parseTabKey(tab.key))
|
||||
.where((parsed) => parsed != null && parsed.$1 == peerId)
|
||||
.map((parsed) => parsed!.$2)
|
||||
.toSet();
|
||||
if (existingTerminalIds.isEmpty) {
|
||||
debugPrint(
|
||||
'[TerminalTabPage] Skip restore: no seed tab for peer $peerId');
|
||||
return;
|
||||
}
|
||||
for (final terminalId in sortedSessions) {
|
||||
if (!existingTerminalIds.add(terminalId)) {
|
||||
continue;
|
||||
}
|
||||
_addNewTerminal(peerId, terminalId: terminalId);
|
||||
_addNewTerminalForCurrentPeer(terminalId: terminalId);
|
||||
// A delay is required to ensure the UI has sufficient time to update
|
||||
// before adding the next terminal. Without this delay, `_TerminalPageState::dispose()`
|
||||
// may be called prematurely while the tab widget is still in the tab controller.
|
||||
|
|
@ -575,11 +546,6 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _closeWindowFromConnection() async {
|
||||
await _closeAllTabs();
|
||||
await WindowController.fromWindowId(windowId()).close();
|
||||
}
|
||||
|
||||
int windowId() {
|
||||
return widget.params["windowId"];
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -99,7 +99,6 @@ class DesktopTabController {
|
|||
/// index, key
|
||||
Function(int, String)? onRemoved;
|
||||
Function(String)? onSelected;
|
||||
Future<void> Function()? onCloseWindow;
|
||||
|
||||
DesktopTabController(
|
||||
{required this.tabType, this.onRemoved, this.onSelected});
|
||||
|
|
@ -593,13 +592,13 @@ class _DesktopTabState extends State<DesktopTab>
|
|||
}
|
||||
|
||||
Widget _buildBar() {
|
||||
final isIncomingHomePage = bind.isIncomingOnly() && isInHomePage();
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
// custom double tap handler
|
||||
onTap: !isIncomingHomePage && showMaximize
|
||||
onTap: !(bind.isIncomingOnly() && isInHomePage()) &&
|
||||
showMaximize
|
||||
? () {
|
||||
final current = DateTime.now().millisecondsSinceEpoch;
|
||||
final elapsed = current - _lastClickTime;
|
||||
|
|
@ -610,7 +609,7 @@ class _DesktopTabState extends State<DesktopTab>
|
|||
.then((value) => stateGlobal.setMaximized(value));
|
||||
}
|
||||
}
|
||||
: (isIncomingHomePage ? () {} : null), // Keep tap recognizer for Windows touch.
|
||||
: null,
|
||||
onPanStart: (_) => startDragging(isMainWindow),
|
||||
onPanCancel: () {
|
||||
// We want to disable dragging of the tab area in the tab bar.
|
||||
|
|
|
|||
95
flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart
Normal file
95
flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart
|
||||
//
|
||||
// Mobile shell for the Keyboard Shortcuts configuration page. Mirrors
|
||||
// `desktop/pages/desktop_keyboard_shortcuts_page.dart` but with a touch-
|
||||
// friendly layout (ListTile rows instead of dense rows) and a hint banner
|
||||
// that explains the recording flow only works with a physical keyboard.
|
||||
//
|
||||
// All actual logic — group definitions, JSON I/O, conflict-replace flow,
|
||||
// recording-dialog round-trip, "Reset to defaults" — lives in the shared
|
||||
// `common/widgets/keyboard_shortcuts/page_body.dart`. This file only
|
||||
// supplies the AppBar, the AppBar action, and the platform hint banner.
|
||||
//
|
||||
// Mobile keyboard detection limitation: Flutter has no reliable
|
||||
// "is a physical keyboard attached?" API on iOS or Android. Soft keyboards
|
||||
// don't generate the `KeyDownEvent`s the recording dialog listens for, so
|
||||
// in practice the dialog only does anything useful when the user actually
|
||||
// has a hardware keyboard plugged in (USB / Bluetooth / Smart Connector).
|
||||
// For V1 we don't try to detect attachment — we just surface the
|
||||
// requirement as an in-page hint instead of disabling the Edit button.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../common/widgets/keyboard_shortcuts/page_body.dart';
|
||||
|
||||
class MobileKeyboardShortcutsPage extends StatefulWidget {
|
||||
const MobileKeyboardShortcutsPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<MobileKeyboardShortcutsPage> createState() =>
|
||||
_MobileKeyboardShortcutsPageState();
|
||||
}
|
||||
|
||||
class _MobileKeyboardShortcutsPageState
|
||||
extends State<MobileKeyboardShortcutsPage> {
|
||||
final GlobalKey<KeyboardShortcutsPageBodyState> _bodyKey = GlobalKey();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(translate('Keyboard Shortcuts')),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: translate('Reset to defaults'),
|
||||
onPressed: () =>
|
||||
_bodyKey.currentState?.resetToDefaultsWithConfirm(),
|
||||
icon: const Icon(Icons.restore),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: KeyboardShortcutsPageBody(
|
||||
key: _bodyKey,
|
||||
compact: false,
|
||||
editButtonHint: translate('shortcut-mobile-physical-keyboard-tip'),
|
||||
headerBanner: _PhysicalKeyboardHintBanner(theme: theme),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A muted info banner shown above the master toggle on mobile. We can't
|
||||
/// reliably detect whether a physical keyboard is attached, so instead of
|
||||
/// disabling the Edit button we surface the requirement up front.
|
||||
class _PhysicalKeyboardHintBanner extends StatelessWidget {
|
||||
final ThemeData theme;
|
||||
const _PhysicalKeyboardHintBanner({required this.theme});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = theme.colorScheme.primary.withOpacity(0.08);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.info_outline,
|
||||
size: 18, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
translate('shortcut-mobile-physical-keyboard-tip'),
|
||||
style: TextStyle(color: theme.colorScheme.onSurface),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import '../../common/widgets/remote_input.dart';
|
|||
import '../../models/input_model.dart';
|
||||
import '../../models/model.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../../models/shortcut_model.dart';
|
||||
import '../../utils/image.dart';
|
||||
import '../widgets/dialog.dart';
|
||||
import '../widgets/custom_scale_widget.dart';
|
||||
|
|
@ -119,6 +120,18 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||
}
|
||||
_disableAndroidSoftKeyboard(
|
||||
isKeyboardVisible: keyboardVisibilityController.isVisible);
|
||||
// Seed shortcut action callbacks once the session is ready, so that
|
||||
// global keyboard shortcuts work even if the user never opens the
|
||||
// toolbar menu. The returned list is intentionally discarded — the
|
||||
// side effect of registering callbacks (inside toolbarControls) is
|
||||
// what we want here.
|
||||
if (mounted) {
|
||||
toolbarControls(context, widget.id, gFFI);
|
||||
// Mobile has no DesktopTabController, so tab-switch shortcuts
|
||||
// remain unregistered (they will simply log a no-handler debug
|
||||
// line if a mobile user binds one — they have no tabs to switch).
|
||||
registerSessionShortcutActions(gFFI);
|
||||
}
|
||||
});
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
|
@ -1183,8 +1196,7 @@ void showOptions(
|
|||
List<TToggleMenu> privacyModeList = [];
|
||||
// privacy mode
|
||||
final privacyModeState = PrivacyModeState.find(id);
|
||||
if ((gFFI.ffiModel.pi.features.privacyMode && gFFI.ffiModel.keyboard) ||
|
||||
privacyModeState.isNotEmpty) {
|
||||
if (gFFI.ffiModel.keyboard && gFFI.ffiModel.pi.features.privacyMode) {
|
||||
privacyModeList = toolbarPrivacyMode(privacyModeState, context, id, gFFI);
|
||||
if (privacyModeList.length == 1) {
|
||||
displayToggles.add(privacyModeList[0]);
|
||||
|
|
|
|||
|
|
@ -583,16 +583,9 @@ class _PermissionCheckerState extends State<PermissionChecker> {
|
|||
Widget build(BuildContext context) {
|
||||
final serverModel = Provider.of<ServerModel>(context);
|
||||
final hasAudioPermission = androidVersion >= 30;
|
||||
final hideStopService = isAndroid &&
|
||||
bind.mainGetBuildinOption(key: kOptionHideStopService) == 'Y';
|
||||
final allowPermChangeInAcceptWindow = option2bool(
|
||||
kOptionEnablePermChangeInAcceptWindow,
|
||||
bind.mainGetBuildinOption(
|
||||
key: kOptionEnablePermChangeInAcceptWindow,
|
||||
));
|
||||
final permissionChangeLocked = isAndroid &&
|
||||
serverModel.clients.any((c) => !c.disconnected) &&
|
||||
!allowPermChangeInAcceptWindow;
|
||||
final hideStopService =
|
||||
isAndroid &&
|
||||
bind.mainGetBuildinOption(key: kOptionHideStopService) == 'Y';
|
||||
return PaddingCard(
|
||||
title: translate("Permissions"),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
|
|
@ -615,21 +608,13 @@ class _PermissionCheckerState extends State<PermissionChecker> {
|
|||
bind.mainGetLocalOption(key: "show-scam-warning") != "N"
|
||||
? () => showScamWarning(context, serverModel)
|
||||
: serverModel.toggleService),
|
||||
PermissionRow(
|
||||
translate("Input Control"),
|
||||
serverModel.inputOk,
|
||||
serverModel.toggleInput,
|
||||
),
|
||||
PermissionRow(
|
||||
translate("Transfer file"),
|
||||
serverModel.fileOk,
|
||||
serverModel.toggleFile,
|
||||
enabled: !permissionChangeLocked,
|
||||
),
|
||||
PermissionRow(translate("Input Control"), serverModel.inputOk,
|
||||
serverModel.toggleInput),
|
||||
PermissionRow(translate("Transfer file"), serverModel.fileOk,
|
||||
serverModel.toggleFile),
|
||||
hasAudioPermission
|
||||
? PermissionRow(translate("Audio Capture"), serverModel.audioOk,
|
||||
serverModel.toggleAudio,
|
||||
enabled: !permissionChangeLocked)
|
||||
serverModel.toggleAudio)
|
||||
: Row(children: [
|
||||
Icon(Icons.info_outline).marginOnly(right: 15),
|
||||
Expanded(
|
||||
|
|
@ -638,25 +623,19 @@ class _PermissionCheckerState extends State<PermissionChecker> {
|
|||
style: const TextStyle(color: MyTheme.darkGray),
|
||||
))
|
||||
]),
|
||||
PermissionRow(
|
||||
translate("Enable clipboard"),
|
||||
serverModel.clipboardOk,
|
||||
serverModel.toggleClipboard,
|
||||
enabled: !permissionChangeLocked,
|
||||
),
|
||||
PermissionRow(translate("Enable clipboard"), serverModel.clipboardOk,
|
||||
serverModel.toggleClipboard),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
class PermissionRow extends StatelessWidget {
|
||||
const PermissionRow(this.name, this.isOk, this.onPressed,
|
||||
{Key? key, this.enabled = true})
|
||||
const PermissionRow(this.name, this.isOk, this.onPressed, {Key? key})
|
||||
: super(key: key);
|
||||
|
||||
final String name;
|
||||
final bool isOk;
|
||||
final VoidCallback onPressed;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -665,11 +644,9 @@ class PermissionRow extends StatelessWidget {
|
|||
contentPadding: EdgeInsets.all(0),
|
||||
title: Text(name),
|
||||
value: isOk,
|
||||
onChanged: enabled
|
||||
? (bool value) {
|
||||
onPressed();
|
||||
}
|
||||
: null);
|
||||
onChanged: (bool value) {
|
||||
onPressed();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,8 +17,10 @@ import '../../common/widgets/login.dart';
|
|||
import '../../consts.dart';
|
||||
import '../../models/model.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../../models/shortcut_model.dart';
|
||||
import '../widgets/dialog.dart';
|
||||
import 'home_page.dart';
|
||||
import 'mobile_keyboard_shortcuts_page.dart';
|
||||
import 'scan_page.dart';
|
||||
|
||||
class SettingsPage extends StatefulWidget implements PageShape {
|
||||
|
|
@ -819,6 +821,22 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
|||
showThemeSettings(gFFI.dialogManager);
|
||||
},
|
||||
),
|
||||
SettingsTile.navigation(
|
||||
leading: Icon(Icons.keyboard_outlined),
|
||||
title: Text(translate('Keyboard Shortcuts')),
|
||||
description: Text(ShortcutModel.isEnabled()
|
||||
? translate('On')
|
||||
: translate('Off')),
|
||||
onPressed: (context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const MobileKeyboardShortcutsPage(),
|
||||
)).then((_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
},
|
||||
),
|
||||
if (!bind.isDisableAccount())
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('note-at-conn-end-tip')),
|
||||
|
|
@ -1352,3 +1370,4 @@ SettingsTile _getPopupDialogRadioEntry({
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -391,30 +391,14 @@ class FileController {
|
|||
|
||||
await Future.delayed(Duration(milliseconds: 100));
|
||||
|
||||
final savedDir = (await bind.sessionGetPeerOption(
|
||||
final dir = (await bind.sessionGetPeerOption(
|
||||
sessionId: sessionId, name: isLocal ? "local_dir" : "remote_dir"));
|
||||
Future<bool> tryOpenReadyDirs() async {
|
||||
final dirs = <String>{
|
||||
if (directory.value.path.isNotEmpty) directory.value.path,
|
||||
if (savedDir.isNotEmpty) savedDir,
|
||||
options.value.home,
|
||||
};
|
||||
for (final dir in dirs) {
|
||||
if (await _openDirectoryPath(dir, isBack: true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
var opened = await tryOpenReadyDirs();
|
||||
openDirectory(dir.isEmpty ? options.value.home : dir);
|
||||
|
||||
await Future.delayed(Duration(seconds: 1));
|
||||
|
||||
if (!opened) {
|
||||
// The peer may become ready during the reconnect delay, so retry the
|
||||
// same candidates instead of only retrying the default home directory.
|
||||
await tryOpenReadyDirs();
|
||||
if (directory.value.path.isEmpty) {
|
||||
openDirectory(options.value.home);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -445,23 +429,19 @@ class FileController {
|
|||
});
|
||||
}
|
||||
|
||||
Future<bool> refresh() async {
|
||||
// "." can be both a refresh command and a real remote directory path.
|
||||
// Refresh must bypass openDirectory's command dispatch to avoid recursion.
|
||||
return await _openDirectoryPath(directory.value.path, isBack: true);
|
||||
Future<void> refresh() async {
|
||||
await openDirectory(directory.value.path);
|
||||
}
|
||||
|
||||
Future<bool> openDirectory(String path, {bool isBack = false}) async {
|
||||
if (!isBack && path == ".") {
|
||||
return await refresh();
|
||||
Future<void> openDirectory(String path, {bool isBack = false}) async {
|
||||
if (path == ".") {
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
if (!isBack && path == "..") {
|
||||
return await _goToParentDirectory(isBack: isBack);
|
||||
if (path == "..") {
|
||||
goToParentDirectory();
|
||||
return;
|
||||
}
|
||||
return await _openDirectoryPath(path, isBack: isBack);
|
||||
}
|
||||
|
||||
Future<bool> _openDirectoryPath(String path, {bool isBack = false}) async {
|
||||
if (!isBack) {
|
||||
pushHistory();
|
||||
}
|
||||
|
|
@ -478,10 +458,8 @@ class FileController {
|
|||
final fd = await fileFetcher.fetchDirectory(path, isLocal, showHidden);
|
||||
fd.format(isWindows, sort: sortBy.value);
|
||||
directory.value = fd;
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint("Failed to openDirectory $path: $e");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -509,22 +487,19 @@ class FileController {
|
|||
goBack();
|
||||
return;
|
||||
}
|
||||
unawaited(_openDirectoryPath(path, isBack: true).then<void>((_) {}));
|
||||
openDirectory(path, isBack: true);
|
||||
}
|
||||
|
||||
void goToParentDirectory() {
|
||||
unawaited(_goToParentDirectory().then<void>((_) {}));
|
||||
}
|
||||
|
||||
Future<bool> _goToParentDirectory({bool isBack = false}) async {
|
||||
final isWindows = options.value.isWindows;
|
||||
final dirPath = directory.value.path;
|
||||
var parent = PathUtil.dirname(dirPath, isWindows);
|
||||
// specially for C:\, D:\, goto '/'
|
||||
if (parent == dirPath && isWindows) {
|
||||
return await _openDirectoryPath('/', isBack: isBack);
|
||||
openDirectory('/');
|
||||
return;
|
||||
}
|
||||
return await _openDirectoryPath(parent, isBack: isBack);
|
||||
openDirectory(parent);
|
||||
}
|
||||
|
||||
// TODO deprecated this
|
||||
|
|
|
|||
|
|
@ -346,7 +346,7 @@ class InputModel {
|
|||
/// which runs per-engine, so each isolate registers its own handler tied
|
||||
/// to its own set of InputModels.
|
||||
static void initSideButtonChannel() {
|
||||
if (!isLinux) return;
|
||||
if (!Platform.isLinux) return;
|
||||
if (_sideButtonChannelInitialized) return;
|
||||
_sideButtonChannelInitialized = true;
|
||||
|
||||
|
|
@ -699,6 +699,7 @@ class InputModel {
|
|||
}
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
// Safe: this only re-dispatches synthesized Shift key-up events.
|
||||
// The key-up path clears the tracked Shift state so this does not loop.
|
||||
void _releaseTrackedShiftKeyEventIfNeeded() {
|
||||
|
|
@ -826,6 +827,7 @@ class InputModel {
|
|||
return KeyEventResult.ignored;
|
||||
}
|
||||
}
|
||||
|
||||
if (isWindows || isLinux) {
|
||||
// Ignore meta keys. Because flutter window will loose focus if meta key is pressed.
|
||||
if (e.physicalKey == PhysicalKeyboardKey.metaLeft ||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import 'package:flutter_hbb/models/peer_model.dart';
|
|||
import 'package:flutter_hbb/models/peer_tab_model.dart';
|
||||
import 'package:flutter_hbb/models/printer_model.dart';
|
||||
import 'package:flutter_hbb/models/server_model.dart';
|
||||
import 'package:flutter_hbb/models/shortcut_model.dart';
|
||||
import 'package:flutter_hbb/models/user_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:flutter_hbb/models/desktop_render_texture.dart';
|
||||
|
|
@ -476,6 +477,11 @@ class FfiModel with ChangeNotifier {
|
|||
} else if (name == 'exit_relative_mouse_mode') {
|
||||
// Handle exit shortcut from rdev grab loop (Ctrl+Alt on Win/Linux, Cmd+G on macOS)
|
||||
parent.target?.inputModel.exitRelativeMouseModeWithKeyRelease();
|
||||
} else if (name == kShortcutEventName) {
|
||||
final action = evt['action'];
|
||||
if (action is String) {
|
||||
parent.target?.shortcutModel.onTriggered(action);
|
||||
}
|
||||
} else {
|
||||
debugPrint('Event is not handled in the fixed branch: $name');
|
||||
}
|
||||
|
|
@ -3623,6 +3629,7 @@ class FFI {
|
|||
late final ElevationModel elevationModel; // session
|
||||
late final CmFileModel cmFileModel; // cm
|
||||
late final TextureModel textureModel; //session
|
||||
late final ShortcutModel shortcutModel; // session
|
||||
late final Peers recentPeersModel; // global
|
||||
late final Peers favoritePeersModel; // global
|
||||
late final Peers lanPeersModel; // global
|
||||
|
|
@ -3652,6 +3659,7 @@ class FFI {
|
|||
elevationModel = ElevationModel(WeakReference(this));
|
||||
cmFileModel = CmFileModel(WeakReference(this));
|
||||
textureModel = TextureModel(WeakReference(this));
|
||||
shortcutModel = ShortcutModel(WeakReference(this));
|
||||
recentPeersModel = Peers(
|
||||
name: PeersModelName.recent,
|
||||
loadEvent: LoadEvent.recent,
|
||||
|
|
|
|||
|
|
@ -298,7 +298,7 @@ class ServerModel with ChangeNotifier {
|
|||
}
|
||||
|
||||
toggleAudio() async {
|
||||
if (clients.any((c) => !c.disconnected)) {
|
||||
if (clients.isNotEmpty) {
|
||||
await showClientsMayNotBeChangedAlert(parent.target);
|
||||
}
|
||||
if (!_audioOk && !await AndroidPermissionManager.check(kRecordAudio)) {
|
||||
|
|
@ -316,7 +316,7 @@ class ServerModel with ChangeNotifier {
|
|||
}
|
||||
|
||||
toggleFile() async {
|
||||
if (clients.any((c) => !c.disconnected)) {
|
||||
if (clients.isNotEmpty) {
|
||||
await showClientsMayNotBeChangedAlert(parent.target);
|
||||
}
|
||||
if (!_fileOk &&
|
||||
|
|
@ -345,7 +345,7 @@ class ServerModel with ChangeNotifier {
|
|||
}
|
||||
|
||||
toggleInput() async {
|
||||
if (clients.any((c) => !c.disconnected)) {
|
||||
if (clients.isNotEmpty) {
|
||||
await showClientsMayNotBeChangedAlert(parent.target);
|
||||
}
|
||||
if (_inputOk) {
|
||||
|
|
@ -549,19 +549,10 @@ class ServerModel with ChangeNotifier {
|
|||
if (index < 0) {
|
||||
_clients.add(client);
|
||||
} else {
|
||||
if (_clients[index].authorized) {
|
||||
_clients[index].privacyMode = client.privacyMode;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
_clients[index].authorized = true;
|
||||
_clients[index].privacyMode = client.privacyMode;
|
||||
}
|
||||
} else {
|
||||
final index = _clients.indexWhere((c) => c.id == client.id);
|
||||
if (index >= 0) {
|
||||
_clients[index].privacyMode = client.privacyMode;
|
||||
notifyListeners();
|
||||
if (_clients.any((c) => c.id == client.id)) {
|
||||
return;
|
||||
}
|
||||
_clients.add(client);
|
||||
|
|
@ -827,7 +818,6 @@ class Client {
|
|||
bool restart = false;
|
||||
bool recording = false;
|
||||
bool blockInput = false;
|
||||
bool privacyMode = false;
|
||||
bool disconnected = false;
|
||||
bool fromSwitch = false;
|
||||
bool inVoiceCall = false;
|
||||
|
|
@ -856,7 +846,6 @@ class Client {
|
|||
restart = json['restart'];
|
||||
recording = json['recording'];
|
||||
blockInput = json['block_input'];
|
||||
privacyMode = json['privacy_mode'] ?? privacyMode;
|
||||
disconnected = json['disconnected'];
|
||||
fromSwitch = json['from_switch'];
|
||||
inVoiceCall = json['in_voice_call'];
|
||||
|
|
@ -881,7 +870,6 @@ class Client {
|
|||
data['restart'] = restart;
|
||||
data['recording'] = recording;
|
||||
data['block_input'] = blockInput;
|
||||
data['privacy_mode'] = privacyMode;
|
||||
data['disconnected'] = disconnected;
|
||||
data['from_switch'] = fromSwitch;
|
||||
data['in_voice_call'] = inVoiceCall;
|
||||
|
|
|
|||
141
flutter/lib/models/shortcut_model.dart
Normal file
141
flutter/lib/models/shortcut_model.dart
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../common.dart';
|
||||
import '../consts.dart';
|
||||
import '../desktop/widgets/tabbar_widget.dart' show DesktopTabController;
|
||||
import '../models/model.dart';
|
||||
import '../models/platform_model.dart';
|
||||
import '../models/state_model.dart';
|
||||
|
||||
/// Per-session shortcut dispatcher. Attached to FFI when a session is created.
|
||||
///
|
||||
/// The Rust matcher (src/keyboard/shortcuts.rs) emits `shortcut_triggered`
|
||||
/// session events containing the matched `action` id. The session event
|
||||
/// listener in [FfiModel.startEventListener] forwards those to this model
|
||||
/// via [onTriggered], which runs whatever callback the toolbar / menu
|
||||
/// builders previously registered for that action id.
|
||||
class ShortcutModel {
|
||||
final WeakReference<FFI> parent;
|
||||
final Map<String, VoidCallback> _callbacks = {};
|
||||
|
||||
ShortcutModel(this.parent);
|
||||
|
||||
/// Called by toolbar / menu builders to register what to do when the
|
||||
/// matched shortcut fires.
|
||||
void register(String actionId, VoidCallback callback) {
|
||||
_callbacks[actionId] = callback;
|
||||
}
|
||||
|
||||
void unregister(String actionId) {
|
||||
_callbacks.remove(actionId);
|
||||
}
|
||||
|
||||
/// Called by the session event listener when a `shortcut_triggered` event
|
||||
/// arrives for this session.
|
||||
void onTriggered(String actionId) {
|
||||
final cb = _callbacks[actionId];
|
||||
if (cb != null) {
|
||||
cb();
|
||||
} else {
|
||||
debugPrint('shortcut_triggered: no handler for $actionId');
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the bindings JSON from LocalConfig.
|
||||
static List<Map<String, dynamic>> readBindings() {
|
||||
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
|
||||
if (raw.isEmpty) return [];
|
||||
try {
|
||||
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
||||
final list = (parsed['bindings'] as List?) ?? [];
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static bool isEnabled() {
|
||||
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
|
||||
if (raw.isEmpty) return false;
|
||||
try {
|
||||
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return parsed['enabled'] == true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the default-bound shortcut actions that aren't already wired by
|
||||
/// `toolbarControls(...)` (which handles things like Ctrl+Alt+Shift+Del and the
|
||||
/// screenshot action). Called once per session from the desktop / mobile
|
||||
/// remote page, after the toolbar registrations have run.
|
||||
///
|
||||
/// [tabController] is the desktop window's tab controller; `null` on mobile /
|
||||
/// web (where tab-switch shortcuts don't apply).
|
||||
///
|
||||
/// Each callback below is a no-op when the underlying state required to
|
||||
/// service the action isn't available (e.g. only one display, only one tab).
|
||||
void registerSessionShortcutActions(
|
||||
FFI ffi, {
|
||||
DesktopTabController? tabController,
|
||||
}) {
|
||||
final sessionId = ffi.sessionId;
|
||||
|
||||
// Toggle Fullscreen — desktop & web-desktop only. `stateGlobal.setFullscreen`
|
||||
// handles native window vs. browser fullscreen; on mobile fullscreen is the
|
||||
// permanent default, so we leave the action unregistered (becomes a logged
|
||||
// no-op if a mobile user binds it).
|
||||
if (isDesktop || isWebDesktop) {
|
||||
ffi.shortcutModel.register(kShortcutActionToggleFullscreen, () {
|
||||
stateGlobal.setFullscreen(!stateGlobal.fullscreen.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Switch Display Next / Prev — requires the peer to have at least 2
|
||||
// displays. No-op when only one display is available or when the user has
|
||||
// selected the "All displays" pseudo-display.
|
||||
void switchDisplayBy(int delta) {
|
||||
final pi = ffi.ffiModel.pi;
|
||||
final count = pi.displays.length;
|
||||
if (count <= 1) return;
|
||||
final current = pi.currentDisplay;
|
||||
if (current == kAllDisplayValue) return;
|
||||
final next = ((current + delta) % count + count) % count;
|
||||
bind.sessionSwitchDisplay(
|
||||
isDesktop: isDesktop,
|
||||
sessionId: sessionId,
|
||||
value: Int32List.fromList([next]),
|
||||
);
|
||||
if (pi.isSupportMultiUiSession) {
|
||||
// On multi-ui-session peers no switch-display message is sent back, so
|
||||
// update the local state directly (mirrors `model.dart` handling).
|
||||
ffi.ffiModel.switchToNewDisplay(next, sessionId, ffi.id);
|
||||
}
|
||||
}
|
||||
|
||||
ffi.shortcutModel.register(kShortcutActionSwitchDisplayNext, () {
|
||||
switchDisplayBy(1);
|
||||
});
|
||||
ffi.shortcutModel.register(kShortcutActionSwitchDisplayPrev, () {
|
||||
switchDisplayBy(-1);
|
||||
});
|
||||
|
||||
// Switch Tab 1..9 — desktop only. The remote-screen tabs live in the
|
||||
// window-scoped DesktopTabController, not on the FFI itself, so we need
|
||||
// the controller from the page that owns this session. No-op on mobile /
|
||||
// web (no controller passed) and when the requested tab index is out of
|
||||
// range.
|
||||
if (tabController != null) {
|
||||
for (var n = 1; n <= 9; n++) {
|
||||
final idx = n - 1;
|
||||
ffi.shortcutModel.register(kShortcutActionSwitchTab(n), () {
|
||||
if (tabController.state.value.tabs.length > idx) {
|
||||
tabController.jumpTo(idx);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -27,30 +27,25 @@ class TerminalModel with ChangeNotifier {
|
|||
// Buffer for output data received before terminal view has valid dimensions.
|
||||
// This prevents NaN errors when writing to terminal before layout is complete.
|
||||
final _pendingOutputChunks = <String>[];
|
||||
final _pendingOutputSuppressFlags = <bool>[];
|
||||
int _pendingOutputSize = 0;
|
||||
static const int _kMaxOutputBufferChars = 8 * 1024;
|
||||
// View ready state: true when terminal has valid dimensions, safe to write
|
||||
bool _terminalViewReady = false;
|
||||
bool _markViewReadyScheduled = false;
|
||||
bool _suppressTerminalOutput = false;
|
||||
bool _suppressNextTerminalDataOutput = false;
|
||||
|
||||
bool get isPeerWindows => parent.ffiModel.pi.platform == kPeerPlatformWindows;
|
||||
|
||||
void Function(int w, int h, int pw, int ph)? onResizeExternal;
|
||||
|
||||
Future<void> _handleInput(String data) async {
|
||||
// Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a
|
||||
// real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'.
|
||||
// - Peer Windows: '\r' works, '\n' is just a newline.
|
||||
// - Peer Linux: canonical-mode shells accept both, but raw-mode apps
|
||||
// (readline, prompt_toolkit, vim, TUI frameworks) expect '\r'.
|
||||
// - Peer macOS: same as Linux, raw-mode apps expect '\r'
|
||||
// (https://github.com/rustdesk/rustdesk/issues/14907).
|
||||
// So on mobile / web-mobile, always normalize a lone '\n' to '\r'.
|
||||
// We deliberately do not touch multi-character payloads (e.g. pasted text)
|
||||
// so embedded newlines in pasted content are preserved.
|
||||
// If we press the `Enter` button on Android,
|
||||
// `data` can be '\r' or '\n' when using different keyboards.
|
||||
// Android -> Windows. '\r' works, but '\n' does not. '\n' is just a newline.
|
||||
// Android -> Linux. Both '\r' and '\n' work as expected (execute a command).
|
||||
// So when we receive '\n', we may need to convert it to '\r' to ensure compatibility.
|
||||
// Desktop -> Desktop works fine.
|
||||
// Check if we are on mobile or web(mobile), and convert '\n' to '\r'.
|
||||
final isMobileOrWebMobile = (isMobile || (isWeb && !isWebDesktop));
|
||||
if (isMobileOrWebMobile && data == '\n') {
|
||||
if (isMobileOrWebMobile && isPeerWindows && data == '\n') {
|
||||
data = '\r';
|
||||
}
|
||||
if (_terminalOpened) {
|
||||
|
|
@ -75,10 +70,7 @@ class TerminalModel with ChangeNotifier {
|
|||
terminalController = TerminalController();
|
||||
|
||||
// Setup terminal callbacks
|
||||
terminal.onOutput = (data) {
|
||||
if (_suppressTerminalOutput) return;
|
||||
_handleInput(data);
|
||||
};
|
||||
terminal.onOutput = _handleInput;
|
||||
|
||||
terminal.onResize = (w, h, pw, ph) async {
|
||||
// Validate all dimensions before using them
|
||||
|
|
@ -92,7 +84,7 @@ class TerminalModel with ChangeNotifier {
|
|||
// Mark terminal view as ready and flush any buffered output on first valid resize.
|
||||
// Must be after onResizeExternal so the view layer has valid dimensions before flushing.
|
||||
if (!_terminalViewReady) {
|
||||
_scheduleMarkViewReady();
|
||||
_markViewReady();
|
||||
}
|
||||
|
||||
if (_terminalOpened) {
|
||||
|
|
@ -118,16 +110,14 @@ class TerminalModel with ChangeNotifier {
|
|||
void onReady() {
|
||||
parent.dialogManager.dismissAll();
|
||||
|
||||
// Fire and forget - don't block onReady. If the transport reconnects while
|
||||
// this model is still open, re-send OpenTerminal so the remote service marks
|
||||
// the persistent session active again and resumes output streaming.
|
||||
openTerminal(force: _terminalOpened).catchError((e) {
|
||||
// Fire and forget - don't block onReady
|
||||
openTerminal().catchError((e) {
|
||||
debugPrint('[TerminalModel] Error opening terminal: $e');
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> openTerminal({bool force = false}) async {
|
||||
if (_terminalOpened && !force) return;
|
||||
Future<void> openTerminal() async {
|
||||
if (_terminalOpened) return;
|
||||
// Request the remote side to open a terminal with default shell
|
||||
// The remote side will decide which shell to use based on its OS
|
||||
|
||||
|
|
@ -285,12 +275,9 @@ class TerminalModel with ChangeNotifier {
|
|||
if (success) {
|
||||
_terminalOpened = true;
|
||||
|
||||
// On reconnect, the server may replay recent output. That replay can include
|
||||
// terminal queries like DSR/DA; xterm answers them through onOutput as
|
||||
// "^[[1;1R^[[2;2R^[[>0;0;0c", which must not be sent back to the peer.
|
||||
final replayTerminalOutput = evt['replay_terminal_output'];
|
||||
_suppressNextTerminalDataOutput = replayTerminalOutput == true ||
|
||||
message == 'Reconnected to existing terminal with pending output';
|
||||
// On reconnect ("Reconnected to existing terminal"), server may replay recent output.
|
||||
// If this TerminalView instance is reused (not rebuilt), duplicate lines can appear.
|
||||
// We intentionally accept this tradeoff for now to keep logic simple.
|
||||
|
||||
// Fallback: if terminal view is not yet ready but already has valid
|
||||
// dimensions (e.g. layout completed before open response arrived),
|
||||
|
|
@ -298,7 +285,7 @@ class TerminalModel with ChangeNotifier {
|
|||
if (!_terminalViewReady &&
|
||||
terminal.viewWidth > 0 &&
|
||||
terminal.viewHeight > 0) {
|
||||
_scheduleMarkViewReady();
|
||||
_markViewReady();
|
||||
}
|
||||
|
||||
// Process any buffered input
|
||||
|
|
@ -310,16 +297,12 @@ class TerminalModel with ChangeNotifier {
|
|||
});
|
||||
|
||||
final persistentSessions =
|
||||
(evt['persistent_sessions'] as List<dynamic>? ?? [])
|
||||
.whereType<int>()
|
||||
.where((id) => !parent.terminalModels.containsKey(id))
|
||||
.toList();
|
||||
evt['persistent_sessions'] as List<dynamic>? ?? [];
|
||||
if (kWindowId != null && persistentSessions.isNotEmpty) {
|
||||
DesktopMultiWindow.invokeMethod(
|
||||
kWindowId!,
|
||||
kWindowEventRestoreTerminalSessions,
|
||||
jsonEncode({
|
||||
'peer_id': id,
|
||||
'persistent_sessions': persistentSessions,
|
||||
}));
|
||||
}
|
||||
|
|
@ -349,8 +332,6 @@ class TerminalModel with ChangeNotifier {
|
|||
final data = evt['data'];
|
||||
|
||||
if (data != null) {
|
||||
final suppressTerminalOutput = _suppressNextTerminalDataOutput;
|
||||
_suppressNextTerminalDataOutput = false;
|
||||
try {
|
||||
String text = '';
|
||||
if (data is String) {
|
||||
|
|
@ -370,7 +351,7 @@ class TerminalModel with ChangeNotifier {
|
|||
return;
|
||||
}
|
||||
|
||||
_writeToTerminal(text, suppressTerminalOutput: suppressTerminalOutput);
|
||||
_writeToTerminal(text);
|
||||
} catch (e) {
|
||||
debugPrint('[TerminalModel] Failed to process terminal data: $e');
|
||||
}
|
||||
|
|
@ -380,10 +361,7 @@ class TerminalModel with ChangeNotifier {
|
|||
/// Write text to terminal, buffering if the view is not yet ready.
|
||||
/// All terminal output should go through this method to avoid NaN errors
|
||||
/// from writing before the terminal view has valid layout dimensions.
|
||||
void _writeToTerminal(
|
||||
String text, {
|
||||
bool suppressTerminalOutput = false,
|
||||
}) {
|
||||
void _writeToTerminal(String text) {
|
||||
if (!_terminalViewReady) {
|
||||
// If a single chunk exceeds the cap, keep only its tail.
|
||||
// Note: truncation may split a multi-byte ANSI escape sequence,
|
||||
|
|
@ -395,73 +373,34 @@ class TerminalModel with ChangeNotifier {
|
|||
_pendingOutputChunks
|
||||
..clear()
|
||||
..add(truncated);
|
||||
_pendingOutputSuppressFlags
|
||||
..clear()
|
||||
..add(suppressTerminalOutput);
|
||||
_pendingOutputSize = truncated.length;
|
||||
} else {
|
||||
_pendingOutputChunks.add(text);
|
||||
_pendingOutputSuppressFlags.add(suppressTerminalOutput);
|
||||
_pendingOutputSize += text.length;
|
||||
// Drop oldest chunks if exceeds limit (whole chunks to preserve ANSI sequences)
|
||||
while (_pendingOutputSize > _kMaxOutputBufferChars &&
|
||||
_pendingOutputChunks.length > 1) {
|
||||
final removed = _pendingOutputChunks.removeAt(0);
|
||||
_pendingOutputSuppressFlags.removeAt(0);
|
||||
_pendingOutputSize -= removed.length;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
_writeTerminalChunk(text, suppressTerminalOutput: suppressTerminalOutput);
|
||||
terminal.write(text);
|
||||
}
|
||||
|
||||
void _flushOutputBuffer() {
|
||||
if (_pendingOutputChunks.isEmpty) return;
|
||||
debugPrint(
|
||||
'[TerminalModel] Flushing $_pendingOutputSize buffered chars (${_pendingOutputChunks.length} chunks)');
|
||||
for (var i = 0; i < _pendingOutputChunks.length; i++) {
|
||||
_writeTerminalChunk(
|
||||
_pendingOutputChunks[i],
|
||||
suppressTerminalOutput: _pendingOutputSuppressFlags[i],
|
||||
);
|
||||
for (final chunk in _pendingOutputChunks) {
|
||||
terminal.write(chunk);
|
||||
}
|
||||
_pendingOutputChunks.clear();
|
||||
_pendingOutputSuppressFlags.clear();
|
||||
_pendingOutputSize = 0;
|
||||
}
|
||||
|
||||
void _writeTerminalChunk(
|
||||
String text, {
|
||||
required bool suppressTerminalOutput,
|
||||
}) {
|
||||
if (!suppressTerminalOutput) {
|
||||
terminal.write(text);
|
||||
return;
|
||||
}
|
||||
final previous = _suppressTerminalOutput;
|
||||
_suppressTerminalOutput = true;
|
||||
try {
|
||||
terminal.write(text);
|
||||
} finally {
|
||||
_suppressTerminalOutput = previous;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark terminal view as ready and flush buffered output.
|
||||
void _scheduleMarkViewReady() {
|
||||
if (_disposed || _terminalViewReady || _markViewReadyScheduled) return;
|
||||
_markViewReadyScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_markViewReadyScheduled = false;
|
||||
if (_disposed || _terminalViewReady) return;
|
||||
if (terminal.viewWidth > 0 && terminal.viewHeight > 0) {
|
||||
_markViewReady();
|
||||
}
|
||||
});
|
||||
WidgetsBinding.instance.ensureVisualUpdate();
|
||||
}
|
||||
|
||||
void _markViewReady() {
|
||||
if (_terminalViewReady) return;
|
||||
_terminalViewReady = true;
|
||||
|
|
@ -487,10 +426,7 @@ class TerminalModel with ChangeNotifier {
|
|||
// Clear buffers to free memory
|
||||
_inputBuffer.clear();
|
||||
_pendingOutputChunks.clear();
|
||||
_pendingOutputSuppressFlags.clear();
|
||||
_pendingOutputSize = 0;
|
||||
_markViewReadyScheduled = false;
|
||||
_suppressNextTerminalDataOutput = false;
|
||||
// Terminal cleanup is handled server-side when service closes
|
||||
super.dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'package:uuid/uuid.dart';
|
|||
import 'dart:html' as html;
|
||||
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/common.dart' as common;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
|
||||
|
|
@ -930,6 +931,30 @@ class RustdeskImpl {
|
|||
]));
|
||||
}
|
||||
|
||||
// Tell the JS-side matcher (flutter/web/js/src/shortcut_matcher.ts) to
|
||||
// re-read its bindings from LocalStorage. Mirrors the native call which
|
||||
// refreshes the Rust matcher's in-memory cache.
|
||||
void mainReloadKeyboardShortcuts({dynamic hint}) {
|
||||
js.context.callMethod('reloadShortcuts', []);
|
||||
}
|
||||
|
||||
// Mirror of `default_bindings()` in `src/keyboard/shortcuts.rs`. Keep these
|
||||
// two lists in sync — if you add or change a default binding on the Rust
|
||||
// side, update the literal below to match.
|
||||
String mainGetDefaultKeyboardShortcuts({dynamic hint}) {
|
||||
const prefix = ['primary', 'alt', 'shift'];
|
||||
final list = <Map<String, dynamic>>[
|
||||
{'action': 'send_ctrl_alt_del', 'mods': prefix, 'key': 'delete'},
|
||||
{'action': 'toggle_fullscreen', 'mods': prefix, 'key': 'enter'},
|
||||
{'action': 'switch_display_next', 'mods': prefix, 'key': 'arrow_right'},
|
||||
{'action': 'switch_display_prev', 'mods': prefix, 'key': 'arrow_left'},
|
||||
{'action': 'screenshot', 'mods': prefix, 'key': 'p'},
|
||||
for (var n = 1; n <= 9; n++)
|
||||
{'action': 'switch_tab_$n', 'mods': prefix, 'key': 'digit$n'},
|
||||
];
|
||||
return jsonEncode(list);
|
||||
}
|
||||
|
||||
String mainGetInputSource({dynamic hint}) {
|
||||
final inputSource =
|
||||
js.context.callMethod('getByName', ['option:local', 'input-source']);
|
||||
|
|
@ -1176,6 +1201,15 @@ class RustdeskImpl {
|
|||
}
|
||||
|
||||
Future<void> mainInit({required String appDir, dynamic hint}) {
|
||||
// JS -> Dart shortcut bridge. The matcher in flutter/web/js/src/
|
||||
// shortcut_matcher.ts calls `window.onShortcutTriggered(actionId)` when a
|
||||
// binding fires; route it to the active session's ShortcutModel.
|
||||
// Web is single-window so `gFFI` is always the active session.
|
||||
js.context['onShortcutTriggered'] = (dynamic action) {
|
||||
if (action is String) {
|
||||
common.gFFI.shortcutModel.onTriggered(action);
|
||||
}
|
||||
};
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
|
|
@ -1729,7 +1763,7 @@ class RustdeskImpl {
|
|||
}
|
||||
|
||||
String mainSupportedPrivacyModeImpls({dynamic hint}) {
|
||||
return '[]';
|
||||
throw UnimplementedError("mainSupportedPrivacyModeImpls");
|
||||
}
|
||||
|
||||
String mainSupportedInputSource({dynamic hint}) {
|
||||
|
|
|
|||
|
|
@ -624,7 +624,6 @@ void CliprdrStream_Delete(CliprdrStream *instance)
|
|||
if (instance)
|
||||
{
|
||||
free(instance->iStream.lpVtbl);
|
||||
instance->iStream.lpVtbl = NULL;
|
||||
free(instance);
|
||||
}
|
||||
}
|
||||
|
|
@ -2161,7 +2160,7 @@ static BOOL wf_cliprdr_add_to_file_arrays(wfClipboard *clipboard, WCHAR *full_fi
|
|||
return FALSE;
|
||||
|
||||
/* add to name array */
|
||||
clipboard->file_names[clipboard->nFiles] = (LPWSTR)malloc((size_t)MAX_PATH * sizeof(WCHAR));
|
||||
clipboard->file_names[clipboard->nFiles] = (LPWSTR)malloc(MAX_PATH * 2);
|
||||
|
||||
if (!clipboard->file_names[clipboard->nFiles])
|
||||
return FALSE;
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 9043c15acc6d5b42b6c12ad284c16c1ec172f1f0
|
||||
Subproject commit 87b11a795964b00deded250657a63626f2c1efa0
|
||||
|
|
@ -276,21 +276,12 @@ impl PipeWireRecorder {
|
|||
// see: https://gitlab.freedesktop.org/pipewire/pipewire/-/issues/982
|
||||
src.set_property("always-copy", &true)?;
|
||||
|
||||
// COSMIC/Wayland fix: insert videoconvert between pipewiresrc and appsink.
|
||||
// xdg-desktop-portal-cosmic's modifier negotiation fails when the downstream
|
||||
// format set is too narrow (appsink only accepts BGRx/RGBx), producing
|
||||
// "no more output formats" / not-negotiated (-4). videoconvert accepts any
|
||||
// system-memory video/x-raw format, widening negotiation so the portal can
|
||||
// settle on a format it can deliver via its SHM path.
|
||||
let convert = gst::ElementFactory::make("videoconvert", None)?;
|
||||
|
||||
let sink = gst::ElementFactory::make("appsink", None)?;
|
||||
sink.set_property("drop", &true)?;
|
||||
sink.set_property("max-buffers", &1u32)?;
|
||||
|
||||
pipeline.add_many(&[&src, &convert, &sink])?;
|
||||
src.link(&convert)?;
|
||||
convert.link(&sink)?;
|
||||
pipeline.add_many(&[&src, &sink])?;
|
||||
src.link(&sink)?;
|
||||
|
||||
let appsink = sink
|
||||
.dynamic_cast::<AppSink>()
|
||||
|
|
|
|||
|
|
@ -31,17 +31,17 @@ LExit:
|
|||
return WcaFinalize(er);
|
||||
}
|
||||
|
||||
// Helper function to safely delete a file using handle-based deletion.
|
||||
// Directories are refused after opening the handle.
|
||||
// Helper function to safely delete a file or directory using handle-based deletion.
|
||||
// This avoids TOCTOU (Time-Of-Check-Time-Of-Use) race conditions.
|
||||
BOOL SafeDeleteItem(LPCWSTR fullPath)
|
||||
{
|
||||
// Open the file/directory with delete and attribute-read access plus FILE_FLAG_OPEN_REPARSE_POINT
|
||||
// Open the file/directory with DELETE access and FILE_FLAG_OPEN_REPARSE_POINT
|
||||
// to prevent following symlinks.
|
||||
// Use shared access to allow deletion even when other processes have the file open.
|
||||
DWORD flags = FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT;
|
||||
HANDLE hFile = CreateFileW(
|
||||
fullPath,
|
||||
DELETE | FILE_READ_ATTRIBUTES,
|
||||
DELETE,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, // Allow shared access
|
||||
NULL,
|
||||
OPEN_EXISTING,
|
||||
|
|
@ -55,21 +55,6 @@ BOOL SafeDeleteItem(LPCWSTR fullPath)
|
|||
return FALSE;
|
||||
}
|
||||
|
||||
BY_HANDLE_FILE_INFORMATION fileInfo;
|
||||
if (FALSE == GetFileInformationByHandle(hFile, &fileInfo))
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "SafeDeleteItem: Failed to inspect '%ls'. Error: %lu", fullPath, GetLastError());
|
||||
CloseHandle(hFile);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
if (fileInfo.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "SafeDeleteItem: Refusing to delete directory '%ls'.", fullPath);
|
||||
CloseHandle(hFile);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
// Use SetFileInformationByHandle to mark for deletion.
|
||||
// The file will be deleted when the handle is closed.
|
||||
FILE_DISPOSITION_INFO dispInfo;
|
||||
|
|
@ -92,74 +77,98 @@ BOOL SafeDeleteItem(LPCWSTR fullPath)
|
|||
return result;
|
||||
}
|
||||
|
||||
BOOL PathEndsWithSlash(LPCWSTR path)
|
||||
// Helper function to recursively delete a directory's contents with detailed logging.
|
||||
void RecursiveDelete(LPCWSTR path)
|
||||
{
|
||||
size_t length = 0;
|
||||
HRESULT hr = StringCchLengthW(path, MAX_PATH, &length);
|
||||
if (FAILED(hr) || length == 0)
|
||||
{
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
WCHAR last = path[length - 1];
|
||||
return last == L'\\' || last == L'/';
|
||||
}
|
||||
|
||||
void ClearReadOnlyAttribute(LPCWSTR fullPath, DWORD attributes)
|
||||
{
|
||||
if (!(attributes & FILE_ATTRIBUTE_READONLY))
|
||||
// Ensure the path is not empty or null.
|
||||
if (path == NULL || path[0] == L'\0')
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DWORD writableAttributes = attributes & ~FILE_ATTRIBUTE_READONLY;
|
||||
if (writableAttributes == 0)
|
||||
// Extra safety: never operate directly on a root path.
|
||||
if (PathIsRootW(path))
|
||||
{
|
||||
writableAttributes = FILE_ATTRIBUTE_NORMAL;
|
||||
}
|
||||
|
||||
if (SetFileAttributesW(fullPath, writableAttributes))
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "Runtime cleanup cleared read-only attribute for '%ls'.", fullPath);
|
||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: refusing to operate on root path '%ls'.", path);
|
||||
return;
|
||||
}
|
||||
|
||||
WcaLog(LOGMSG_STANDARD, "Runtime cleanup failed to clear read-only attribute for '%ls'. Error: %lu", fullPath, GetLastError());
|
||||
}
|
||||
|
||||
BOOL DeleteRuntimeGeneratedFile(LPCWSTR installFolder, LPCWSTR fileName)
|
||||
{
|
||||
WCHAR fullPath[MAX_PATH];
|
||||
LPCWSTR separator = PathEndsWithSlash(installFolder) ? L"" : L"\\";
|
||||
HRESULT hr = StringCchPrintfW(fullPath, MAX_PATH, L"%s%s%s", installFolder, separator, fileName);
|
||||
if (FAILED(hr))
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "Runtime cleanup path is too long for '%ls'.", fileName);
|
||||
return FALSE;
|
||||
// MAX_PATH is enough here since the installer should not be using longer paths.
|
||||
// No need to handle extended-length paths (\\?\) in this context.
|
||||
WCHAR searchPath[MAX_PATH];
|
||||
HRESULT hr = StringCchPrintfW(searchPath, MAX_PATH, L"%s\\*", path);
|
||||
if (FAILED(hr)) {
|
||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Path too long to enumerate: %ls", path);
|
||||
return;
|
||||
}
|
||||
|
||||
DWORD attributes = GetFileAttributesW(fullPath);
|
||||
if (attributes == INVALID_FILE_ATTRIBUTES)
|
||||
WIN32_FIND_DATAW findData;
|
||||
HANDLE hFind = FindFirstFileW(searchPath, &findData);
|
||||
|
||||
if (hFind == INVALID_HANDLE_VALUE)
|
||||
{
|
||||
DWORD error = GetLastError();
|
||||
if (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND)
|
||||
// This can happen if the directory is empty or doesn't exist, which is not an error in our case.
|
||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Failed to enumerate directory '%ls'. It may be missing or inaccessible. Error: %lu", path, GetLastError());
|
||||
return;
|
||||
}
|
||||
|
||||
do
|
||||
{
|
||||
// Skip '.' and '..' directories.
|
||||
if (wcscmp(findData.cFileName, L".") == 0 || wcscmp(findData.cFileName, L"..") == 0)
|
||||
{
|
||||
return TRUE;
|
||||
continue;
|
||||
}
|
||||
|
||||
WcaLog(LOGMSG_STANDARD, "Runtime cleanup cannot stat '%ls'. Error: %lu", fullPath, error);
|
||||
return FALSE;
|
||||
}
|
||||
// MAX_PATH is enough here since the installer should not be using longer paths.
|
||||
// No need to handle extended-length paths (\\?\) in this context.
|
||||
WCHAR fullPath[MAX_PATH];
|
||||
hr = StringCchPrintfW(fullPath, MAX_PATH, L"%s\\%s", path, findData.cFileName);
|
||||
if (FAILED(hr)) {
|
||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Path too long for item '%ls' in '%ls', skipping.", findData.cFileName, path);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (attributes & FILE_ATTRIBUTE_DIRECTORY)
|
||||
// Before acting, ensure the read-only attribute is not set.
|
||||
if (findData.dwFileAttributes & FILE_ATTRIBUTE_READONLY)
|
||||
{
|
||||
if (FALSE == SetFileAttributesW(fullPath, findData.dwFileAttributes & ~FILE_ATTRIBUTE_READONLY))
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Failed to remove read-only attribute. Error: %lu", GetLastError());
|
||||
}
|
||||
}
|
||||
|
||||
if (findData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)
|
||||
{
|
||||
// Check for reparse points (symlinks/junctions) to prevent directory traversal attacks.
|
||||
// Do not follow reparse points, only remove the link itself.
|
||||
if (findData.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Not recursing into reparse point (symlink/junction), deleting link itself: %ls", fullPath);
|
||||
SafeDeleteItem(fullPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Recursively delete directory contents first
|
||||
RecursiveDelete(fullPath);
|
||||
// Then delete the directory itself
|
||||
SafeDeleteItem(fullPath);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Delete file using safe handle-based deletion
|
||||
SafeDeleteItem(fullPath);
|
||||
}
|
||||
} while (FindNextFileW(hFind, &findData) != 0);
|
||||
|
||||
DWORD lastError = GetLastError();
|
||||
if (lastError != ERROR_NO_MORE_FILES)
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "Runtime cleanup skipped directory '%ls'.", fullPath);
|
||||
return FALSE;
|
||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: FindNextFileW failed with error %lu", lastError);
|
||||
}
|
||||
|
||||
ClearReadOnlyAttribute(fullPath, attributes);
|
||||
WcaLog(LOGMSG_STANDARD, "Runtime cleanup deleting '%ls'.", fullPath);
|
||||
return SafeDeleteItem(fullPath);
|
||||
FindClose(hFind);
|
||||
}
|
||||
|
||||
// See `Package.wxs` for the sequence of this custom action.
|
||||
|
|
@ -169,13 +178,13 @@ BOOL DeleteRuntimeGeneratedFile(LPCWSTR installFolder, LPCWSTR fileName)
|
|||
// 2. RemoveExistingProducts
|
||||
// ├─ TerminateProcesses
|
||||
// ├─ TryStopDeleteService
|
||||
// ├─ RemoveRuntimeGeneratedFiles - <-- Here
|
||||
// ├─ RemoveInstallFolder - <-- Here
|
||||
// └─ RemoveFiles
|
||||
// 3. InstallValidate
|
||||
// 4. InstallFiles
|
||||
// 5. InstallExecute
|
||||
// 6. InstallFinalize
|
||||
UINT __stdcall RemoveRuntimeGeneratedFiles(
|
||||
UINT __stdcall RemoveInstallFolder(
|
||||
__in MSIHANDLE hInstall)
|
||||
{
|
||||
HRESULT hr = S_OK;
|
||||
|
|
@ -185,7 +194,7 @@ UINT __stdcall RemoveRuntimeGeneratedFiles(
|
|||
LPWSTR pwz = NULL;
|
||||
LPWSTR pwzData = NULL;
|
||||
|
||||
hr = WcaInitialize(hInstall, "RemoveRuntimeGeneratedFiles");
|
||||
hr = WcaInitialize(hInstall, "RemoveInstallFolder");
|
||||
ExitOnFailure(hr, "Failed to initialize");
|
||||
|
||||
hr = WcaGetProperty(L"CustomActionData", &pwzData);
|
||||
|
|
@ -193,20 +202,24 @@ UINT __stdcall RemoveRuntimeGeneratedFiles(
|
|||
|
||||
pwz = pwzData;
|
||||
hr = WcaReadStringFromCaData(&pwz, &installFolder);
|
||||
ExitOnFailure(hr, "failed to read install folder from custom action data: %ls", pwz);
|
||||
ExitOnFailure(hr, "failed to read database key from custom action data: %ls", pwz);
|
||||
|
||||
if (installFolder == NULL || installFolder[0] == L'\0') {
|
||||
WcaLog(LOGMSG_STANDARD, "Install folder path is empty, skipping runtime cleanup.");
|
||||
WcaLog(LOGMSG_STANDARD, "Install folder path is empty, skipping recursive delete.");
|
||||
goto LExit;
|
||||
}
|
||||
|
||||
if (PathIsRootW(installFolder)) {
|
||||
WcaLog(LOGMSG_STANDARD, "Refusing runtime cleanup in root folder '%ls'.", installFolder);
|
||||
WcaLog(LOGMSG_STANDARD, "Refusing to recursively delete root folder '%ls'.", installFolder);
|
||||
goto LExit;
|
||||
}
|
||||
|
||||
WcaLog(LOGMSG_STANDARD, "Removing runtime-generated files from install folder: %ls", installFolder);
|
||||
DeleteRuntimeGeneratedFile(installFolder, L"RuntimeBroker_rustdesk.exe");
|
||||
WcaLog(LOGMSG_STANDARD, "Attempting to recursively delete contents of install folder: %ls", installFolder);
|
||||
|
||||
RecursiveDelete(installFolder);
|
||||
|
||||
// The standard MSI 'RemoveFolders' action will take care of removing the (now empty) directories.
|
||||
// We don't need to call RemoveDirectoryW on installFolder itself, as it might still be in use by the installer.
|
||||
|
||||
LExit:
|
||||
ReleaseStr(pwzData);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ LIBRARY "CustomActions"
|
|||
|
||||
EXPORTS
|
||||
CustomActionHello
|
||||
RemoveRuntimeGeneratedFiles
|
||||
RemoveInstallFolder
|
||||
TerminateProcesses
|
||||
AddFirewallRules
|
||||
SetPropertyIsServiceRunning
|
||||
|
|
|
|||
|
|
@ -16,15 +16,8 @@
|
|||
<!-- If a command line value was stored, restore it after the registry search has been performed -->
|
||||
<SetProperty Action="RestoreSavedInstallFolderValue" Id="INSTALLFOLDER" Value="[SavedInstallFolderCmdLineValue]" After="AppSearch" Sequence="first" Condition="SavedInstallFolderCmdLineValue" />
|
||||
|
||||
<!-- Normalize INSTALLFOLDER from the command line or registry before assigning INSTALLFOLDER_INNER. -->
|
||||
<!-- Case 1: already ends with \$(var.Product)\, keep it unchanged. -->
|
||||
<SetProperty Action="SetInstallFolderInnerFromProductDir" Id="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER]" After="RestoreSavedInstallFolderValue" Sequence="first" Condition="INSTALLFOLDER AND INSTALLFOLDER ~>> "\$(var.Product)\"" />
|
||||
<!-- Case 2: already ends with \$(var.Product) but has no trailing slash, add the slash. -->
|
||||
<SetProperty Action="SetInstallFolderInnerFromProductDirNoSlash" Id="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER]\" After="RestoreSavedInstallFolderValue" Sequence="first" Condition="INSTALLFOLDER AND INSTALLFOLDER ~>> "\$(var.Product)"" />
|
||||
<!-- Case 3: ends with a slash but not \$(var.Product)\, append $(var.Product)\. -->
|
||||
<SetProperty Action="SetInstallFolderInnerAppendProduct" Id="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER]$(var.Product)\" After="RestoreSavedInstallFolderValue" Sequence="first" Condition="INSTALLFOLDER AND INSTALLFOLDER ~>> "\" AND NOT (INSTALLFOLDER ~>> "\$(var.Product)\" OR INSTALLFOLDER ~>> "\$(var.Product)")" />
|
||||
<!-- Case 4: has no trailing slash and does not end with \$(var.Product), append \$(var.Product)\. -->
|
||||
<SetProperty Action="SetInstallFolderInnerAppendSlashProduct" Id="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER]\$(var.Product)\" After="RestoreSavedInstallFolderValue" Sequence="first" Condition="INSTALLFOLDER AND NOT INSTALLFOLDER ~>> "\" AND NOT (INSTALLFOLDER ~>> "\$(var.Product)\" OR INSTALLFOLDER ~>> "\$(var.Product)")" />
|
||||
<!-- If a command line value or registry value was set, update the main properties with the value -->
|
||||
<SetProperty Id="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER]" After="RestoreSavedInstallFolderValue" Sequence="first" Condition="INSTALLFOLDER" />
|
||||
|
||||
<!-- INSTALLFOLDER_INNER is defined for compatibility with previous versions of the installer. -->
|
||||
<!-- Because we need to use INSTALLFOLDER as the command line argument. -->
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
</Component>
|
||||
</DirectoryRef>
|
||||
|
||||
<CustomAction Id="RemoveRuntimeGeneratedFiles.SetParam" Return="check" Property="RemoveRuntimeGeneratedFiles" Value="[INSTALLFOLDER_INNER]" />
|
||||
<CustomAction Id="RemoveInstallFolder.SetParam" Return="check" Property="RemoveInstallFolder" Value="[INSTALLFOLDER_INNER]" />
|
||||
<CustomAction Id="AddFirewallRules.SetParam" Return="check" Property="AddFirewallRules" Value="1[INSTALLFOLDER_INNER]$(var.Product).exe" />
|
||||
<CustomAction Id="RemoveFirewallRules.SetParam" Return="check" Property="RemoveFirewallRules" Value="0[INSTALLFOLDER_INNER]$(var.Product).exe" />
|
||||
<CustomAction Id="CreateStartService.SetParam" Return="check" Property="CreateStartService" Value="$(var.Product);"[INSTALLFOLDER_INNER]$(var.Product).exe" --service" />
|
||||
|
|
@ -77,21 +77,21 @@
|
|||
|
||||
<Custom Action="AddRegSoftwareSASGeneration" Before="InstallFinalize" Condition="NOT (Installed AND REMOVE AND NOT UPGRADINGPRODUCTCODE) AND (NOT CC_CONNECTION_TYPE="outgoing")"/>
|
||||
|
||||
<Custom Action="RemoveRuntimeGeneratedFiles" Before="RemoveFiles" Condition="Installed AND (REMOVE="ALL" OR UPGRADINGPRODUCTCODE)"/>
|
||||
<Custom Action="RemoveRuntimeGeneratedFiles.SetParam" Before="RemoveRuntimeGeneratedFiles" Condition="Installed AND (REMOVE="ALL" OR UPGRADINGPRODUCTCODE)"/>
|
||||
<Custom Action="TryStopDeleteService" Before="RemoveRuntimeGeneratedFiles.SetParam" />
|
||||
<Custom Action="RemoveInstallFolder" Before="RemoveFiles"/>
|
||||
<Custom Action="RemoveInstallFolder.SetParam" Before="RemoveInstallFolder"/>
|
||||
<Custom Action="TryStopDeleteService" Before="RemoveInstallFolder.SetParam" />
|
||||
<Custom Action="TryStopDeleteService.SetParam" Before="TryStopDeleteService" />
|
||||
|
||||
<Custom Action="RemoveFirewallRules" Before="RemoveFiles"/>
|
||||
<Custom Action="RemoveFirewallRules.SetParam" Before="RemoveFirewallRules"/>
|
||||
|
||||
<Custom Action="UninstallPrinter" Before="RemoveRuntimeGeneratedFiles" Condition="VersionNT >= 603" />
|
||||
<Custom Action="UninstallPrinter" Before="RemoveInstallFolder" Condition="VersionNT >= 603" />
|
||||
|
||||
<Custom Action="TerminateProcesses" Before="RemoveRuntimeGeneratedFiles"/>
|
||||
<Custom Action="TerminateProcesses" Before="RemoveInstallFolder"/>
|
||||
<Custom Action="TerminateProcesses.SetParam" Before="TerminateProcesses"/>
|
||||
<Custom Action="TerminateBrokers" Before="RemoveRuntimeGeneratedFiles"/>
|
||||
<Custom Action="TerminateBrokers" Before="RemoveInstallFolder"/>
|
||||
<Custom Action="TerminateBrokers.SetParam" Before="TerminateBrokers"/>
|
||||
<Custom Action="RemoveAmyuniIdd" Before="RemoveRuntimeGeneratedFiles"/>
|
||||
<Custom Action="RemoveAmyuniIdd" Before="RemoveInstallFolder"/>
|
||||
<Custom Action="RemoveAmyuniIdd.SetParam" Before="RemoveAmyuniIdd"/>
|
||||
</InstallExecuteSequence>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<Binary Id="Custom_Actions_Dll" SourceFile="$(var.CustomActions.TargetDir)$(var.CustomActions.TargetName).dll" />
|
||||
|
||||
<CustomAction Id="CustomActionHello" DllEntry="CustomActionHello" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||
<CustomAction Id="RemoveRuntimeGeneratedFiles" DllEntry="RemoveRuntimeGeneratedFiles" Impersonate="no" Execute="deferred" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||
<CustomAction Id="RemoveInstallFolder" DllEntry="RemoveInstallFolder" Impersonate="no" Execute="deferred" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||
<CustomAction Id="TerminateProcesses" DllEntry="TerminateProcesses" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||
<CustomAction Id="TerminateBrokers" DllEntry="TerminateProcesses" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||
<CustomAction Id="AddFirewallRules" DllEntry="AddFirewallRules" Impersonate="no" Execute="deferred" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||
|
|
|
|||
|
|
@ -23,13 +23,12 @@ Patch dialog sequence:
|
|||
-->
|
||||
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs" xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
|
||||
<?include ../Includes.wxi?>
|
||||
<?foreach WIXUIARCH in X86;X64;A64 ?>
|
||||
<Fragment>
|
||||
<UI Id="UI_MyInstallDialog_$(WIXUIARCH)">
|
||||
<Publish Dialog="LicenseAgreementDlg" Control="Print" Event="DoAction" Value="WixUIPrintEula_$(WIXUIARCH)" />
|
||||
<Publish Dialog="BrowseDlg" Control="OK" Event="DoAction" Value="WixUIValidatePath_$(WIXUIARCH)" Order="3" Condition="NOT WIXUI_DONTVALIDATEPATH" />
|
||||
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="DoAction" Value="WixUIValidatePath_$(WIXUIARCH)" Order="5" Condition="NOT WIXUI_DONTVALIDATEPATH" />
|
||||
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="DoAction" Value="WixUIValidatePath_$(WIXUIARCH)" Order="2" Condition="NOT WIXUI_DONTVALIDATEPATH" />
|
||||
</UI>
|
||||
|
||||
<UIRef Id="UI_MyInstallDialog" />
|
||||
|
|
@ -65,16 +64,9 @@ Patch dialog sequence:
|
|||
<Publish Dialog="LicenseAgreementDlg" Control="Next" Event="NewDialog" Value="MyInstallDirDlg" Condition="LicenseAccepted = "1"" />
|
||||
|
||||
<Publish Dialog="MyInstallDirDlg" Control="Back" Event="NewDialog" Value="LicenseAgreementDlg" />
|
||||
<!-- Normalize INSTALLFOLDER_INNER before SetTargetPath and WixUIValidatePath run. -->
|
||||
<!-- UI case 1: already ends with \$(var.Product) but has no trailing slash, add the slash. -->
|
||||
<Publish Dialog="MyInstallDirDlg" Control="Next" Property="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER_INNER]\" Order="1" Condition="INSTALLFOLDER_INNER AND INSTALLFOLDER_INNER ~>> "\$(var.Product)"" />
|
||||
<!-- UI case 2: ends with a slash but not \$(var.Product)\, append $(var.Product)\. -->
|
||||
<Publish Dialog="MyInstallDirDlg" Control="Next" Property="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER_INNER]$(var.Product)\" Order="2" Condition="INSTALLFOLDER_INNER AND INSTALLFOLDER_INNER ~>> "\" AND NOT (INSTALLFOLDER_INNER ~>> "\$(var.Product)\" OR INSTALLFOLDER_INNER ~>> "\$(var.Product)")" />
|
||||
<!-- UI case 3: has no trailing slash and does not end with \$(var.Product), append \$(var.Product)\. -->
|
||||
<Publish Dialog="MyInstallDirDlg" Control="Next" Property="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER_INNER]\$(var.Product)\" Order="3" Condition="INSTALLFOLDER_INNER AND NOT INSTALLFOLDER_INNER ~>> "\" AND NOT (INSTALLFOLDER_INNER ~>> "\$(var.Product)\" OR INSTALLFOLDER_INNER ~>> "\$(var.Product)")" />
|
||||
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="SetTargetPath" Value="[WIXUI_INSTALLDIR]" Order="4" />
|
||||
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="SpawnDialog" Value="InvalidDirDlg" Order="6" Condition="NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID<>"1"" />
|
||||
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="7" Condition="WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID="1"" />
|
||||
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="SetTargetPath" Value="[WIXUI_INSTALLDIR]" Order="1" />
|
||||
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="SpawnDialog" Value="InvalidDirDlg" Order="3" Condition="NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID<>"1"" />
|
||||
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="4" Condition="WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID="1"" />
|
||||
<Publish Dialog="MyInstallDirDlg" Control="ChangeFolder" Property="_BrowseProperty" Value="[WIXUI_INSTALLDIR]" Order="1" />
|
||||
<Publish Dialog="MyInstallDirDlg" Control="ChangeFolder" Event="SpawnDialog" Value="BrowseDlg" Order="2" />
|
||||
<Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="MyInstallDirDlg" Order="1" Condition="NOT Installed" />
|
||||
|
|
|
|||
|
|
@ -1745,9 +1745,6 @@ pub struct LoginConfigHandler {
|
|||
pub direct: Option<bool>,
|
||||
pub received: bool,
|
||||
switch_uuid: Option<String>,
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
switch_back_allowed: bool,
|
||||
pub save_ab_password_to_recent: bool, // true: connected with ab password
|
||||
pub other_server: Option<(String, String, String)>,
|
||||
pub custom_fps: Arc<Mutex<Option<usize>>>,
|
||||
|
|
@ -1864,11 +1861,6 @@ impl LoginConfigHandler {
|
|||
|
||||
self.direct = None;
|
||||
self.received = false;
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
{
|
||||
self.switch_back_allowed = false;
|
||||
}
|
||||
self.switch_uuid = switch_uuid;
|
||||
self.adapter_luid = adapter_luid;
|
||||
self.selected_windows_session_id = None;
|
||||
|
|
@ -1882,23 +1874,6 @@ impl LoginConfigHandler {
|
|||
self.is_terminal_admin = is_terminal_admin;
|
||||
}
|
||||
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub fn allow_switch_back_once(&mut self) {
|
||||
self.switch_back_allowed = true;
|
||||
}
|
||||
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub fn consume_switch_back_permission(&mut self) -> bool {
|
||||
if self.switch_back_allowed {
|
||||
self.switch_back_allowed = false;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the client should auto login.
|
||||
/// Return password if the client should auto login, otherwise return empty string.
|
||||
pub fn should_auto_login(&self) -> String {
|
||||
|
|
@ -3402,36 +3377,6 @@ pub fn handle_login_error(
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
async fn consume_local_switch_sides_uuid(id: &str, uuid: &Uuid) -> bool {
|
||||
let Ok(mut conn) = crate::ipc::connect(1000, "").await else {
|
||||
return false;
|
||||
};
|
||||
let uuid = uuid.to_string();
|
||||
if conn
|
||||
.send(&crate::ipc::Data::SwitchSidesUuid(
|
||||
uuid.clone(),
|
||||
id.to_owned(),
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
match conn.next_timeout(1000).await {
|
||||
Ok(Some(crate::ipc::Data::SwitchSidesUuid(
|
||||
returned_uuid,
|
||||
returned_id,
|
||||
Some(true),
|
||||
))) => {
|
||||
returned_uuid == uuid && returned_id == id
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle hash message sent by peer.
|
||||
/// Hash will be used for login.
|
||||
///
|
||||
|
|
@ -3452,22 +3397,12 @@ pub async fn handle_hash(
|
|||
// Take care of password application order
|
||||
|
||||
// switch_uuid
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
{
|
||||
let uuid = lc.write().unwrap().switch_uuid.take();
|
||||
if let Some(uuid) = uuid {
|
||||
if let Ok(uuid) = uuid::Uuid::from_str(&uuid) {
|
||||
let id = lc.read().unwrap().id.clone();
|
||||
if !consume_local_switch_sides_uuid(&id, &uuid).await {
|
||||
log::warn!("Ignored untrusted switch_uuid");
|
||||
} else {
|
||||
lc.write().unwrap().allow_switch_back_once();
|
||||
send_switch_login_request(lc.clone(), peer, uuid).await;
|
||||
lc.write().unwrap().password_source = Default::default();
|
||||
return;
|
||||
}
|
||||
}
|
||||
let uuid = lc.write().unwrap().switch_uuid.take();
|
||||
if let Some(uuid) = uuid {
|
||||
if let Ok(uuid) = uuid::Uuid::from_str(&uuid) {
|
||||
send_switch_login_request(lc.clone(), peer, uuid).await;
|
||||
lc.write().unwrap().password_source = Default::default();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// last password
|
||||
|
|
|
|||
|
|
@ -1797,9 +1797,6 @@ impl<T: InvokeUiSession> Remote<T> {
|
|||
Ok(Permission::BlockInput) => {
|
||||
self.handler.set_permission("block_input", p.enabled);
|
||||
}
|
||||
Ok(Permission::PrivacyMode) => {
|
||||
self.handler.set_permission("privacy_mode", p.enabled);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1923,23 +1920,9 @@ impl<T: InvokeUiSession> Remote<T> {
|
|||
);
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
Some(misc::Union::SwitchBack(_)) => {
|
||||
let allow_switch_back = self
|
||||
.handler
|
||||
.lc
|
||||
.write()
|
||||
.unwrap()
|
||||
.consume_switch_back_permission();
|
||||
if allow_switch_back {
|
||||
self.handler.switch_back(&self.handler.get_id());
|
||||
} else {
|
||||
log::warn!(
|
||||
"Ignored unsolicited SwitchBack from {}",
|
||||
self.handler.get_id()
|
||||
);
|
||||
}
|
||||
#[cfg(feature = "flutter")]
|
||||
self.handler.switch_back(&self.handler.get_id());
|
||||
}
|
||||
#[cfg(all(feature = "flutter", feature = "plugin_framework"))]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
|
|
|
|||
165
src/core_main.rs
165
src/core_main.rs
|
|
@ -146,13 +146,7 @@ pub fn core_main() -> Option<Vec<String>> {
|
|||
crate::portable_service::client::set_quick_support(_is_quick_support);
|
||||
}
|
||||
let mut log_name = "".to_owned();
|
||||
// Keep portable-service logs under a stable directory name.
|
||||
let has_portable_service_shmem_arg = args
|
||||
.iter()
|
||||
.any(|arg| arg.starts_with("--portable-service-shmem-name="));
|
||||
if has_portable_service_shmem_arg {
|
||||
log_name = "portable-service".to_owned();
|
||||
} else if args.len() > 0 && args[0].starts_with("--") {
|
||||
if args.len() > 0 && args[0].starts_with("--") {
|
||||
let name = args[0].replace("--", "");
|
||||
if !name.is_empty() {
|
||||
log_name = name;
|
||||
|
|
@ -199,20 +193,6 @@ pub fn core_main() -> Option<Vec<String>> {
|
|||
}
|
||||
std::thread::spawn(move || crate::start_server(false, no_server));
|
||||
} else {
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
// Root CLI management commands must talk to the user `--server` main IPC.
|
||||
// Example: `sudo rustdesk --option custom-rendezvous-server` should query the
|
||||
// user's IPC instead of root's `/tmp/<app>-0/ipc`; `connect()` still limits this
|
||||
// routing to empty-postfix main IPC only.
|
||||
let _user_main_ipc_scope = if crate::platform::is_installed()
|
||||
&& is_root()
|
||||
&& is_user_main_ipc_scope_cli_command(&args)
|
||||
{
|
||||
Some(crate::ipc::UserMainIpcScope::new())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use crate::platform;
|
||||
|
|
@ -641,98 +621,6 @@ pub fn core_main() -> Option<Vec<String>> {
|
|||
println!("Installation and administrative privileges required!");
|
||||
}
|
||||
return None;
|
||||
} else if args[0] == "--deploy" {
|
||||
if config::Config::no_register_device() {
|
||||
println!("Cannot deploy an unregistrable device!");
|
||||
} else if crate::platform::is_installed() && is_root() {
|
||||
let max = args.len() - 1;
|
||||
let pos = args.iter().position(|x| x == "--token").unwrap_or(max);
|
||||
if pos >= max {
|
||||
println!("--token is required!");
|
||||
return None;
|
||||
}
|
||||
let token = args[pos + 1].to_owned();
|
||||
let get_value = |c: &str| {
|
||||
let pos = args.iter().position(|x| x == c).unwrap_or(max);
|
||||
if pos < max {
|
||||
Some(args[pos + 1].to_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
let new_id = get_value("--id");
|
||||
let local_id = crate::ipc::get_id();
|
||||
let id_to_deploy = new_id.clone().unwrap_or_else(|| local_id.clone());
|
||||
let uuid = crate::encode64(hbb_common::get_uuid());
|
||||
let pk = crate::encode64(
|
||||
hbb_common::config::Config::get_key_pair().1,
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"id": id_to_deploy,
|
||||
"uuid": uuid,
|
||||
"pk": pk,
|
||||
});
|
||||
let header = "Authorization: Bearer ".to_owned() + &token;
|
||||
let url = crate::ui_interface::get_api_server() + "/api/devices/deploy";
|
||||
match crate::post_request_sync(url, body.to_string(), &header) {
|
||||
Err(err) => {
|
||||
println!("Request failed: {}", err);
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(text) => {
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);
|
||||
let result = parsed["result"].as_str().unwrap_or("");
|
||||
match result {
|
||||
"OK" => {
|
||||
if let Some(ref new_id) = new_id {
|
||||
if *new_id != local_id {
|
||||
if let Err(err) =
|
||||
crate::ipc::set_config("id", new_id.clone())
|
||||
{
|
||||
println!(
|
||||
"Failed to persist deployed id locally: {}",
|
||||
err
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(err) = crate::ipc::notify_deployed() {
|
||||
log::warn!("Failed to notify deployed state: {}", err);
|
||||
}
|
||||
println!("Device deployed.");
|
||||
}
|
||||
"NOT_ENABLED" => {
|
||||
println!("Server does not require deployment.");
|
||||
std::process::exit(3);
|
||||
}
|
||||
"INVALID_INPUT" => {
|
||||
println!("Invalid input.");
|
||||
std::process::exit(5);
|
||||
}
|
||||
"ID_TAKEN" => {
|
||||
println!(
|
||||
"Id `{}` is already used by another machine on the server.",
|
||||
id_to_deploy
|
||||
);
|
||||
std::process::exit(6);
|
||||
}
|
||||
_ => {
|
||||
if text.is_empty() {
|
||||
println!("Unknown response.");
|
||||
} else {
|
||||
println!("{}", text);
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("Installation and administrative privileges required!");
|
||||
}
|
||||
return None;
|
||||
} else if args[0] == "--check-hwcodec-config" {
|
||||
#[cfg(feature = "hwcodec")]
|
||||
crate::ipc::hwcodec_process();
|
||||
|
|
@ -952,57 +840,6 @@ fn is_root() -> bool {
|
|||
crate::platform::is_root()
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos", test))]
|
||||
fn is_user_main_ipc_scope_cli_command(args: &[String]) -> bool {
|
||||
matches!(
|
||||
args.first().map(String::as_str),
|
||||
Some("--password")
|
||||
| Some("--set-unlock-pin")
|
||||
| Some("--get-id")
|
||||
| Some("--set-id")
|
||||
| Some("--config")
|
||||
| Some("--option")
|
||||
| Some("--assign")
|
||||
| Some("--deploy")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn args(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|value| value.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_main_ipc_scope_cli_command_matches_management_commands_only() {
|
||||
for command in [
|
||||
"--password",
|
||||
"--set-unlock-pin",
|
||||
"--get-id",
|
||||
"--set-id",
|
||||
"--config",
|
||||
"--option",
|
||||
"--assign",
|
||||
"--deploy",
|
||||
] {
|
||||
assert!(is_user_main_ipc_scope_cli_command(&args(&[command])));
|
||||
}
|
||||
|
||||
for command in [
|
||||
"--service",
|
||||
"--server",
|
||||
"--tray",
|
||||
"--cm",
|
||||
"--check-hwcodec-config",
|
||||
"--connect",
|
||||
] {
|
||||
assert!(!is_user_main_ipc_scope_cli_command(&args(&[command])));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the executable is a Quick Support version.
|
||||
/// Note: This function must be kept in sync with `libs/portable/src/main.rs`.
|
||||
#[cfg(windows)]
|
||||
|
|
|
|||
|
|
@ -1135,10 +1135,6 @@ impl InvokeUiSession for FlutterHandler {
|
|||
("message", json!(&opened.message)),
|
||||
("pid", json!(opened.pid)),
|
||||
("service_id", json!(&opened.service_id)),
|
||||
(
|
||||
"replay_terminal_output",
|
||||
json!(opened.replay_terminal_output),
|
||||
),
|
||||
];
|
||||
if !opened.persistent_sessions.is_empty() {
|
||||
event_data.push(("persistent_sessions", json!(opened.persistent_sessions)));
|
||||
|
|
|
|||
|
|
@ -575,6 +575,7 @@ pub fn session_handle_flutter_key_event(
|
|||
if let Some(session) = sessions::get_session_by_session_id(&session_id) {
|
||||
let keyboard_mode = session.get_keyboard_mode();
|
||||
session.handle_flutter_key_event(
|
||||
session_id,
|
||||
&keyboard_mode,
|
||||
&character,
|
||||
usb_hid,
|
||||
|
|
@ -595,6 +596,7 @@ pub fn session_handle_flutter_raw_key_event(
|
|||
if let Some(session) = sessions::get_session_by_session_id(&session_id) {
|
||||
let keyboard_mode = session.get_keyboard_mode();
|
||||
session.handle_flutter_raw_key_event(
|
||||
session_id,
|
||||
&keyboard_mode,
|
||||
&name,
|
||||
platform_code,
|
||||
|
|
@ -972,27 +974,6 @@ pub fn main_show_option(_key: String) -> SyncReturn<bool> {
|
|||
}
|
||||
|
||||
pub fn main_set_option(key: String, value: String) {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
let is_permission_option = key.eq(config::keys::OPTION_ENABLE_CLIPBOARD)
|
||||
|| key.eq(config::keys::OPTION_ENABLE_FILE_TRANSFER)
|
||||
|| key.eq(config::keys::OPTION_ENABLE_AUDIO);
|
||||
let allow_perm_change_in_accept_window = config::option2bool(
|
||||
config::keys::OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW,
|
||||
&crate::get_builtin_option(config::keys::OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW),
|
||||
);
|
||||
if is_permission_option
|
||||
&& !allow_perm_change_in_accept_window
|
||||
&& crate::ui_cm_interface::has_active_clients()
|
||||
{
|
||||
log::info!(
|
||||
"blocked main_set_option by policy, key={}, value={}",
|
||||
key,
|
||||
value
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "android")]
|
||||
if key.eq(config::keys::OPTION_ENABLE_KEYBOARD) {
|
||||
crate::ui_cm_interface::switch_permission_all(
|
||||
|
|
@ -1040,29 +1021,7 @@ pub fn main_get_options_sync() -> SyncReturn<String> {
|
|||
}
|
||||
|
||||
pub fn main_set_options(json: String) {
|
||||
let mut map: HashMap<String, String> = serde_json::from_str(&json).unwrap_or(HashMap::new());
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
let allow_perm_change_in_accept_window = config::option2bool(
|
||||
config::keys::OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW,
|
||||
&crate::get_builtin_option(config::keys::OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW),
|
||||
);
|
||||
if !allow_perm_change_in_accept_window && crate::ui_cm_interface::has_active_clients() {
|
||||
for key in [
|
||||
config::keys::OPTION_ENABLE_CLIPBOARD,
|
||||
config::keys::OPTION_ENABLE_FILE_TRANSFER,
|
||||
config::keys::OPTION_ENABLE_AUDIO,
|
||||
] {
|
||||
if let Some(value) = map.remove(key) {
|
||||
log::info!(
|
||||
"blocked main_set_options item by policy, key={}, value={}",
|
||||
key,
|
||||
value
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let map: HashMap<String, String> = serde_json::from_str(&json).unwrap_or(HashMap::new());
|
||||
if !map.is_empty() {
|
||||
set_options(map)
|
||||
}
|
||||
|
|
@ -1771,6 +1730,7 @@ pub fn cm_get_clients_length() -> usize {
|
|||
|
||||
pub fn main_init(app_dir: String, custom_client_config: String) {
|
||||
initialize(&app_dir, &custom_client_config);
|
||||
crate::keyboard::shortcuts::reload_from_config();
|
||||
}
|
||||
|
||||
pub fn main_device_id(id: String) {
|
||||
|
|
@ -2213,7 +2173,7 @@ pub fn cm_elevate_portable(conn_id: i32) {
|
|||
}
|
||||
|
||||
pub fn cm_switch_back(conn_id: i32) {
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
#[cfg(not(any(target_os = "ios")))]
|
||||
crate::ui_cm_interface::switch_back(conn_id);
|
||||
}
|
||||
|
||||
|
|
@ -2290,6 +2250,17 @@ pub fn main_init_input_source() -> SyncReturn<()> {
|
|||
SyncReturn(())
|
||||
}
|
||||
|
||||
pub fn main_reload_keyboard_shortcuts() -> SyncReturn<()> {
|
||||
crate::keyboard::shortcuts::reload_from_config();
|
||||
SyncReturn(())
|
||||
}
|
||||
|
||||
pub fn main_get_default_keyboard_shortcuts() -> SyncReturn<String> {
|
||||
let bindings = crate::keyboard::shortcuts::default_bindings();
|
||||
let json = serde_json::to_string(&bindings).unwrap_or_default();
|
||||
SyncReturn(json)
|
||||
}
|
||||
|
||||
pub fn main_is_installed_lower_version() -> SyncReturn<bool> {
|
||||
SyncReturn(is_installed_lower_version())
|
||||
}
|
||||
|
|
|
|||
688
src/ipc.rs
688
src/ipc.rs
|
|
@ -1,28 +1,33 @@
|
|||
#[path = "ipc/auth.rs"]
|
||||
mod ipc_auth;
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[path = "ipc/fs.rs"]
|
||||
mod ipc_fs;
|
||||
use crate::{
|
||||
common::CheckTestNatType,
|
||||
privacy_mode::PrivacyModeState,
|
||||
ui_interface::{get_local_option, set_local_option},
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use parity_tokio_ipc::{
|
||||
Connection as Conn, ConnectionClient as ConnClient, Endpoint, Incoming, SecurityAttributes,
|
||||
};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
use std::{fs::File, io::prelude::*};
|
||||
|
||||
#[cfg(all(feature = "flutter", feature = "plugin_framework"))]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
use crate::plugin::ipc::Plugin;
|
||||
use crate::{
|
||||
common::{is_server, CheckTestNatType},
|
||||
privacy_mode,
|
||||
privacy_mode::PrivacyModeState,
|
||||
rendezvous_mediator::RendezvousMediator,
|
||||
ui_interface::{get_local_option, set_local_option},
|
||||
};
|
||||
use bytes::Bytes;
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub use clipboard::ClipboardFile;
|
||||
#[cfg(target_os = "linux")]
|
||||
use hbb_common::anyhow;
|
||||
use hbb_common::{
|
||||
allow_err, bail, bytes,
|
||||
bytes_codec::BytesCodec,
|
||||
config::{self, keys::OPTION_ALLOW_WEBSOCKET, Config, Config2},
|
||||
config::{
|
||||
self,
|
||||
keys::{self, OPTION_ALLOW_WEBSOCKET},
|
||||
Config, Config2,
|
||||
},
|
||||
futures::StreamExt as _,
|
||||
futures_util::sink::SinkExt,
|
||||
log, password_security as password, timeout,
|
||||
|
|
@ -33,92 +38,13 @@ use hbb_common::{
|
|||
tokio_util::codec::Framed,
|
||||
ResultType,
|
||||
};
|
||||
#[cfg(windows)]
|
||||
pub(crate) use ipc_auth::authorize_windows_portable_service_ipc_connection;
|
||||
#[cfg(windows)]
|
||||
pub(crate) use ipc_auth::ensure_peer_executable_matches_current_by_pid_opt;
|
||||
#[cfg(windows)]
|
||||
pub(crate) use ipc_auth::log_rejected_windows_ipc_connection;
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
use ipc_auth::{active_uid, authorize_service_scoped_ipc_connection};
|
||||
#[cfg(windows)]
|
||||
use ipc_auth::{
|
||||
authorize_windows_main_ipc_connection, portable_service_listener_security_attributes,
|
||||
should_allow_everyone_create_on_windows,
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) use ipc_auth::{
|
||||
ensure_peer_executable_matches_current_by_fd, is_allowed_service_peer_uid,
|
||||
log_rejected_uinput_connection, peer_uid_from_fd,
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
use ipc_fs::terminal_count_candidate_uids;
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
use ipc_fs::{
|
||||
check_pid, ensure_secure_ipc_parent_dir, scrub_secure_ipc_parent_dir,
|
||||
should_scrub_parent_entries_after_check_pid, write_pid,
|
||||
};
|
||||
use parity_tokio_ipc::{
|
||||
Connection as Conn, ConnectionClient as ConnClient, Endpoint, Incoming, SecurityAttributes,
|
||||
};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
use std::cell::Cell;
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
use crate::{common::is_server, privacy_mode, rendezvous_mediator::RendezvousMediator};
|
||||
|
||||
// IPC actions here.
|
||||
pub const IPC_ACTION_CLOSE: &str = "close";
|
||||
#[cfg(target_os = "windows")]
|
||||
const PORTABLE_SERVICE_IPC_HANDSHAKE_TIMEOUT_MS: u64 = 3_000;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) const IPC_TOKEN_LEN: usize = 64;
|
||||
#[cfg(target_os = "windows")]
|
||||
const IPC_TOKEN_RANDOM_BYTES: usize = IPC_TOKEN_LEN / 2;
|
||||
#[cfg(target_os = "windows")]
|
||||
const _: () = assert!(IPC_TOKEN_LEN % 2 == 0);
|
||||
pub static EXIT_RECV_CLOSE: AtomicBool = AtomicBool::new(true);
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
thread_local! {
|
||||
static USE_USER_MAIN_IPC: Cell<bool> = Cell::new(false);
|
||||
}
|
||||
|
||||
#[must_use = "bind this guard to a local variable to keep the IPC scope active"]
|
||||
/// Thread-local guard for routing root main IPC to the active user on Linux/macOS.
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
pub(crate) struct UserMainIpcScope {
|
||||
previous: bool,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
impl UserMainIpcScope {
|
||||
pub(crate) fn new() -> Self {
|
||||
let previous = USE_USER_MAIN_IPC.with(|use_user_main| {
|
||||
let previous = use_user_main.get();
|
||||
use_user_main.set(true);
|
||||
previous
|
||||
});
|
||||
Self { previous }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
impl Drop for UserMainIpcScope {
|
||||
fn drop(&mut self) {
|
||||
USE_USER_MAIN_IPC.with(|use_user_main| use_user_main.set(self.previous));
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub async fn connect_service(ms_timeout: u64) -> ResultType<ConnectionTmpl<ConnClient>> {
|
||||
connect(ms_timeout, crate::POSTFIX_SERVICE).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(tag = "t", content = "c")]
|
||||
pub enum FS {
|
||||
|
|
@ -281,8 +207,6 @@ pub enum DataControl {
|
|||
pub enum DataPortableService {
|
||||
Ping,
|
||||
Pong,
|
||||
AuthToken(String),
|
||||
AuthResult(bool),
|
||||
ConnCount(Option<usize>),
|
||||
Mouse((Vec<u8>, i32, String, u32, bool, bool)),
|
||||
Pointer((Vec<u8>, i32)),
|
||||
|
|
@ -313,7 +237,6 @@ pub enum Data {
|
|||
restart: bool,
|
||||
recording: bool,
|
||||
block_input: bool,
|
||||
privacy_mode: bool,
|
||||
from_switch: bool,
|
||||
},
|
||||
ChatMessage {
|
||||
|
|
@ -349,7 +272,6 @@ pub enum Data {
|
|||
ClipboardNonFile(Option<(String, Vec<ClipboardNonFile>)>),
|
||||
PrivacyModeState((i32, PrivacyModeState, String)),
|
||||
TestRendezvousServer,
|
||||
Deployed,
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
Keyboard(DataKeyboard),
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
|
|
@ -362,14 +284,7 @@ pub enum Data {
|
|||
Empty,
|
||||
Disconnected,
|
||||
DataPortableService(DataPortableService),
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
SwitchSidesRequest(String),
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
SwitchSidesUuid(String, String, Option<bool>),
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
SwitchSidesBack,
|
||||
UrlLink(String),
|
||||
VoiceCallIncoming,
|
||||
|
|
@ -488,22 +403,6 @@ pub async fn start(postfix: &str) -> ResultType<()> {
|
|||
Ok(stream) => {
|
||||
let mut stream = Connection::new(stream);
|
||||
let postfix = postfix.to_owned();
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
if config::is_service_ipc_postfix(&postfix) {
|
||||
if !authorize_service_scoped_ipc_connection(&stream, &postfix) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if postfix.is_empty() {
|
||||
// Windows main IPC (`postfix == ""`) is authorized here.
|
||||
// Other security-sensitive channels use dedicated authorization paths:
|
||||
// - `_portable_service`: portable-service listener + handshake policy
|
||||
// - service-scoped postfixes: service-specific listener/authorization
|
||||
if !authorize_windows_main_ipc_connection(&stream, &postfix) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match stream.next().await {
|
||||
|
|
@ -512,48 +411,9 @@ pub async fn start(postfix: &str) -> ResultType<()> {
|
|||
break;
|
||||
}
|
||||
Ok(Some(data)) => {
|
||||
// On Linux/macOS, the protected `_service` channel is used only for
|
||||
// syncing config between root service and the active user process.
|
||||
//
|
||||
// NOTE: `is_service_ipc_postfix()` also includes `_uinput_*`, but those
|
||||
// channels are handled by the dedicated uinput listener/protocol in
|
||||
// `src/server/uinput.rs` and therefore do not share this Data enum
|
||||
// allowlist. The SyncConfig allowlist here is intentionally scoped to the
|
||||
// `_service` channel only.
|
||||
//
|
||||
// Keep this explicit branch to avoid policy drift between `_service` and
|
||||
// uinput IPC paths while still minimizing exposed message surface here.
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
if postfix == crate::POSTFIX_SERVICE {
|
||||
if matches!(&data, Data::SyncConfig(_)) {
|
||||
handle(data, &mut stream).await;
|
||||
} else {
|
||||
log::warn!(
|
||||
"Rejected non-sync data on protected _service IPC channel: postfix={}, data_kind={:?}, peer_uid={:?}",
|
||||
postfix,
|
||||
std::mem::discriminant(&data),
|
||||
stream.peer_uid()
|
||||
);
|
||||
// Close the connection to avoid keeping a protected channel
|
||||
// alive while repeatedly receiving invalid traffic.
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
handle(data, &mut stream).await;
|
||||
}
|
||||
Ok(None) => {
|
||||
// `Ok(None)` means a complete frame arrived but did not
|
||||
// deserialize into `Data`. Peer close/reset is returned as
|
||||
// `Err` by `ConnectionTmpl::next()`. Keep the historical
|
||||
// ignore behavior except on the protected `_service` channel.
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
{
|
||||
if postfix == crate::POSTFIX_SERVICE {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -568,77 +428,20 @@ pub async fn start(postfix: &str) -> ResultType<()> {
|
|||
|
||||
pub async fn new_listener(postfix: &str) -> ResultType<Incoming> {
|
||||
let path = Config::ipc_path(postfix);
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
let should_scrub_parent_entries = ensure_secure_ipc_parent_dir(&path, postfix)?;
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
let existing_listener_alive = check_pid(postfix).await;
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
if should_scrub_parent_entries_after_check_pid(
|
||||
should_scrub_parent_entries,
|
||||
existing_listener_alive,
|
||||
) {
|
||||
scrub_secure_ipc_parent_dir(&path, postfix)?;
|
||||
}
|
||||
#[cfg(not(any(windows, target_os = "android", target_os = "ios")))]
|
||||
check_pid(postfix).await;
|
||||
let mut endpoint = Endpoint::new(path.clone());
|
||||
let security_attrs = {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if postfix == "_portable_service" {
|
||||
portable_service_listener_security_attributes()
|
||||
} else if should_allow_everyone_create_on_windows(postfix) {
|
||||
SecurityAttributes::allow_everyone_create()
|
||||
} else {
|
||||
Ok(SecurityAttributes::empty())
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
SecurityAttributes::allow_everyone_create()
|
||||
}
|
||||
};
|
||||
match security_attrs {
|
||||
match SecurityAttributes::allow_everyone_create() {
|
||||
Ok(attr) => endpoint.set_security_attributes(attr),
|
||||
Err(err) => {
|
||||
log::error!("Failed to set ipc{} security: {}", postfix, err);
|
||||
#[cfg(windows)]
|
||||
if postfix == "_portable_service" {
|
||||
// Fail closed for `_portable_service` when SDDL construction fails.
|
||||
// This endpoint is security-critical and must not start with default ACLs.
|
||||
return Err(err.into());
|
||||
}
|
||||
}
|
||||
Err(err) => log::error!("Failed to set ipc{} security: {}", postfix, err),
|
||||
};
|
||||
match endpoint.incoming() {
|
||||
Ok(incoming) => {
|
||||
if postfix == crate::POSTFIX_SERVICE {
|
||||
log::info!("Started protected ipc service server: postfix={}", postfix);
|
||||
} else {
|
||||
log::info!("Started ipc{} server at path: {}", postfix, &path);
|
||||
}
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
log::info!("Started ipc{} server at path: {}", postfix, &path);
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// NOTE: On Linux/macOS, some IPC sockets are intentionally world-connectable
|
||||
// (0666) so the active (non-root) user process can connect. Authorization is
|
||||
// enforced at accept-time for these channels, and the protected `_service`
|
||||
// channel is further restricted by an explicit message allowlist (SyncConfig
|
||||
// only).
|
||||
let socket_mode = if config::is_service_ipc_postfix(postfix) {
|
||||
0o0666
|
||||
} else {
|
||||
0o0600
|
||||
};
|
||||
if let Err(err) =
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(socket_mode))
|
||||
{
|
||||
log::error!(
|
||||
"Failed to set permissions on ipc{} socket at path {}: {}",
|
||||
postfix,
|
||||
&path,
|
||||
err
|
||||
);
|
||||
std::fs::remove_file(&path).ok();
|
||||
return Err(err.into());
|
||||
}
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o0777)).ok();
|
||||
write_pid(postfix);
|
||||
}
|
||||
Ok(incoming)
|
||||
|
|
@ -967,12 +770,6 @@ async fn handle(data: Data, stream: &mut Connection) {
|
|||
Data::TestRendezvousServer => {
|
||||
crate::test_rendezvous_server();
|
||||
}
|
||||
Data::Deployed => {
|
||||
crate::rendezvous_mediator::NEEDS_DEPLOY.store(false, Ordering::SeqCst);
|
||||
crate::rendezvous_mediator::RendezvousMediator::restart();
|
||||
}
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
Data::SwitchSidesRequest(id) => {
|
||||
let uuid = uuid::Uuid::new_v4();
|
||||
crate::server::insert_switch_sides_uuid(id, uuid.clone());
|
||||
|
|
@ -982,19 +779,6 @@ async fn handle(data: Data, stream: &mut Connection) {
|
|||
.await
|
||||
);
|
||||
}
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
Data::SwitchSidesUuid(uuid, id, None) => {
|
||||
let allowed = uuid
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(|uuid| crate::server::remove_pending_switch_sides_uuid(&id, &uuid))
|
||||
.unwrap_or(false);
|
||||
allow_err!(
|
||||
stream
|
||||
.send(&Data::SwitchSidesUuid(uuid, id, Some(allowed)))
|
||||
.await
|
||||
);
|
||||
}
|
||||
#[cfg(all(feature = "flutter", feature = "plugin_framework"))]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
Data::Plugin(plugin) => crate::plugin::ipc::handle_plugin(plugin, stream).await,
|
||||
|
|
@ -1146,210 +930,13 @@ async fn handle(data: Data, stream: &mut Connection) {
|
|||
);
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn generate_one_time_ipc_token() -> ResultType<String> {
|
||||
use hbb_common::rand::{rngs::OsRng, RngCore as _};
|
||||
use std::fmt::Write as _;
|
||||
|
||||
let mut random_bytes = [0u8; IPC_TOKEN_RANDOM_BYTES];
|
||||
let mut rng = OsRng;
|
||||
rng.try_fill_bytes(&mut random_bytes).map_err(|err| {
|
||||
hbb_common::anyhow::anyhow!(
|
||||
"failed to generate portable service ipc token from OsRng: {}",
|
||||
err
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut token = String::with_capacity(IPC_TOKEN_LEN);
|
||||
for byte in random_bytes {
|
||||
let _ = write!(token, "{:02x}", byte);
|
||||
}
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn constant_time_ipc_token_eq(expected: &str, candidate: &str) -> bool {
|
||||
if expected.len() != IPC_TOKEN_LEN || candidate.len() != IPC_TOKEN_LEN {
|
||||
return false;
|
||||
}
|
||||
expected
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.zip(candidate.as_bytes().iter())
|
||||
.fold(0u8, |diff, (left, right)| diff | (*left ^ *right))
|
||||
== 0
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) async fn portable_service_ipc_handshake_as_client<T>(
|
||||
stream: &mut ConnectionTmpl<T>,
|
||||
token: &str,
|
||||
) -> ResultType<()>
|
||||
where
|
||||
T: AsyncRead + AsyncWrite + std::marker::Unpin,
|
||||
{
|
||||
stream
|
||||
.send(&Data::DataPortableService(DataPortableService::AuthToken(
|
||||
token.to_owned(),
|
||||
)))
|
||||
.await?;
|
||||
match stream
|
||||
.next_timeout(PORTABLE_SERVICE_IPC_HANDSHAKE_TIMEOUT_MS)
|
||||
.await?
|
||||
{
|
||||
Some(Data::DataPortableService(DataPortableService::AuthResult(true))) => Ok(()),
|
||||
Some(Data::DataPortableService(DataPortableService::AuthResult(false))) => {
|
||||
bail!("portable service ipc handshake was rejected by server")
|
||||
}
|
||||
Some(_) | None => bail!("portable service ipc handshake returned an unexpected response"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) async fn portable_service_ipc_handshake_as_server<T, F>(
|
||||
stream: &mut ConnectionTmpl<T>,
|
||||
mut validate_token: F,
|
||||
) -> ResultType<()>
|
||||
where
|
||||
T: AsyncRead + AsyncWrite + std::marker::Unpin,
|
||||
// Token validators must use `constant_time_ipc_token_eq` or an equivalent
|
||||
// fixed-length comparison; this handshake is part of the privilege boundary.
|
||||
F: FnMut(&str) -> bool,
|
||||
{
|
||||
let authorized = match stream
|
||||
.next_timeout(PORTABLE_SERVICE_IPC_HANDSHAKE_TIMEOUT_MS)
|
||||
.await?
|
||||
{
|
||||
Some(Data::DataPortableService(DataPortableService::AuthToken(token))) => {
|
||||
validate_token(&token)
|
||||
}
|
||||
Some(_) | None => false,
|
||||
};
|
||||
stream
|
||||
.send(&Data::DataPortableService(DataPortableService::AuthResult(
|
||||
authorized,
|
||||
)))
|
||||
.await?;
|
||||
if !authorized {
|
||||
bail!("portable service ipc handshake failed")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
async fn connect_with_path(ms_timeout: u64, path: &str) -> ResultType<ConnectionTmpl<ConnClient>> {
|
||||
let client = timeout(ms_timeout, Endpoint::connect(path)).await??;
|
||||
Ok(ConnectionTmpl::new(client))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[inline]
|
||||
fn select_server_uid_for_user_main_ipc(
|
||||
server_uids: &[u32],
|
||||
active_uid: Option<u32>,
|
||||
prefer_root: bool,
|
||||
) -> ResultType<u32> {
|
||||
let mut server_uids = server_uids.to_vec();
|
||||
server_uids.sort_unstable();
|
||||
server_uids.dedup();
|
||||
|
||||
match server_uids.as_slice() {
|
||||
[] => {
|
||||
if let Some(uid) = active_uid {
|
||||
// If no `--server` processes are found but the active user is identifiable,
|
||||
// try the active user anyway because the main process may also listen on "" IPC.
|
||||
return Ok(uid);
|
||||
} else {
|
||||
bail!("No --server process found for user main IPC")
|
||||
}
|
||||
}
|
||||
[uid] => return Ok(*uid),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if prefer_root && server_uids.contains(&0) {
|
||||
return Ok(0);
|
||||
}
|
||||
if let Some(active_uid) = active_uid.filter(|uid| server_uids.contains(uid)) {
|
||||
return Ok(active_uid);
|
||||
}
|
||||
bail!("Multiple --server processes found for user main IPC");
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
fn running_server_uids_for_current_exe() -> ResultType<Vec<u32>> {
|
||||
let current_exe = std::env::current_exe()?;
|
||||
let current_exe_path = std::fs::canonicalize(¤t_exe)?;
|
||||
let current_pid = hbb_common::sysinfo::Pid::from_u32(std::process::id());
|
||||
let mut sys = hbb_common::sysinfo::System::new();
|
||||
sys.refresh_processes();
|
||||
let mut server_uids = Vec::new();
|
||||
for process in sys.processes().values() {
|
||||
if process.pid() == current_pid {
|
||||
continue;
|
||||
}
|
||||
if process.cmd().get(1).map_or(true, |arg| arg != "--server") {
|
||||
continue;
|
||||
}
|
||||
let Ok(process_path) = std::fs::canonicalize(process.exe()) else {
|
||||
continue;
|
||||
};
|
||||
if process_path != current_exe_path {
|
||||
continue;
|
||||
}
|
||||
let Some(uid) = process.user_id().map(|uid| **uid as u32) else {
|
||||
// Root CLI management commands need a stable matching `--server` target.
|
||||
// If this key process races during enumeration, failing the command is clearer
|
||||
// than silently skipping it; `--server` is not expected to exit frequently.
|
||||
bail!("Failed to read --server process uid");
|
||||
};
|
||||
server_uids.push(uid);
|
||||
}
|
||||
Ok(server_uids)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
fn user_main_ipc_server_uid() -> ResultType<u32> {
|
||||
let server_uids = running_server_uids_for_current_exe()?;
|
||||
#[cfg(target_os = "linux")]
|
||||
let prefer_root = crate::platform::linux::is_login_screen_wayland();
|
||||
#[cfg(target_os = "macos")]
|
||||
let prefer_root = false;
|
||||
select_server_uid_for_user_main_ipc(&server_uids, active_uid(), prefer_root)
|
||||
}
|
||||
|
||||
pub async fn connect(ms_timeout: u64, postfix: &str) -> ResultType<ConnectionTmpl<ConnClient>> {
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
{
|
||||
let use_user_main_ipc = USE_USER_MAIN_IPC.with(|use_user_main| use_user_main.get());
|
||||
let is_root_main_ipc =
|
||||
unsafe { hbb_common::libc::geteuid() == 0 } && postfix.is_empty() && use_user_main_ipc;
|
||||
if is_root_main_ipc {
|
||||
let uid = user_main_ipc_server_uid()?;
|
||||
let path = Config::ipc_path_for_uid(uid, postfix);
|
||||
return connect_with_path(ms_timeout, &path).await;
|
||||
}
|
||||
let path = Config::ipc_path(postfix);
|
||||
return connect_with_path(ms_timeout, &path).await;
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
{
|
||||
let path = Config::ipc_path(postfix);
|
||||
connect_with_path(ms_timeout, &path).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub async fn connect_for_uid(
|
||||
ms_timeout: u64,
|
||||
uid: u32,
|
||||
postfix: &str,
|
||||
) -> ResultType<ConnectionTmpl<ConnClient>> {
|
||||
let path = Config::ipc_path_for_uid(uid, postfix);
|
||||
connect_with_path(ms_timeout, &path).await
|
||||
let path = Config::ipc_path(postfix);
|
||||
let client = timeout(ms_timeout, Endpoint::connect(&path)).await??;
|
||||
Ok(ConnectionTmpl::new(client))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
|
|
@ -1429,6 +1016,54 @@ pub async fn start_pa() {
|
|||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[cfg(not(windows))]
|
||||
fn get_pid_file(postfix: &str) -> String {
|
||||
let path = Config::ipc_path(postfix);
|
||||
format!("{}.pid", path)
|
||||
}
|
||||
|
||||
#[cfg(not(any(windows, target_os = "android", target_os = "ios")))]
|
||||
async fn check_pid(postfix: &str) {
|
||||
let pid_file = get_pid_file(postfix);
|
||||
if let Ok(mut file) = File::open(&pid_file) {
|
||||
let mut content = String::new();
|
||||
file.read_to_string(&mut content).ok();
|
||||
let pid = content.parse::<usize>().unwrap_or(0);
|
||||
if pid > 0 {
|
||||
use hbb_common::sysinfo::System;
|
||||
let mut sys = System::new();
|
||||
sys.refresh_processes();
|
||||
if let Some(p) = sys.process(pid.into()) {
|
||||
if let Some(current) = sys.process((std::process::id() as usize).into()) {
|
||||
if current.name() == p.name() {
|
||||
// double check with connect
|
||||
if connect(1000, postfix).await.is_ok() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// if not remove old ipc file, the new ipc creation will fail
|
||||
// if we remove a ipc file, but the old ipc process is still running,
|
||||
// new connection to the ipc will connect to new ipc, old connection to old ipc still keep alive
|
||||
std::fs::remove_file(&Config::ipc_path(postfix)).ok();
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[cfg(not(windows))]
|
||||
fn write_pid(postfix: &str) {
|
||||
let path = get_pid_file(postfix);
|
||||
if let Ok(mut file) = File::create(&path) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o0777)).ok();
|
||||
file.write_all(&std::process::id().to_string().into_bytes())
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ConnectionTmpl<T> {
|
||||
inner: Framed<T, BytesCodec>,
|
||||
}
|
||||
|
|
@ -1875,13 +1510,6 @@ pub async fn test_rendezvous_server() -> ResultType<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
pub async fn notify_deployed() -> ResultType<()> {
|
||||
let mut c = connect(1000, "").await?;
|
||||
c.send(&Data::Deployed).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
pub async fn send_url_scheme(url: String) -> ResultType<()> {
|
||||
connect(1_000, "_url")
|
||||
|
|
@ -1899,10 +1527,9 @@ pub fn close_all_instances() -> ResultType<bool> {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
pub async fn connect_to_user_session(usid: Option<u32>) -> ResultType<()> {
|
||||
let mut stream = crate::ipc::connect_service(1000).await?;
|
||||
let mut stream = crate::ipc::connect(1000, crate::POSTFIX_SERVICE).await?;
|
||||
timeout(1000, stream.send(&crate::ipc::Data::UserSid(usid))).await??;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -2028,76 +1655,13 @@ pub async fn update_controlling_session_count(count: usize) -> ResultType<()> {
|
|||
#[cfg(target_os = "linux")]
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
pub async fn get_terminal_session_count() -> ResultType<usize> {
|
||||
let timeout_ms = 1_000;
|
||||
let effective_uid = unsafe { hbb_common::libc::geteuid() as u32 };
|
||||
let candidate_uids = terminal_count_candidate_uids(effective_uid);
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for candidate_uid in candidate_uids {
|
||||
let socket_path = Config::ipc_path_for_uid(candidate_uid, "");
|
||||
let connect_result = timeout(timeout_ms, Endpoint::connect(&socket_path))
|
||||
.await
|
||||
.map_err(|err| {
|
||||
anyhow::anyhow!(
|
||||
"Timeout connecting to terminal ipc at {}: {}",
|
||||
socket_path,
|
||||
err
|
||||
)
|
||||
});
|
||||
let connection = match connect_result {
|
||||
Ok(Ok(connection)) => connection,
|
||||
Ok(Err(err)) => {
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
"Failed to connect to terminal ipc at {}: {}",
|
||||
socket_path,
|
||||
err
|
||||
));
|
||||
continue;
|
||||
}
|
||||
Err(err) => {
|
||||
last_err = Some(err);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut ipc_conn = ConnectionTmpl::new(connection);
|
||||
if let Err(err) = ipc_conn.send(&Data::TerminalSessionCount(0)).await {
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
"Failed to request terminal session count via ipc at {}: {}",
|
||||
socket_path,
|
||||
err
|
||||
));
|
||||
continue;
|
||||
}
|
||||
match ipc_conn.next_timeout(timeout_ms).await {
|
||||
Ok(Some(Data::TerminalSessionCount(session_count))) => {
|
||||
return Ok(session_count);
|
||||
}
|
||||
Ok(None) => {
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
"Invalid response when requesting terminal session count via ipc at {}",
|
||||
socket_path
|
||||
));
|
||||
}
|
||||
Ok(other) => {
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
"Unexpected response when requesting terminal session count via ipc at {}: {:?}",
|
||||
socket_path,
|
||||
other.map(|v| std::mem::discriminant(&v))
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
"Failed to read terminal session count via ipc at {}: {}",
|
||||
socket_path,
|
||||
err
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(err) = last_err {
|
||||
Err(err.into())
|
||||
} else {
|
||||
Ok(0)
|
||||
let ms_timeout = 1_000;
|
||||
let mut c = connect(ms_timeout, "").await?;
|
||||
c.send(&Data::TerminalSessionCount(0)).await?;
|
||||
if let Some(Data::TerminalSessionCount(c)) = c.next_timeout(ms_timeout).await? {
|
||||
return Ok(c);
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
async fn handle_wayland_screencast_restore_token(
|
||||
|
|
@ -2128,81 +1692,9 @@ pub async fn set_install_option(k: String, v: String) -> ResultType<()> {
|
|||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn verify_ffi_enum_data_size() {
|
||||
println!("{}", std::mem::size_of::<Data>());
|
||||
assert!(std::mem::size_of::<Data>() <= 120);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[test]
|
||||
fn test_service_ipc_path_is_shared_across_uids() {
|
||||
assert_eq!(
|
||||
Config::ipc_path_for_uid(0, crate::POSTFIX_SERVICE),
|
||||
Config::ipc_path_for_uid(501, crate::POSTFIX_SERVICE)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[test]
|
||||
fn test_ipc_path_differs_by_uid_for_cm() {
|
||||
let effective_uid = unsafe { hbb_common::libc::geteuid() as u32 };
|
||||
let other_uid = effective_uid.saturating_add(1);
|
||||
let postfix = "_cm";
|
||||
|
||||
// Default connect path targets the current effective uid.
|
||||
assert_eq!(
|
||||
Config::ipc_path(postfix),
|
||||
Config::ipc_path_for_uid(effective_uid, postfix)
|
||||
);
|
||||
// A different uid yields a different socket path - this is the root cause of the
|
||||
// cross-user regression when root spawns a user process but still connects as uid 0.
|
||||
assert_ne!(
|
||||
Config::ipc_path(postfix),
|
||||
Config::ipc_path_for_uid(other_uid, postfix)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[test]
|
||||
fn test_select_server_uid_uses_active_uid_when_no_server_found() {
|
||||
assert_eq!(
|
||||
select_server_uid_for_user_main_ipc(&[], Some(501), false).unwrap(),
|
||||
501
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[test]
|
||||
fn test_select_server_uid_uses_single_server_uid() {
|
||||
assert_eq!(
|
||||
select_server_uid_for_user_main_ipc(&[501], None, false).unwrap(),
|
||||
501
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[test]
|
||||
fn test_select_server_uid_prefers_active_uid_with_multiple_servers() {
|
||||
assert_eq!(
|
||||
select_server_uid_for_user_main_ipc(&[0, 501], Some(501), false).unwrap(),
|
||||
501
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[test]
|
||||
fn test_select_server_uid_prefers_root_on_wayland_login_screen() {
|
||||
assert_eq!(
|
||||
select_server_uid_for_user_main_ipc(&[0, 501], Some(501), true).unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[test]
|
||||
fn test_select_server_uid_fails_when_multiple_servers_are_ambiguous() {
|
||||
assert!(select_server_uid_for_user_main_ipc(&[501, 502], None, false).is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1075
src/ipc/auth.rs
1075
src/ipc/auth.rs
File diff suppressed because it is too large
Load diff
951
src/ipc/fs.rs
951
src/ipc/fs.rs
|
|
@ -1,951 +0,0 @@
|
|||
#[cfg(target_os = "linux")]
|
||||
use super::ipc_auth::active_uid;
|
||||
use crate::ipc::{connect, Data};
|
||||
use hbb_common::{config, log, ResultType};
|
||||
use std::{
|
||||
ffi::CString,
|
||||
io::{Error, ErrorKind},
|
||||
os::unix::ffi::OsStrExt,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
struct FdGuard(i32);
|
||||
impl Drop for FdGuard {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
hbb_common::libc::close(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[inline]
|
||||
pub(crate) fn terminal_count_candidate_uids(effective_uid: u32) -> Vec<u32> {
|
||||
if effective_uid != 0 {
|
||||
return vec![effective_uid];
|
||||
}
|
||||
let mut candidates = Vec::with_capacity(2);
|
||||
if let Some(uid) = active_uid().filter(|uid| *uid != 0) {
|
||||
candidates.push(uid);
|
||||
}
|
||||
candidates.push(0);
|
||||
candidates
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn expected_ipc_parent_mode(postfix: &str) -> u32 {
|
||||
if config::is_service_ipc_postfix(postfix) {
|
||||
0o0711
|
||||
} else {
|
||||
0o0700
|
||||
}
|
||||
}
|
||||
|
||||
fn open_ipc_parent_dir_fd(parent_c: &CString) -> std::io::Result<i32> {
|
||||
let fd = unsafe {
|
||||
hbb_common::libc::open(
|
||||
parent_c.as_ptr(),
|
||||
hbb_common::libc::O_RDONLY
|
||||
| hbb_common::libc::O_DIRECTORY
|
||||
| hbb_common::libc::O_CLOEXEC
|
||||
| hbb_common::libc::O_NOFOLLOW,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
Err(std::io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(fd)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove one preexisting IPC artifact via an already-opened parent directory FD.
|
||||
//
|
||||
// Security intent:
|
||||
// - Bind cleanup to the exact parent inode that passed O_NOFOLLOW + fstat checks.
|
||||
// - Avoid path-based TOCTOU during scrub (e.g., parent path rename/swap race).
|
||||
//
|
||||
// Flow:
|
||||
// 1) fstatat(..., AT_SYMLINK_NOFOLLOW) to inspect the target entry under parent_fd.
|
||||
// 2) Decide file vs directory from st_mode.
|
||||
// 3) unlinkat relative to parent_fd (AT_REMOVEDIR for directories).
|
||||
//
|
||||
// Error policy:
|
||||
// - NotFound is treated as benign (already removed / raced away).
|
||||
// - Other errors are surfaced explicitly.
|
||||
fn remove_parent_entry_via_fd(
|
||||
parent_fd: i32,
|
||||
parent_dir: &Path,
|
||||
entry_name: &str,
|
||||
) -> ResultType<()> {
|
||||
if entry_name.contains('/') {
|
||||
return Err(Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"invalid ipc parent entry name (contains '/'): parent={}, entry={}",
|
||||
parent_dir.display(),
|
||||
entry_name
|
||||
),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let entry_c = CString::new(entry_name.as_bytes().to_vec()).map_err(|err| {
|
||||
Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"invalid ipc parent entry name: parent={}, entry={}, err={}",
|
||||
parent_dir.display(),
|
||||
entry_name,
|
||||
err
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let mut stat: hbb_common::libc::stat = unsafe { std::mem::zeroed() };
|
||||
let stat_rc = unsafe {
|
||||
hbb_common::libc::fstatat(
|
||||
parent_fd,
|
||||
entry_c.as_ptr(),
|
||||
&mut stat,
|
||||
hbb_common::libc::AT_SYMLINK_NOFOLLOW,
|
||||
)
|
||||
};
|
||||
if stat_rc != 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.kind() == ErrorKind::NotFound {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(Error::new(
|
||||
err.kind(),
|
||||
format!(
|
||||
"failed to stat preexisting ipc parent dir entry by fd: parent={}, entry={}, err={}",
|
||||
parent_dir.display(),
|
||||
entry_name,
|
||||
err
|
||||
),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let is_dir = (stat.st_mode & (hbb_common::libc::S_IFMT as hbb_common::libc::mode_t))
|
||||
== hbb_common::libc::S_IFDIR;
|
||||
let unlink_flags = if is_dir {
|
||||
hbb_common::libc::AT_REMOVEDIR
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let unlink_rc =
|
||||
unsafe { hbb_common::libc::unlinkat(parent_fd, entry_c.as_ptr(), unlink_flags) };
|
||||
if unlink_rc != 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.kind() == ErrorKind::NotFound {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(Error::new(
|
||||
err.kind(),
|
||||
format!(
|
||||
"failed to remove preexisting ipc parent dir entry by fd: parent={}, entry={}, err={}",
|
||||
parent_dir.display(),
|
||||
entry_name,
|
||||
err
|
||||
),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scrub_preexisting_ipc_parent_entries(
|
||||
parent_fd: i32,
|
||||
parent_dir: &Path,
|
||||
postfix: &str,
|
||||
) -> ResultType<()> {
|
||||
let ipc_basename = format!("ipc{}", postfix);
|
||||
remove_parent_entry_via_fd(parent_fd, parent_dir, &ipc_basename)?;
|
||||
remove_parent_entry_via_fd(parent_fd, parent_dir, &format!("{}.pid", ipc_basename))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_ipc_socket_via_secure_parent_fd(postfix: &str) -> ResultType<()> {
|
||||
let path = config::Config::ipc_path(postfix);
|
||||
let parent_dir = Path::new(&path)
|
||||
.parent()
|
||||
.ok_or_else(|| Error::new(ErrorKind::InvalidInput, format!("invalid ipc path: {path}")))?;
|
||||
let parent_c = CString::new(parent_dir.as_os_str().as_bytes().to_vec())?;
|
||||
let fd = match open_ipc_parent_dir_fd(&parent_c) {
|
||||
Ok(fd) => fd,
|
||||
Err(open_err) => {
|
||||
if open_err.kind() == ErrorKind::NotFound {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(Error::new(
|
||||
open_err.kind(),
|
||||
format!(
|
||||
"failed to open ipc parent dir for stale socket cleanup (no-follow): postfix={}, parent={}, err={}",
|
||||
postfix,
|
||||
parent_dir.display(),
|
||||
open_err
|
||||
),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
};
|
||||
let _fd_guard = FdGuard(fd);
|
||||
remove_parent_entry_via_fd(fd, parent_dir, &format!("ipc{}", postfix))
|
||||
}
|
||||
|
||||
// Purpose:
|
||||
// - Harden the IPC parent directory before creating/listening socket files.
|
||||
// - Prevent symlink/path-race abuse and reject unsafe owner/mode.
|
||||
//
|
||||
// Approach:
|
||||
// - Open parent dir with O_NOFOLLOW/O_DIRECTORY and operate on that fd.
|
||||
// - Validate inode type/owner/mode via fstat.
|
||||
// - For protected service postfix, optionally adopt owner (root only), then scrub stale
|
||||
// rustdesk IPC artifacts when directory trust boundary changed.
|
||||
//
|
||||
// Main steps:
|
||||
// 1) Resolve parent path and open/create directory securely.
|
||||
// 2) Verify directory inode type and owner uid.
|
||||
// 3) Enforce expected mode via fchmod on opened fd.
|
||||
// 4) Scrub stale IPC artifacts when owner/mode was unsafe before hardening.
|
||||
//
|
||||
// References:
|
||||
// - open(2): O_NOFOLLOW/O_DIRECTORY/O_CLOEXEC
|
||||
// https://man7.org/linux/man-pages/man2/open.2.html
|
||||
// - fstat(2): verify file type/metadata on opened fd
|
||||
// https://man7.org/linux/man-pages/man2/fstat.2.html
|
||||
// - fchown(2): adopt ownership when running as root
|
||||
// https://man7.org/linux/man-pages/man2/chown.2.html
|
||||
// - fchmod(2): enforce exact mode on opened fd
|
||||
// https://man7.org/linux/man-pages/man2/fchmod.2.html
|
||||
pub(crate) fn ensure_secure_ipc_parent_dir(path: &str, postfix: &str) -> ResultType<bool> {
|
||||
let parent_dir = Path::new(path)
|
||||
.parent()
|
||||
.ok_or_else(|| Error::new(ErrorKind::InvalidInput, format!("invalid ipc path: {path}")))?;
|
||||
// Harden against common TOCTOU by opening the parent directory with O_NOFOLLOW (so the parent
|
||||
// itself cannot be a symlink) and then operating on its FD (fstat/fchown/fchmod). This ensures
|
||||
// we mutate the inode we opened, though it does not protect against symlinks in ancestor path
|
||||
// components.
|
||||
let parent_c = CString::new(parent_dir.as_os_str().as_bytes().to_vec())?;
|
||||
let fd = match open_ipc_parent_dir_fd(&parent_c) {
|
||||
Ok(fd) => fd,
|
||||
Err(open_err) => {
|
||||
// If the directory doesn't exist yet, create it with the expected mode. The parent
|
||||
// dir is intended to be a single-level /tmp path, so mkdir is sufficient here.
|
||||
if open_err.raw_os_error() == Some(hbb_common::libc::ENOENT) {
|
||||
let expected_mode = expected_ipc_parent_mode(postfix);
|
||||
let rc = unsafe {
|
||||
hbb_common::libc::mkdir(
|
||||
parent_c.as_ptr(),
|
||||
expected_mode as hbb_common::libc::mode_t,
|
||||
)
|
||||
};
|
||||
if rc != 0 {
|
||||
let mkdir_err = std::io::Error::last_os_error();
|
||||
// Handle a race where another process created the directory first.
|
||||
if mkdir_err.raw_os_error() != Some(hbb_common::libc::EEXIST) {
|
||||
return Err(Error::new(
|
||||
mkdir_err.kind(),
|
||||
format!(
|
||||
"failed to mkdir ipc parent dir: postfix={}, parent={}, err={}",
|
||||
postfix,
|
||||
parent_dir.display(),
|
||||
mkdir_err
|
||||
),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
match open_ipc_parent_dir_fd(&parent_c) {
|
||||
Ok(fd) => fd,
|
||||
Err(err) => {
|
||||
return Err(Error::new(
|
||||
err.kind(),
|
||||
format!(
|
||||
"failed to open ipc parent dir (no-follow): postfix={}, parent={}, err={}",
|
||||
postfix,
|
||||
parent_dir.display(),
|
||||
err
|
||||
),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Err(Error::new(
|
||||
open_err.kind(),
|
||||
format!(
|
||||
"failed to open ipc parent dir (no-follow): postfix={}, parent={}, err={}",
|
||||
postfix,
|
||||
parent_dir.display(),
|
||||
open_err
|
||||
),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
};
|
||||
let _fd_guard = FdGuard(fd);
|
||||
|
||||
let mut st: hbb_common::libc::stat = unsafe { std::mem::zeroed() };
|
||||
if unsafe { hbb_common::libc::fstat(fd, &mut st as *mut _) } != 0 {
|
||||
let os_err = std::io::Error::last_os_error();
|
||||
return Err(Error::new(
|
||||
os_err.kind(),
|
||||
format!(
|
||||
"failed to stat ipc parent dir: postfix={}, parent={}, err={}",
|
||||
postfix,
|
||||
parent_dir.display(),
|
||||
os_err
|
||||
),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let mode = st.st_mode as u32;
|
||||
let is_dir = (mode & (hbb_common::libc::S_IFMT as u32)) == (hbb_common::libc::S_IFDIR as u32);
|
||||
if !is_dir {
|
||||
return Err(Error::new(
|
||||
ErrorKind::PermissionDenied,
|
||||
format!(
|
||||
"ipc parent is not directory: postfix={}, parent={}",
|
||||
postfix,
|
||||
parent_dir.display()
|
||||
),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let expected_uid = unsafe { hbb_common::libc::geteuid() as u32 };
|
||||
let mut owner_uid = st.st_uid as u32;
|
||||
let mut adopted_foreign_service_parent = false;
|
||||
// Service-scoped IPC may be created by different privilege contexts historically.
|
||||
// If running as root on protected service postfix, try adopting ownership first.
|
||||
if owner_uid != expected_uid && expected_uid == 0 && config::is_service_ipc_postfix(postfix) {
|
||||
let rc = unsafe {
|
||||
hbb_common::libc::fchown(
|
||||
fd,
|
||||
expected_uid as hbb_common::libc::uid_t,
|
||||
hbb_common::libc::gid_t::MAX,
|
||||
)
|
||||
};
|
||||
if rc == 0 {
|
||||
let mut st2: hbb_common::libc::stat = unsafe { std::mem::zeroed() };
|
||||
if unsafe { hbb_common::libc::fstat(fd, &mut st2 as *mut _) } == 0 {
|
||||
owner_uid = st2.st_uid as u32;
|
||||
st = st2;
|
||||
adopted_foreign_service_parent = true;
|
||||
}
|
||||
} else {
|
||||
// Keep behavior unchanged; capture errno to ease diagnosing why chown failed.
|
||||
let err = std::io::Error::last_os_error();
|
||||
log::warn!(
|
||||
"Failed to chown ipc parent dir, parent={}, postfix={}, expected_uid={}, rc={}, err={:?}",
|
||||
parent_dir.display(),
|
||||
postfix,
|
||||
expected_uid,
|
||||
rc,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
if owner_uid != expected_uid {
|
||||
return Err(Error::new(
|
||||
ErrorKind::PermissionDenied,
|
||||
format!(
|
||||
"unsafe ipc parent owner, postfix={}, expected uid {expected_uid}, got {owner_uid}: {}",
|
||||
postfix,
|
||||
parent_dir.display()
|
||||
),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let expected_mode = expected_ipc_parent_mode(postfix);
|
||||
// Include special bits (setuid/setgid/sticky) to ensure the directory is hardened to the exact
|
||||
// expected mode.
|
||||
let current_mode = (st.st_mode as u32) & 0o7777;
|
||||
let repaired_parent_mode = current_mode != expected_mode;
|
||||
let had_untrusted_parent_mode = (current_mode & 0o022) != 0;
|
||||
if repaired_parent_mode {
|
||||
// Use fchmod on the opened fd to avoid path-race between check and chmod.
|
||||
if unsafe { hbb_common::libc::fchmod(fd, expected_mode as hbb_common::libc::mode_t) } != 0 {
|
||||
let os_err = std::io::Error::last_os_error();
|
||||
return Err(Error::new(
|
||||
os_err.kind(),
|
||||
format!(
|
||||
"failed to chmod ipc parent dir: postfix={}, parent={}, err={}",
|
||||
postfix,
|
||||
parent_dir.display(),
|
||||
os_err
|
||||
),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
let should_scrub =
|
||||
repaired_parent_mode || adopted_foreign_service_parent || had_untrusted_parent_mode;
|
||||
Ok(should_scrub)
|
||||
}
|
||||
|
||||
pub(crate) fn scrub_secure_ipc_parent_dir(path: &str, postfix: &str) -> ResultType<()> {
|
||||
let parent_dir = Path::new(path)
|
||||
.parent()
|
||||
.ok_or_else(|| Error::new(ErrorKind::InvalidInput, format!("invalid ipc path: {path}")))?;
|
||||
let parent_c = CString::new(parent_dir.as_os_str().as_bytes().to_vec())?;
|
||||
let fd = open_ipc_parent_dir_fd(&parent_c).map_err(|err| {
|
||||
Error::new(
|
||||
err.kind(),
|
||||
format!(
|
||||
"failed to open ipc parent dir for scrub (no-follow): postfix={}, parent={}, err={}",
|
||||
postfix,
|
||||
parent_dir.display(),
|
||||
err
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let _fd_guard = FdGuard(fd);
|
||||
scrub_preexisting_ipc_parent_entries(fd, parent_dir, postfix)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn get_pid_file(postfix: &str) -> String {
|
||||
let path = config::Config::ipc_path(postfix);
|
||||
format!("{}.pid", path)
|
||||
}
|
||||
|
||||
// Purpose:
|
||||
// - Write current process pid to pid file without following attacker-controlled symlinks.
|
||||
// - Ensure the pid file is a regular file owned by the opened inode path.
|
||||
//
|
||||
// Approach:
|
||||
// - Use libc open/fstat/write syscalls (FFI) so flags and inode validation are explicit.
|
||||
// - Open file with O_NOFOLLOW/O_CLOEXEC and verify S_IFREG with fstat before write.
|
||||
// - Keep unsafe scopes minimal and check syscall return values immediately.
|
||||
//
|
||||
// Main steps:
|
||||
// 1) Secure-open pid file (without truncation).
|
||||
// 2) Validate opened inode is a regular file owned by current euid.
|
||||
// 3) Enforce pid file mode to 0600 and truncate via ftruncate after validation.
|
||||
// 4) Write process id bytes through fd.
|
||||
//
|
||||
// Why not plain std::fs::write?
|
||||
// - std::fs helpers cannot enforce this exact open-time hardening sequence
|
||||
// (especially "open with O_NOFOLLOW, then fstat the same opened inode").
|
||||
//
|
||||
// References:
|
||||
// - open(2): O_NOFOLLOW/O_CLOEXEC/O_NONBLOCK
|
||||
// https://man7.org/linux/man-pages/man2/open.2.html
|
||||
// - fstat(2): verify file type on opened fd
|
||||
// https://man7.org/linux/man-pages/man2/fstat.2.html
|
||||
// - fchmod(2): enforce secure mode on reused pid file
|
||||
// https://man7.org/linux/man-pages/man2/fchmod.2.html
|
||||
// - ftruncate(2): truncate after validation
|
||||
// https://man7.org/linux/man-pages/man2/ftruncate.2.html
|
||||
// - write(2): write bytes via fd
|
||||
// https://man7.org/linux/man-pages/man2/write.2.html
|
||||
fn write_pid_file(path: &Path) -> ResultType<()> {
|
||||
let path_c = CString::new(path.as_os_str().as_bytes().to_vec()).map_err(|err| {
|
||||
Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
format!("invalid pid file path '{}': {}", path.display(), err),
|
||||
)
|
||||
})?;
|
||||
let flags = hbb_common::libc::O_WRONLY
|
||||
| hbb_common::libc::O_CREAT
|
||||
| hbb_common::libc::O_CLOEXEC
|
||||
| hbb_common::libc::O_NOFOLLOW
|
||||
| hbb_common::libc::O_NONBLOCK;
|
||||
let fd = unsafe { hbb_common::libc::open(path_c.as_ptr(), flags, 0o0600) };
|
||||
if fd < 0 {
|
||||
let os_err = std::io::Error::last_os_error();
|
||||
return Err(Error::new(
|
||||
os_err.kind(),
|
||||
format!(
|
||||
"failed to open pid file with no-follow '{}': {}",
|
||||
path.display(),
|
||||
os_err
|
||||
),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let _fd_guard = FdGuard(fd);
|
||||
let mut stat: hbb_common::libc::stat = unsafe { std::mem::zeroed() };
|
||||
if unsafe { hbb_common::libc::fstat(fd, &mut stat) } != 0 {
|
||||
let os_err = std::io::Error::last_os_error();
|
||||
return Err(Error::new(
|
||||
os_err.kind(),
|
||||
format!("failed to stat pid file '{}': {}", path.display(), os_err),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if (stat.st_mode & (hbb_common::libc::S_IFMT as hbb_common::libc::mode_t))
|
||||
!= (hbb_common::libc::S_IFREG as hbb_common::libc::mode_t)
|
||||
{
|
||||
return Err(Error::new(
|
||||
ErrorKind::PermissionDenied,
|
||||
format!("pid file path is not a regular file: '{}'", path.display()),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let expected_uid = unsafe { hbb_common::libc::geteuid() as u32 };
|
||||
if stat.st_uid as u32 != expected_uid {
|
||||
return Err(Error::new(
|
||||
ErrorKind::PermissionDenied,
|
||||
format!(
|
||||
"pid file owner mismatch: expected uid {}, got {} for '{}'",
|
||||
expected_uid,
|
||||
stat.st_uid,
|
||||
path.display()
|
||||
),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if unsafe { hbb_common::libc::fchmod(fd, 0o600) } != 0 {
|
||||
let os_err = std::io::Error::last_os_error();
|
||||
return Err(Error::new(
|
||||
os_err.kind(),
|
||||
format!("failed to chmod pid file '{}': {}", path.display(), os_err),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if unsafe { hbb_common::libc::ftruncate(fd, 0) } != 0 {
|
||||
let os_err = std::io::Error::last_os_error();
|
||||
return Err(Error::new(
|
||||
os_err.kind(),
|
||||
format!(
|
||||
"failed to truncate pid file '{}': {}",
|
||||
path.display(),
|
||||
os_err
|
||||
),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let bytes = std::process::id().to_string();
|
||||
let buf = bytes.as_bytes();
|
||||
// `write(2)` is allowed to return a short write even for regular files.
|
||||
// PID content is tiny and usually written in one shot, but we still loop
|
||||
// until all bytes are persisted so this path is semantically correct.
|
||||
let mut written = 0usize;
|
||||
while written < buf.len() {
|
||||
let rc = unsafe {
|
||||
hbb_common::libc::write(
|
||||
fd,
|
||||
buf[written..].as_ptr() as *const hbb_common::libc::c_void,
|
||||
buf.len() - written,
|
||||
)
|
||||
};
|
||||
if rc < 0 {
|
||||
let os_err = std::io::Error::last_os_error();
|
||||
return Err(Error::new(
|
||||
os_err.kind(),
|
||||
format!("failed to write pid file '{}': {}", path.display(), os_err),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if rc == 0 {
|
||||
return Err(Error::new(
|
||||
ErrorKind::WriteZero,
|
||||
format!(
|
||||
"failed to write pid file '{}': write returned 0 bytes",
|
||||
path.display()
|
||||
),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
written += rc as usize;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn write_pid(postfix: &str) {
|
||||
let path = std::path::PathBuf::from(get_pid_file(postfix));
|
||||
if let Err(err) = write_pid_file(&path) {
|
||||
log::warn!(
|
||||
"Failed to write pid file for postfix '{}', path='{}', err={}",
|
||||
postfix,
|
||||
path.display(),
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Purpose:
|
||||
// - Read pid file safely and avoid trusting symlink/non-regular files.
|
||||
//
|
||||
// Approach:
|
||||
// - Use libc open/fstat/read syscalls (FFI) to control flags and inode checks.
|
||||
// - Open path with O_NOFOLLOW, validate opened fd via fstat, then read and parse.
|
||||
// - Keep unsafe scopes minimal and check syscall return values immediately.
|
||||
//
|
||||
// Main steps:
|
||||
// 1) Secure-open pid file read-only.
|
||||
// 2) Ensure fd points to regular file.
|
||||
// 3) Read bytes and parse usize pid.
|
||||
//
|
||||
// References:
|
||||
// - open(2): O_NOFOLLOW/O_CLOEXEC/O_NONBLOCK
|
||||
// https://man7.org/linux/man-pages/man2/open.2.html
|
||||
// - fstat(2): validate S_IFREG on opened fd
|
||||
// https://man7.org/linux/man-pages/man2/fstat.2.html
|
||||
// - read(2): read bytes via fd
|
||||
// https://man7.org/linux/man-pages/man2/read.2.html
|
||||
#[inline]
|
||||
fn read_pid_file_secure(path: &Path) -> Option<usize> {
|
||||
let path_c = CString::new(path.as_os_str().as_bytes().to_vec()).ok()?;
|
||||
let flags = hbb_common::libc::O_RDONLY
|
||||
| hbb_common::libc::O_CLOEXEC
|
||||
| hbb_common::libc::O_NOFOLLOW
|
||||
| hbb_common::libc::O_NONBLOCK;
|
||||
let fd = unsafe { hbb_common::libc::open(path_c.as_ptr(), flags) };
|
||||
if fd < 0 {
|
||||
return None;
|
||||
}
|
||||
let _fd_guard = FdGuard(fd);
|
||||
|
||||
let mut stat: hbb_common::libc::stat = unsafe { std::mem::zeroed() };
|
||||
if unsafe { hbb_common::libc::fstat(fd, &mut stat) } != 0 {
|
||||
return None;
|
||||
}
|
||||
if (stat.st_mode & (hbb_common::libc::S_IFMT as hbb_common::libc::mode_t))
|
||||
!= (hbb_common::libc::S_IFREG as hbb_common::libc::mode_t)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut buffer = [0u8; 64];
|
||||
let read_len = unsafe {
|
||||
hbb_common::libc::read(
|
||||
fd,
|
||||
buffer.as_mut_ptr() as *mut hbb_common::libc::c_void,
|
||||
buffer.len(),
|
||||
)
|
||||
};
|
||||
if read_len <= 0 {
|
||||
return None;
|
||||
}
|
||||
let content = String::from_utf8_lossy(&buffer[..read_len as usize]).to_string();
|
||||
content.trim().parse::<usize>().ok()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
async fn probe_existing_listener(postfix: &str) -> bool {
|
||||
let Ok(mut stream) = connect(1000, postfix).await else {
|
||||
return false;
|
||||
};
|
||||
if postfix != crate::POSTFIX_SERVICE {
|
||||
return true;
|
||||
}
|
||||
if stream.send(&Data::SyncConfig(None)).await.is_err() {
|
||||
return false;
|
||||
}
|
||||
matches!(
|
||||
stream.next_timeout(1000).await,
|
||||
Ok(Some(Data::SyncConfig(Some(_))))
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn check_pid(postfix: &str) -> bool {
|
||||
let pid_file = std::path::PathBuf::from(get_pid_file(postfix));
|
||||
if let Some(pid) = read_pid_file_secure(&pid_file) {
|
||||
if pid > 0 {
|
||||
let mut sys = hbb_common::sysinfo::System::new();
|
||||
sys.refresh_processes();
|
||||
if let Some(p) = sys.process(pid.into()) {
|
||||
if let Some(current) = sys.process((std::process::id() as usize).into()) {
|
||||
if current.name() == p.name() && probe_existing_listener(postfix).await {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if probe_existing_listener(postfix).await {
|
||||
return true;
|
||||
}
|
||||
// if not remove old ipc file, the new ipc creation will fail
|
||||
// if we remove a ipc file, but the old ipc process is still running,
|
||||
// new connection to the ipc will connect to new ipc, old connection to old ipc still keep alive
|
||||
if let Err(err) = remove_ipc_socket_via_secure_parent_fd(postfix) {
|
||||
log::debug!(
|
||||
"Failed to remove stale ipc socket via secure parent fd: postfix={}, err={}",
|
||||
postfix,
|
||||
err
|
||||
);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn should_scrub_parent_entries_after_check_pid(
|
||||
should_scrub_parent_entries: bool,
|
||||
existing_listener_alive: bool,
|
||||
) -> bool {
|
||||
should_scrub_parent_entries && !existing_listener_alive
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn test_write_pid_file_rejects_symlink() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let unique = format!(
|
||||
"rustdesk-ipc-pid-file-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
);
|
||||
let base = std::env::temp_dir().join(unique);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
|
||||
let target = base.join("target_pid");
|
||||
std::fs::write(&target, b"origin").unwrap();
|
||||
let link = base.join("pid_link");
|
||||
symlink(&target, &link).unwrap();
|
||||
|
||||
let res = super::write_pid_file(&link);
|
||||
assert!(res.is_err());
|
||||
assert_eq!(std::fs::read_to_string(&target).unwrap(), "origin");
|
||||
|
||||
std::fs::remove_file(&link).ok();
|
||||
std::fs::remove_file(&target).ok();
|
||||
std::fs::remove_dir_all(&base).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_secure_ipc_parent_dir_rejects_symlink_parent() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let unique = format!(
|
||||
"rustdesk-ipc-secure-dir-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
);
|
||||
let base = std::env::temp_dir().join(unique);
|
||||
let real_dir = base.join("real");
|
||||
let link_dir = base.join("link");
|
||||
std::fs::create_dir_all(&real_dir).unwrap();
|
||||
symlink(&real_dir, &link_dir).unwrap();
|
||||
let ipc_path = link_dir.join("ipc_service");
|
||||
let res =
|
||||
super::ensure_secure_ipc_parent_dir(ipc_path.to_string_lossy().as_ref(), "_service");
|
||||
assert!(res.is_err());
|
||||
std::fs::remove_file(&link_dir).ok();
|
||||
std::fs::remove_dir_all(&real_dir).ok();
|
||||
std::fs::remove_dir_all(&base).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_secure_ipc_parent_dir_creates_parent_with_expected_mode() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let unique = format!(
|
||||
"rustdesk-ipc-secure-dir-create-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
);
|
||||
let base = std::env::temp_dir().join(unique);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
|
||||
// Intentionally choose a parent that does not exist to exercise the ENOENT -> mkdir branch.
|
||||
let parent_dir = base.join("parent");
|
||||
assert!(!parent_dir.exists());
|
||||
let ipc_path = parent_dir.join("ipc");
|
||||
|
||||
let res = super::ensure_secure_ipc_parent_dir(ipc_path.to_string_lossy().as_ref(), "");
|
||||
// Restrictive umask can make mkdir create a stricter initial mode. In that case
|
||||
// ensure_secure_ipc_parent_dir repairs it with fchmod and may request a scrub.
|
||||
res.unwrap();
|
||||
|
||||
let md = std::fs::metadata(&parent_dir).unwrap();
|
||||
assert!(md.is_dir());
|
||||
let mode = md.permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o0700);
|
||||
|
||||
std::fs::remove_dir_all(&base).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scrub_preexisting_ipc_parent_entries_only_removes_target_postfix_artifacts() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
let unique = format!(
|
||||
"rustdesk-ipc-scrub-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
);
|
||||
let base = std::env::temp_dir().join(unique);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
|
||||
let ipc_file = base.join("ipc_service");
|
||||
let ipc_pid_file = base.join("ipc_service.pid");
|
||||
let ipc_other_postfix_file = base.join("ipc_uinput_1");
|
||||
let keep_file = base.join("keep.txt");
|
||||
let keep_dir = base.join("keep_dir");
|
||||
|
||||
std::fs::write(&ipc_file, b"socket-placeholder").unwrap();
|
||||
std::fs::write(&ipc_pid_file, b"1234").unwrap();
|
||||
std::fs::write(&ipc_other_postfix_file, b"other-postfix").unwrap();
|
||||
std::fs::write(&keep_file, b"keep").unwrap();
|
||||
std::fs::create_dir_all(&keep_dir).unwrap();
|
||||
|
||||
let base_c = std::ffi::CString::new(base.as_os_str().as_bytes().to_vec()).unwrap();
|
||||
let base_fd = super::open_ipc_parent_dir_fd(&base_c).unwrap();
|
||||
let _base_guard = super::FdGuard(base_fd);
|
||||
super::scrub_preexisting_ipc_parent_entries(base_fd, &base, "_service").unwrap();
|
||||
|
||||
assert!(!ipc_file.exists());
|
||||
assert!(!ipc_pid_file.exists());
|
||||
assert!(ipc_other_postfix_file.exists());
|
||||
assert!(keep_file.exists());
|
||||
assert!(keep_dir.exists());
|
||||
|
||||
std::fs::remove_file(&ipc_other_postfix_file).ok();
|
||||
std::fs::remove_file(&keep_file).ok();
|
||||
std::fs::remove_dir_all(&keep_dir).ok();
|
||||
std::fs::remove_dir_all(&base).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scrub_preexisting_ipc_parent_entries_should_bind_to_opened_inode_not_path() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
let unique = format!(
|
||||
"rustdesk-ipc-scrub-fd-bind-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
);
|
||||
let base = std::env::temp_dir().join(unique);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
|
||||
let trusted_parent = base.join("trusted_parent");
|
||||
let trusted_parent_moved = base.join("trusted_parent_moved");
|
||||
let attacker_parent = base.join("attacker_parent");
|
||||
std::fs::create_dir_all(&trusted_parent).unwrap();
|
||||
std::fs::create_dir_all(&attacker_parent).unwrap();
|
||||
|
||||
let trusted_ipc_file = trusted_parent.join("ipc_service");
|
||||
let attacker_ipc_file = attacker_parent.join("ipc_service");
|
||||
std::fs::write(&trusted_ipc_file, b"trusted").unwrap();
|
||||
std::fs::write(&attacker_ipc_file, b"attacker").unwrap();
|
||||
|
||||
let trusted_parent_c =
|
||||
std::ffi::CString::new(trusted_parent.as_os_str().as_bytes().to_vec()).unwrap();
|
||||
let trusted_parent_fd = super::open_ipc_parent_dir_fd(&trusted_parent_c).unwrap();
|
||||
let _trusted_parent_guard = super::FdGuard(trusted_parent_fd);
|
||||
|
||||
// Swap the path after the trusted inode has been opened.
|
||||
std::fs::rename(&trusted_parent, &trusted_parent_moved).unwrap();
|
||||
std::fs::rename(&attacker_parent, &trusted_parent).unwrap();
|
||||
|
||||
super::scrub_preexisting_ipc_parent_entries(trusted_parent_fd, &trusted_parent, "_service")
|
||||
.unwrap();
|
||||
|
||||
// Expected secure behavior: scrub should target the inode that was opened before path swap.
|
||||
assert!(
|
||||
!trusted_parent_moved.join("ipc_service").exists(),
|
||||
"trusted inode artifact should be removed even after path swap"
|
||||
);
|
||||
assert!(
|
||||
trusted_parent.join("ipc_service").exists(),
|
||||
"path-swapped attacker directory should not be scrubbed"
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&base).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_secure_ipc_parent_dir_keeps_service_artifacts_before_liveness_probe() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let unique = format!(
|
||||
"rustdesk-ipc-secure-dir-order-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
);
|
||||
let base = std::env::temp_dir().join(unique);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
|
||||
let parent_dir = base.join("service_parent");
|
||||
std::fs::create_dir_all(&parent_dir).unwrap();
|
||||
// Trigger "had_untrusted_service_parent_mode".
|
||||
std::fs::set_permissions(&parent_dir, std::fs::Permissions::from_mode(0o777)).unwrap();
|
||||
|
||||
let ipc_file = parent_dir.join("ipc_service");
|
||||
let ipc_pid_file = parent_dir.join("ipc_service.pid");
|
||||
std::fs::write(&ipc_file, b"socket-placeholder").unwrap();
|
||||
std::fs::write(&ipc_pid_file, b"1234").unwrap();
|
||||
|
||||
let res =
|
||||
super::ensure_secure_ipc_parent_dir(ipc_file.to_string_lossy().as_ref(), "_service");
|
||||
assert_eq!(res.unwrap(), true);
|
||||
|
||||
// Parent hardening should run first; artifacts should stay until liveness probe completes.
|
||||
assert!(ipc_file.exists(), "ipc socket marker should be preserved");
|
||||
assert!(ipc_pid_file.exists(), "pid marker should be preserved");
|
||||
|
||||
std::fs::remove_dir_all(&base).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_secure_ipc_parent_dir_marks_non_service_mode_repair_for_scrub() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let unique = format!(
|
||||
"rustdesk-ipc-nonservice-mode-repair-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
);
|
||||
let base = std::env::temp_dir().join(unique);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
|
||||
let parent_dir = base.join("non_service_parent");
|
||||
std::fs::create_dir_all(&parent_dir).unwrap();
|
||||
std::fs::set_permissions(&parent_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let ipc_file = parent_dir.join("ipc");
|
||||
std::fs::write(&ipc_file, b"socket-placeholder").unwrap();
|
||||
|
||||
let res = super::ensure_secure_ipc_parent_dir(ipc_file.to_string_lossy().as_ref(), "");
|
||||
assert_eq!(res.unwrap(), true);
|
||||
|
||||
std::fs::remove_dir_all(&base).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_scrub_parent_entries_after_check_pid_only_when_requested_and_not_alive() {
|
||||
assert!(!super::should_scrub_parent_entries_after_check_pid(
|
||||
false, false
|
||||
));
|
||||
assert!(!super::should_scrub_parent_entries_after_check_pid(
|
||||
false, true
|
||||
));
|
||||
assert!(super::should_scrub_parent_entries_after_check_pid(
|
||||
true, false
|
||||
));
|
||||
assert!(!super::should_scrub_parent_entries_after_check_pid(
|
||||
true, true
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ use crate::{client::get_key_state, common::GrabState};
|
|||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
use hbb_common::log;
|
||||
use hbb_common::message_proto::*;
|
||||
use hbb_common::SessionID;
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
use rdev::KeyCode;
|
||||
use rdev::{Event, EventType, Key};
|
||||
|
|
@ -79,6 +80,8 @@ lazy_static::lazy_static! {
|
|||
};
|
||||
}
|
||||
|
||||
pub mod shortcuts;
|
||||
|
||||
pub mod client {
|
||||
use super::*;
|
||||
|
||||
|
|
@ -319,6 +322,33 @@ pub mod client {
|
|||
}
|
||||
|
||||
pub fn process_event(keyboard_mode: &str, event: &Event, lock_modes: Option<i32>) {
|
||||
// Shortcut intercept — must come before any wire encoding.
|
||||
// Only fires on KeyPress (event_to_key_name in shortcuts.rs returns None
|
||||
// for KeyRelease and other non-press events), so flushed releases from
|
||||
// release_remote_keys pass straight through to the encode/forward path.
|
||||
if let Some(action_id) = crate::keyboard::shortcuts::match_event(event) {
|
||||
#[cfg(feature = "flutter")]
|
||||
{
|
||||
// The rdev grab loop is genuinely process-wide: it does not know which
|
||||
// Flutter SessionID the keystroke was meant for, so we route to the
|
||||
// globally-current session via flutter::get_cur_session_id() (maintained
|
||||
// by session_enter_or_leave). This is the only behavior available on the
|
||||
// rdev path; the Flutter path threads the explicit per-call SessionID
|
||||
// through process_event_with_session instead.
|
||||
let session_id = crate::flutter::get_cur_session_id();
|
||||
crate::flutter::push_session_event(
|
||||
&session_id,
|
||||
"shortcut_triggered",
|
||||
vec![("action", &action_id)],
|
||||
);
|
||||
}
|
||||
#[cfg(not(feature = "flutter"))]
|
||||
{
|
||||
let _ = action_id;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let keyboard_mode = get_keyboard_mode_enum(keyboard_mode);
|
||||
if is_long_press(&event) {
|
||||
return;
|
||||
|
|
@ -334,7 +364,33 @@ pub mod client {
|
|||
event: &Event,
|
||||
lock_modes: Option<i32>,
|
||||
session: &Session<T>,
|
||||
session_id: SessionID,
|
||||
) {
|
||||
// Shortcut intercept — must come before any wire encoding.
|
||||
// Only fires on KeyPress (event_to_key_name in shortcuts.rs returns None
|
||||
// for KeyRelease and other non-press events), so flushed releases from
|
||||
// release_remote_keys pass straight through to the encode/forward path.
|
||||
if let Some(action_id) = crate::keyboard::shortcuts::match_event(event) {
|
||||
#[cfg(feature = "flutter")]
|
||||
{
|
||||
// The Flutter path threads the explicit SessionID from the FFI entry
|
||||
// (session_handle_flutter_*key_event) through this call, so the dispatch
|
||||
// targets the exact tab the keystroke originated from — no dependency on
|
||||
// the global focus tracker and no multi-window race.
|
||||
crate::flutter::push_session_event(
|
||||
&session_id,
|
||||
"shortcut_triggered",
|
||||
vec![("action", &action_id)],
|
||||
);
|
||||
}
|
||||
#[cfg(not(feature = "flutter"))]
|
||||
{
|
||||
let _ = action_id;
|
||||
let _ = session_id;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let keyboard_mode = get_keyboard_mode_enum(keyboard_mode);
|
||||
if is_long_press(&event) {
|
||||
return;
|
||||
|
|
|
|||
370
src/keyboard/shortcuts.rs
Normal file
370
src/keyboard/shortcuts.rs
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
//! Keyboard shortcuts for triggering session actions locally.
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const LOCAL_CONFIG_KEY: &str = "keyboard-shortcuts";
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref CACHE: RwLock<Arc<Bindings>> = RwLock::new(Arc::new(Bindings::default()));
|
||||
}
|
||||
|
||||
/// Registry of all valid action ids that may appear in `Binding.action`.
|
||||
/// Source-of-truth lives on the Flutter side (`flutter/lib/consts.dart`,
|
||||
/// `kShortcutAction*`); these mirror that vocabulary so Rust code can reach
|
||||
/// for them without re-stringifying.
|
||||
#[allow(dead_code)]
|
||||
pub mod action_id {
|
||||
pub const SEND_CTRL_ALT_DEL: &str = "send_ctrl_alt_del";
|
||||
pub const TOGGLE_FULLSCREEN: &str = "toggle_fullscreen";
|
||||
pub const SWITCH_DISPLAY_NEXT: &str = "switch_display_next";
|
||||
pub const SWITCH_DISPLAY_PREV: &str = "switch_display_prev";
|
||||
pub const SCREENSHOT: &str = "screenshot";
|
||||
pub const INSERT_LOCK: &str = "insert_lock";
|
||||
pub const REFRESH: &str = "refresh";
|
||||
pub const TOGGLE_AUDIO: &str = "toggle_audio";
|
||||
pub const TOGGLE_BLOCK_INPUT: &str = "toggle_block_input";
|
||||
pub const TOGGLE_RECORDING: &str = "toggle_recording";
|
||||
pub const TOGGLE_PRIVACY_MODE: &str = "toggle_privacy_mode";
|
||||
pub const VIEW_MODE_1_TO_1: &str = "view_mode_1_to_1";
|
||||
pub const VIEW_MODE_SHRINK: &str = "view_mode_shrink";
|
||||
pub const VIEW_MODE_STRETCH: &str = "view_mode_stretch";
|
||||
pub const SWITCH_SIDES: &str = "switch_sides";
|
||||
// switch_tab_1 .. switch_tab_9 are generated below.
|
||||
}
|
||||
|
||||
pub fn switch_tab_action_id(n: u8) -> Option<&'static str> {
|
||||
match n {
|
||||
1 => Some("switch_tab_1"),
|
||||
2 => Some("switch_tab_2"),
|
||||
3 => Some("switch_tab_3"),
|
||||
4 => Some("switch_tab_4"),
|
||||
5 => Some("switch_tab_5"),
|
||||
6 => Some("switch_tab_6"),
|
||||
7 => Some("switch_tab_7"),
|
||||
8 => Some("switch_tab_8"),
|
||||
9 => Some("switch_tab_9"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Modifier {
|
||||
Primary,
|
||||
Alt,
|
||||
Shift,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Binding {
|
||||
pub action: String,
|
||||
pub mods: Vec<Modifier>,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct Bindings {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub bindings: Vec<Binding>,
|
||||
}
|
||||
|
||||
pub fn default_bindings() -> Vec<Binding> {
|
||||
let prefix = || vec![Modifier::Primary, Modifier::Alt, Modifier::Shift];
|
||||
let mut v = vec![
|
||||
Binding { action: action_id::SEND_CTRL_ALT_DEL.into(), mods: prefix(), key: "delete".into() },
|
||||
Binding { action: action_id::TOGGLE_FULLSCREEN.into(), mods: prefix(), key: "enter".into() },
|
||||
Binding { action: action_id::SWITCH_DISPLAY_NEXT.into(), mods: prefix(), key: "arrow_right".into() },
|
||||
Binding { action: action_id::SWITCH_DISPLAY_PREV.into(), mods: prefix(), key: "arrow_left".into() },
|
||||
Binding { action: action_id::SCREENSHOT.into(), mods: prefix(), key: "p".into() },
|
||||
];
|
||||
for n in 1..=9u8 {
|
||||
if let Some(action) = switch_tab_action_id(n) {
|
||||
v.push(Binding {
|
||||
action: action.into(),
|
||||
mods: prefix(),
|
||||
key: format!("digit{n}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Match a normalized (key, modifiers) pair against the given bindings.
|
||||
/// Returns the matched action ID, or None.
|
||||
pub fn match_normalized<'a>(key: &str, mods: &[Modifier], b: &'a Bindings) -> Option<&'a str> {
|
||||
if !b.enabled {
|
||||
return None;
|
||||
}
|
||||
for binding in &b.bindings {
|
||||
if binding.key == key && mods_equal(&binding.mods, mods) {
|
||||
return Some(binding.action.as_str());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn normalize_modifiers(alt: bool, ctrl: bool, shift: bool, command: bool) -> Vec<Modifier> {
|
||||
let mut v = Vec::new();
|
||||
let primary = if cfg!(target_os = "macos") { command } else { ctrl };
|
||||
if primary { v.push(Modifier::Primary); }
|
||||
if alt { v.push(Modifier::Alt); }
|
||||
if shift { v.push(Modifier::Shift); }
|
||||
v
|
||||
}
|
||||
|
||||
/// Map an rdev::Event to a string key name, matching the storage schema.
|
||||
/// Returns None for events we don't intercept (modifier-only presses, releases, etc.).
|
||||
pub fn event_to_key_name(event: &rdev::Event) -> Option<String> {
|
||||
use rdev::{EventType, Key};
|
||||
let key = match event.event_type {
|
||||
EventType::KeyPress(k) => k,
|
||||
_ => return None,
|
||||
};
|
||||
Some(match key {
|
||||
Key::Delete => "delete".into(),
|
||||
Key::Return => "enter".into(),
|
||||
Key::LeftArrow => "arrow_left".into(),
|
||||
Key::RightArrow => "arrow_right".into(),
|
||||
Key::UpArrow => "arrow_up".into(),
|
||||
Key::DownArrow => "arrow_down".into(),
|
||||
Key::KeyA => "a".into(),
|
||||
Key::KeyB => "b".into(),
|
||||
Key::KeyC => "c".into(),
|
||||
Key::KeyD => "d".into(),
|
||||
Key::KeyE => "e".into(),
|
||||
Key::KeyF => "f".into(),
|
||||
Key::KeyG => "g".into(),
|
||||
Key::KeyH => "h".into(),
|
||||
Key::KeyI => "i".into(),
|
||||
Key::KeyJ => "j".into(),
|
||||
Key::KeyK => "k".into(),
|
||||
Key::KeyL => "l".into(),
|
||||
Key::KeyM => "m".into(),
|
||||
Key::KeyN => "n".into(),
|
||||
Key::KeyO => "o".into(),
|
||||
Key::KeyP => "p".into(),
|
||||
Key::KeyQ => "q".into(),
|
||||
Key::KeyR => "r".into(),
|
||||
Key::KeyS => "s".into(),
|
||||
Key::KeyT => "t".into(),
|
||||
Key::KeyU => "u".into(),
|
||||
Key::KeyV => "v".into(),
|
||||
Key::KeyW => "w".into(),
|
||||
Key::KeyX => "x".into(),
|
||||
Key::KeyY => "y".into(),
|
||||
Key::KeyZ => "z".into(),
|
||||
Key::Num1 => "digit1".into(),
|
||||
Key::Num2 => "digit2".into(),
|
||||
Key::Num3 => "digit3".into(),
|
||||
Key::Num4 => "digit4".into(),
|
||||
Key::Num5 => "digit5".into(),
|
||||
Key::Num6 => "digit6".into(),
|
||||
Key::Num7 => "digit7".into(),
|
||||
Key::Num8 => "digit8".into(),
|
||||
Key::Num9 => "digit9".into(),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read keyboard-shortcut bindings from `LocalConfig` and refresh the cache.
|
||||
///
|
||||
/// Empty or invalid JSON falls back to `Bindings::default()` (disabled, no
|
||||
/// bindings). Call this once at startup and again whenever the config is
|
||||
/// written.
|
||||
pub fn reload_from_config() {
|
||||
let raw = hbb_common::config::LocalConfig::get_option(LOCAL_CONFIG_KEY);
|
||||
let parsed = if raw.is_empty() {
|
||||
Bindings::default()
|
||||
} else {
|
||||
serde_json::from_str(&raw).unwrap_or_default()
|
||||
};
|
||||
if let Ok(mut w) = CACHE.write() {
|
||||
*w = Arc::new(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot of the currently cached bindings. Cheap (one atomic increment) —
|
||||
/// safe to call on every keystroke.
|
||||
pub fn current() -> Arc<Bindings> {
|
||||
CACHE
|
||||
.read()
|
||||
.map(|b| Arc::clone(&b))
|
||||
.unwrap_or_else(|_| Arc::new(Bindings::default()))
|
||||
}
|
||||
|
||||
/// Match an `rdev::Event` against the cached bindings. Returns the matched
|
||||
/// action id, or `None` if no binding fires. The Flutter side ignores unknown
|
||||
/// action ids (logged as "no handler"), so no whitelist check is needed here.
|
||||
pub fn match_event(event: &rdev::Event) -> Option<String> {
|
||||
let bindings = current();
|
||||
if !bindings.enabled {
|
||||
return None;
|
||||
}
|
||||
let key_name = event_to_key_name(event)?;
|
||||
let (alt, ctrl, shift, command) =
|
||||
crate::keyboard::client::get_modifiers_state(false, false, false, false);
|
||||
let mods = normalize_modifiers(alt, ctrl, shift, command);
|
||||
match_normalized(&key_name, &mods, &bindings).map(str::to_owned)
|
||||
}
|
||||
|
||||
fn mods_bits(m: &[Modifier]) -> u8 {
|
||||
let mut bits = 0u8;
|
||||
for x in m {
|
||||
bits |= match x {
|
||||
Modifier::Primary => 1,
|
||||
Modifier::Alt => 2,
|
||||
Modifier::Shift => 4,
|
||||
};
|
||||
}
|
||||
bits
|
||||
}
|
||||
|
||||
fn mods_equal(a: &[Modifier], b: &[Modifier]) -> bool {
|
||||
mods_bits(a) == mods_bits(b)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bindings_round_trip_json() {
|
||||
let json = r#"{
|
||||
"enabled": true,
|
||||
"bindings": [
|
||||
{"action": "send_ctrl_alt_del", "mods": ["primary","alt","shift"], "key": "delete"},
|
||||
{"action": "toggle_fullscreen", "mods": ["primary","alt","shift"], "key": "enter"}
|
||||
]
|
||||
}"#;
|
||||
let parsed: Bindings = serde_json::from_str(json).expect("parse");
|
||||
assert!(parsed.enabled);
|
||||
assert_eq!(parsed.bindings.len(), 2);
|
||||
assert_eq!(parsed.bindings[0].action, "send_ctrl_alt_del");
|
||||
assert_eq!(parsed.bindings[0].key, "delete");
|
||||
|
||||
let serialized = serde_json::to_string(&parsed).expect("serialize");
|
||||
let reparsed: Bindings = serde_json::from_str(&serialized).expect("reparse");
|
||||
assert_eq!(parsed, reparsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_match_design_doc() {
|
||||
let defaults = default_bindings();
|
||||
let actions: Vec<&str> = defaults.iter().map(|b| b.action.as_str()).collect();
|
||||
assert!(actions.contains(&action_id::SEND_CTRL_ALT_DEL));
|
||||
assert!(actions.contains(&action_id::TOGGLE_FULLSCREEN));
|
||||
assert!(actions.contains(&action_id::SWITCH_DISPLAY_NEXT));
|
||||
assert!(actions.contains(&action_id::SWITCH_DISPLAY_PREV));
|
||||
assert!(actions.contains(&action_id::SCREENSHOT));
|
||||
assert!(actions.contains(&"switch_tab_1"));
|
||||
assert!(actions.contains(&"switch_tab_9"));
|
||||
// every default binding includes the three-modifier prefix
|
||||
for b in &defaults {
|
||||
assert!(b.mods.contains(&Modifier::Primary));
|
||||
assert!(b.mods.contains(&Modifier::Alt));
|
||||
assert!(b.mods.contains(&Modifier::Shift));
|
||||
}
|
||||
}
|
||||
|
||||
fn match_for_test<'a>(key: &str, mods: &[Modifier], b: &'a Bindings) -> Option<&'a str> {
|
||||
match_normalized(key, mods, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_returns_none_when_disabled() {
|
||||
let bindings = Bindings { enabled: false, bindings: default_bindings() };
|
||||
let result = match_for_test("p", &[Modifier::Primary, Modifier::Alt, Modifier::Shift], &bindings);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_screenshot_when_enabled() {
|
||||
let bindings = Bindings { enabled: true, bindings: default_bindings() };
|
||||
let result = match_for_test("p", &[Modifier::Primary, Modifier::Alt, Modifier::Shift], &bindings);
|
||||
assert_eq!(result, Some(action_id::SCREENSHOT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_returns_none_when_modifiers_partial() {
|
||||
let bindings = Bindings { enabled: true, bindings: default_bindings() };
|
||||
// missing Shift
|
||||
let result = match_for_test("p", &[Modifier::Primary, Modifier::Alt], &bindings);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_does_not_fire_on_extra_unbound_keys() {
|
||||
let bindings = Bindings { enabled: true, bindings: default_bindings() };
|
||||
let result = match_for_test("z", &[Modifier::Primary, Modifier::Alt, Modifier::Shift], &bindings);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_handles_duplicate_modifiers_in_input() {
|
||||
// A user-edited config could contain duplicate modifiers; the matcher must
|
||||
// treat the modifier list as a set, not a multiset.
|
||||
let bindings = Bindings {
|
||||
enabled: true,
|
||||
bindings: vec![Binding {
|
||||
action: "x".into(),
|
||||
mods: vec![Modifier::Primary, Modifier::Alt],
|
||||
key: "a".into(),
|
||||
}],
|
||||
};
|
||||
// Caller passes Primary twice — must not match a binding with Primary+Alt.
|
||||
assert_eq!(
|
||||
match_normalized("a", &[Modifier::Primary, Modifier::Primary], &bindings),
|
||||
None,
|
||||
);
|
||||
// Caller passes Primary+Alt with one duplicate — should still match.
|
||||
assert_eq!(
|
||||
match_normalized("a", &[Modifier::Primary, Modifier::Alt, Modifier::Alt], &bindings),
|
||||
Some("x"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifier_normalization_primary_resolves_per_os() {
|
||||
// On Win/Linux: pressing Ctrl satisfies Primary
|
||||
let mods = normalize_modifiers(/*alt=*/true, /*ctrl=*/true, /*shift=*/true, /*command=*/false);
|
||||
if cfg!(target_os = "macos") {
|
||||
// On macOS Ctrl is NOT primary
|
||||
assert!(!mods.contains(&Modifier::Primary));
|
||||
} else {
|
||||
assert!(mods.contains(&Modifier::Primary));
|
||||
}
|
||||
assert!(mods.contains(&Modifier::Alt));
|
||||
assert!(mods.contains(&Modifier::Shift));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifier_normalization_command_is_primary_on_mac() {
|
||||
let mods = normalize_modifiers(true, false, true, /*command=*/true);
|
||||
if cfg!(target_os = "macos") {
|
||||
assert!(mods.contains(&Modifier::Primary));
|
||||
} else {
|
||||
// On Win/Linux Command/Meta is NOT primary
|
||||
assert!(!mods.contains(&Modifier::Primary));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_handles_missing_and_invalid_json() {
|
||||
// empty (no value set) → defaults
|
||||
hbb_common::config::LocalConfig::set_option(LOCAL_CONFIG_KEY.into(), String::new());
|
||||
reload_from_config();
|
||||
let b = current();
|
||||
assert!(!b.enabled);
|
||||
assert!(b.bindings.is_empty());
|
||||
|
||||
// invalid JSON → defaults (no panic)
|
||||
hbb_common::config::LocalConfig::set_option(LOCAL_CONFIG_KEY.into(), "not json".into());
|
||||
reload_from_config();
|
||||
let b = current();
|
||||
assert!(!b.enabled);
|
||||
}
|
||||
}
|
||||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", "اسم العرض"),
|
||||
("password-hidden-tip", "كلمة المرور مخفية"),
|
||||
("preset-password-in-use-tip", "كلمة المرور المحددة مسبقًا قيد الاستخدام"),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", "Імя для адлюстравання"),
|
||||
("password-hidden-tip", "Зададзены пастаянны пароль (скрыты)."),
|
||||
("preset-password-in-use-tip", "Пададзены пароль цяпер выкарыстоўваецца"),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,44 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", "显示名称"),
|
||||
("password-hidden-tip", "永久密码已设置(已隐藏)"),
|
||||
("preset-password-in-use-tip", "当前使用预设密码"),
|
||||
("Enable privacy mode", "允许隐私模式"),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
("Keyboard Shortcuts", "键盘快捷键"),
|
||||
("Configure shortcuts...", "配置快捷键..."),
|
||||
("Enable keyboard shortcuts in remote session", "在远程会话中启用键盘快捷键"),
|
||||
("shortcut-page-description", "启用后,列出的组合键将在本地触发会话操作,而不会发送到远程端。所有快捷键必须包含 Ctrl+Alt+Shift(macOS 上为 Cmd+Option+Shift),以避免与正常输入冲突。"),
|
||||
("Reset to defaults", "恢复默认设置"),
|
||||
("shortcut-reset-confirm-tip", "这将以默认快捷键替换所有当前绑定。是否继续?"),
|
||||
("Session Control", "会话控制"),
|
||||
("Toggle Fullscreen", "切换全屏"),
|
||||
("Switch to next display", "切换到下一个显示器"),
|
||||
("Switch to previous display", "切换到上一个显示器"),
|
||||
("View Mode 1:1", "原始大小"),
|
||||
("View Mode Shrink", "缩小"),
|
||||
("View Mode Stretch", "拉伸"),
|
||||
("Take Screenshot", "截图"),
|
||||
("Toggle Audio", "切换音频"),
|
||||
("Toggle Privacy Mode", "切换隐私模式"),
|
||||
("Toggle Recording", "切换录制"),
|
||||
("Toggle Block User Input", "切换屏蔽用户输入"),
|
||||
("Switch Tab 1", "切换到第 1 个标签"),
|
||||
("Switch Tab 2", "切换到第 2 个标签"),
|
||||
("Switch Tab 3", "切换到第 3 个标签"),
|
||||
("Switch Tab 4", "切换到第 4 个标签"),
|
||||
("Switch Tab 5", "切换到第 5 个标签"),
|
||||
("Switch Tab 6", "切换到第 6 个标签"),
|
||||
("Switch Tab 7", "切换到第 7 个标签"),
|
||||
("Switch Tab 8", "切换到第 8 个标签"),
|
||||
("Switch Tab 9", "切换到第 9 个标签"),
|
||||
("Edit", "编辑"),
|
||||
("Save", "保存"),
|
||||
("Set Shortcut", "设置快捷键"),
|
||||
("shortcut-recording-instruction", "请按下您想使用的组合键。"),
|
||||
("shortcut-recording-press-keys-tip", "请按下组合键..."),
|
||||
("shortcut-must-include-prefix", "必须包含"),
|
||||
("shortcut-already-bound-to", "已绑定到"),
|
||||
("Replace", "替换"),
|
||||
("Valid", "有效"),
|
||||
("shortcut-mobile-physical-keyboard-tip", "录制需要使用物理键盘,不支持软键盘。"),
|
||||
("On", "开"),
|
||||
("Off", "关"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", "Anzeigename"),
|
||||
("password-hidden-tip", "Ein permanentes Passwort wurde festgelegt (ausgeblendet)."),
|
||||
("preset-password-in-use-tip", "Das voreingestellte Passwort wird derzeit verwendet."),
|
||||
("Enable privacy mode", "Datenschutzmodus aktivieren"),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", "Εμφανιζόμενο όνομα"),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -274,6 +274,47 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("keep-awake-during-incoming-sessions-label", "Keep screen awake during incoming sessions"),
|
||||
("password-hidden-tip", "Permanent password is set (hidden)."),
|
||||
("preset-password-in-use-tip", "Preset password is currently in use."),
|
||||
("allow-remote-toolbar-docking-any-edge", "Allow docking remote toolbar to any window edge"),
|
||||
("Keyboard Shortcuts", ""),
|
||||
("Configure shortcuts...", ""),
|
||||
("Enable keyboard shortcuts in remote session", ""),
|
||||
("shortcut-page-description", "When enabled, listed key combinations trigger session actions locally instead of being sent to the remote. All bindings must include Ctrl+Alt+Shift (Cmd+Option+Shift on macOS) to avoid conflicts with normal typing."),
|
||||
("Reset to defaults", ""),
|
||||
("shortcut-reset-confirm-tip", "This will replace all current bindings with the default set. Continue?"),
|
||||
("Session Control", ""),
|
||||
("Display", ""),
|
||||
("Other", ""),
|
||||
("Toggle Fullscreen", ""),
|
||||
("Switch to next display", ""),
|
||||
("Switch to previous display", ""),
|
||||
("View Mode 1:1", ""),
|
||||
("View Mode Shrink", ""),
|
||||
("View Mode Stretch", ""),
|
||||
("Take Screenshot", ""),
|
||||
("Toggle Audio", ""),
|
||||
("Toggle Privacy Mode", ""),
|
||||
("Toggle Recording", ""),
|
||||
("Toggle Block User Input", ""),
|
||||
("Switch Tab 1", ""),
|
||||
("Switch Tab 2", ""),
|
||||
("Switch Tab 3", ""),
|
||||
("Switch Tab 4", ""),
|
||||
("Switch Tab 5", ""),
|
||||
("Switch Tab 6", ""),
|
||||
("Switch Tab 7", ""),
|
||||
("Switch Tab 8", ""),
|
||||
("Switch Tab 9", ""),
|
||||
("Edit", ""),
|
||||
("Save", ""),
|
||||
("Set Shortcut", ""),
|
||||
("shortcut-recording-instruction", "Press the key combination you want to use."),
|
||||
("shortcut-recording-press-keys-tip", "Press a key combination..."),
|
||||
("shortcut-must-include-prefix", "Must include"),
|
||||
("shortcut-already-bound-to", "Already bound to"),
|
||||
("Replace", ""),
|
||||
("Valid", ""),
|
||||
("shortcut-mobile-physical-keyboard-tip", "Recording requires a physical keyboard. Soft keyboards are not supported."),
|
||||
("Clear", ""),
|
||||
("On", ""),
|
||||
("Off", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Closed manually by the peer", "Cerrado manualmente por el par"),
|
||||
("Enable remote configuration modification", "Habilitar modificación remota de configuración"),
|
||||
("Run without install", "Ejecutar sin instalar"),
|
||||
("Connect via relay", "Conectar a través de relay"),
|
||||
("Connect via relay", ""),
|
||||
("Always connect via relay", "Conéctese siempre a través de relay"),
|
||||
("whitelist_tip", "Solo las direcciones IP autorizadas pueden conectarse a este escritorio"),
|
||||
("Login", "Iniciar sesión"),
|
||||
|
|
@ -228,7 +228,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Username missed", "Olvidó su nombre de usuario"),
|
||||
("Password missed", "Olvidó su contraseña"),
|
||||
("Wrong credentials", "Credenciales incorrectas"),
|
||||
("The verification code is incorrect or has expired", "El código de verificación es incorrecto o ha caducado"),
|
||||
("The verification code is incorrect or has expired", ""),
|
||||
("Edit Tag", "Editar tag"),
|
||||
("Forget Password", "Olvidar contraseña"),
|
||||
("Favorites", "Favoritos"),
|
||||
|
|
@ -302,8 +302,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Keep RustDesk background service", "Dejar RustDesk como Servicio en 2do plano"),
|
||||
("Ignore Battery Optimizations", "Ignorar optimizacioens de bateria"),
|
||||
("android_open_battery_optimizations_tip", "Si deseas deshabilitar esta característica, por favor, ve a la página siguiente de ajustes, busca y entra en [Batería] y desmarca [Sin restricción]"),
|
||||
("Start on boot", "Iniciar al arrancar"),
|
||||
("Start the screen sharing service on boot, requires special permissions", "Iniciar el servicio de pantalla compartida al arrancar, requiere permisos especiales"),
|
||||
("Start on boot", ""),
|
||||
("Start the screen sharing service on boot, requires special permissions", ""),
|
||||
("Connection not allowed", "Conexión no disponible"),
|
||||
("Legacy mode", "Modo heredado"),
|
||||
("Map mode", "Modo mapa"),
|
||||
|
|
@ -326,8 +326,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Ratio", "Relación"),
|
||||
("Image Quality", "Calidad de imagen"),
|
||||
("Scroll Style", "Estilo de desplazamiento"),
|
||||
("Show Toolbar", "Mostrar herramientas"),
|
||||
("Hide Toolbar", "Ocultar herramientas"),
|
||||
("Show Toolbar", ""),
|
||||
("Hide Toolbar", ""),
|
||||
("Direct Connection", "Conexión directa"),
|
||||
("Relay Connection", "Conexión Relay"),
|
||||
("Secure Connection", "Conexión segura"),
|
||||
|
|
@ -338,7 +338,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Security", "Seguridad"),
|
||||
("Theme", "Tema"),
|
||||
("Dark Theme", "Tema Oscuro"),
|
||||
("Light Theme", "Tema claro"),
|
||||
("Light Theme", ""),
|
||||
("Dark", "Oscuro"),
|
||||
("Light", "Claro"),
|
||||
("Follow System", "Tema del sistema"),
|
||||
|
|
@ -355,12 +355,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Audio Input Device", "Dispositivo de entrada de audio"),
|
||||
("Use IP Whitelisting", "Usar lista de IPs admitidas"),
|
||||
("Network", "Red"),
|
||||
("Pin Toolbar", "Anclar herramientas"),
|
||||
("Unpin Toolbar", "Desanclar herramientas"),
|
||||
("Pin Toolbar", ""),
|
||||
("Unpin Toolbar", ""),
|
||||
("Recording", "Grabando"),
|
||||
("Directory", "Directorio"),
|
||||
("Automatically record incoming sessions", "Grabación automática de sesiones entrantes"),
|
||||
("Automatically record outgoing sessions", "Grabación automática de sesiones salientes"),
|
||||
("Automatically record outgoing sessions", ""),
|
||||
("Change", "Cambiar"),
|
||||
("Start session recording", "Comenzar grabación de sesión"),
|
||||
("Stop session recording", "Detener grabación de sesión"),
|
||||
|
|
@ -368,7 +368,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Enable LAN discovery", "Habilitar descubrimiento de LAN"),
|
||||
("Deny LAN discovery", "Denegar descubrimiento de LAN"),
|
||||
("Write a message", "Escribir un mensaje"),
|
||||
("Prompt", "Solicitud"),
|
||||
("Prompt", ""),
|
||||
("Please wait for confirmation of UAC...", "Por favor, espera confirmación de UAC"),
|
||||
("elevated_foreground_window_tip", "La ventana actual del escritorio remoto necesita privilegios elevados para funcionar, así que no puedes usar ratón y teclado temporalmente. Puedes solicitar al usuario remoto que minimize la ventana actual o hacer clic en el botón de elevación de la ventana de gestión de conexión. Para evitar este problema, se recomienda instalar el programa en el dispositivo remto."),
|
||||
("Disconnected", "Desconectado"),
|
||||
|
|
@ -616,9 +616,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("During service is on", "Mientras el servicio está activo"),
|
||||
("Capture screen using DirectX", "Capturar pantalla con DirectX"),
|
||||
("Back", "Atrás"),
|
||||
("Apps", "Aplicaciones"),
|
||||
("Volume up", "Subir volumen"),
|
||||
("Volume down", "Bajar volumen"),
|
||||
("Apps", ""),
|
||||
("Volume up", "Bajar volumen"),
|
||||
("Volume down", "Subir volumen"),
|
||||
("Power", "Encendido"),
|
||||
("Telegram bot", "Bot de Telegram"),
|
||||
("enable-bot-tip", "Si activas esta característica puedes recibir código 2FA de tu bot. También puede funcionar como notificación de conexión."),
|
||||
|
|
@ -651,7 +651,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Update client clipboard", "Actualizar portapapeles del cliente"),
|
||||
("Untagged", "Sin itiquetar"),
|
||||
("new-version-of-{}-tip", "Hay una nueva versión de {} disponible"),
|
||||
("Accessible devices", "Dispositivos accesibles"),
|
||||
("Accessible devices", ""),
|
||||
("upgrade_remote_rustdesk_client_to_{}_tip", "Por favor, actualiza el cliente RustDesk a la versión {} o superior en el lado remoto"),
|
||||
("d3d_render_tip", "Al activar el renderizado D3D, la pantalla de control remoto puede verse negra en algunos equipos."),
|
||||
("Use D3D rendering", "Usar renderizado D3D"),
|
||||
|
|
@ -689,9 +689,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Use WebSocket", "Usar WebSocket"),
|
||||
("Trackpad speed", "Velocidad de trackpad"),
|
||||
("Default trackpad speed", "Velocidad predeterminada de trackpad"),
|
||||
("Numeric one-time password", "Contraseña numérica de un solo uso"),
|
||||
("Enable IPv6 P2P connection", "Habilitar conexión IPv6 P2P"),
|
||||
("Enable UDP hole punching", "Habilitar perforación de agujero UDP"),
|
||||
("Numeric one-time password", ""),
|
||||
("Enable IPv6 P2P connection", ""),
|
||||
("Enable UDP hole punching", ""),
|
||||
("View camera", "Ver cámara"),
|
||||
("Enable camera", "Habilitar cámara"),
|
||||
("No cameras", "No hay cámaras"),
|
||||
|
|
@ -708,8 +708,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Failed to check if the user is an administrator.", "No se ha podido comprobar si el usuario es un administrador."),
|
||||
("Supported only in the installed version.", "Soportado solo en la versión instalada."),
|
||||
("elevation_username_tip", "Introduzca el nombre de usuario o dominio\\NombreDeUsuario"),
|
||||
("Preparing for installation ...", "Preparando instlación..."),
|
||||
("Show my cursor", "Mostrar mi cursor"),
|
||||
("Preparing for installation ...", ""),
|
||||
("Show my cursor", ""),
|
||||
("Scale custom", "Escala personalizada"),
|
||||
("Custom scale slider", "Control deslizante de escala personalizada"),
|
||||
("Decrease", "Disminuir"),
|
||||
|
|
@ -721,29 +721,27 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Show virtual joystick", "Mostrar joystick virtual"),
|
||||
("Edit note", "Editar nota"),
|
||||
("Alias", ""),
|
||||
("ScrollEdge", "Desplazamiento de pantalla"),
|
||||
("Allow insecure TLS fallback", "Permitir conexión TLS insegura de respaldo"),
|
||||
("allow-insecure-tls-fallback-tip", "De forma predeterminada, RustDesk verifica el certificado de servidor para protocolos que usen TLS.\nCon esta opción habilitada, Rustdesk volverá al paso de omisión de verificación y procederá en caso de fallo de verificación."),
|
||||
("Disable UDP", "Inhabilitar UDP"),
|
||||
("disable-udp-tip", "Controla si se usa TCP solamente.\nCuando esta opción está activa, RustDesk no usará más el puerto UDP 21116, en su lugar se usará el TCP 21116."),
|
||||
("server-oss-not-support-tip", "NOTA: El servidor RustDesk OSS no incluye esta característica."),
|
||||
("input note here", "Introducir nota aquí"),
|
||||
("note-at-conn-end-tip", "Pedir nota al finalizar la conexión"),
|
||||
("Show terminal extra keys", "Mostrar teclas extra del terminal"),
|
||||
("Relative mouse mode", "Modo de ratón relativo"),
|
||||
("rel-mouse-not-supported-peer-tip", "El modo relativo de ratón no está soportado por el par."),
|
||||
("rel-mouse-not-ready-tip", "El modo relativo de ratón aún no está preparado. Por favor, inténtalo de nuevo."),
|
||||
("rel-mouse-lock-failed-tip", "Ha fallado el bloqueo del cursor. El modo relativo del ratón ha sido inhabilitado."),
|
||||
("rel-mouse-exit-{}-tip", "Pulsa {} para salir."),
|
||||
("rel-mouse-permission-lost-tip", "Permiso de teclado revocado. El modo relativo del ratón ha sido inhabilitado."),
|
||||
("Changelog", "Registro de cambios"),
|
||||
("keep-awake-during-outgoing-sessions-label", "Mantener la pantalla activa durante sesiones salientes"),
|
||||
("keep-awake-during-incoming-sessions-label", "Mantener la pantalla activa durante sesiones entrantes"),
|
||||
("ScrollEdge", ""),
|
||||
("Allow insecure TLS fallback", ""),
|
||||
("allow-insecure-tls-fallback-tip", ""),
|
||||
("Disable UDP", ""),
|
||||
("disable-udp-tip", ""),
|
||||
("server-oss-not-support-tip", ""),
|
||||
("input note here", ""),
|
||||
("note-at-conn-end-tip", ""),
|
||||
("Show terminal extra keys", ""),
|
||||
("Relative mouse mode", ""),
|
||||
("rel-mouse-not-supported-peer-tip", ""),
|
||||
("rel-mouse-not-ready-tip", ""),
|
||||
("rel-mouse-lock-failed-tip", ""),
|
||||
("rel-mouse-exit-{}-tip", ""),
|
||||
("rel-mouse-permission-lost-tip", ""),
|
||||
("Changelog", ""),
|
||||
("keep-awake-during-outgoing-sessions-label", ""),
|
||||
("keep-awake-during-incoming-sessions-label", ""),
|
||||
("Continue with {}", "Continuar con {}"),
|
||||
("Display Name", "Nombre de pantalla"),
|
||||
("password-hidden-tip", "La contraseña permanente está ajustada a (oculta)."),
|
||||
("preset-password-in-use-tip", "Se está usando la contraseña predeterminada."),
|
||||
("Enable privacy mode", "Habilitar modo privado"),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", "Nom d’affichage"),
|
||||
("password-hidden-tip", "Le mot de passe permanent est défini (masqué)."),
|
||||
("preset-password-in-use-tip", "Le mot de passe prédéfini est actuellement utilisé."),
|
||||
("Enable privacy mode", "Activer le mode de confidentialité"),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -654,7 +654,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Accessible devices", "એક્સેસિબલ ઉપકરણો"),
|
||||
("upgrade_remote_rustdesk_client_to_{}_tip", "રિમોટ ક્લાયન્ટને {} માં અપગ્રેડ કરો"),
|
||||
("d3d_render_tip", "D3D રેન્ડરિંગ વાપરો"),
|
||||
("Use D3D rendering", ""),
|
||||
("Printer", "પ્રિન્ટર"),
|
||||
("printer-os-requirement-tip", "પ્રિન્ટિંગ માટે Windows જરૂરી છે."),
|
||||
("printer-requires-installed-{}-client-tip", "આ માટે {} ક્લાયન્ટ ઇન્સ્ટોલ હોવું જોઈએ."),
|
||||
|
|
@ -743,7 +742,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", "ડિસ્પ્લે નામ"),
|
||||
("password-hidden-tip", "સુરક્ષા માટે પાસવર્ડ છુપાવેલ છે."),
|
||||
("preset-password-in-use-tip", "પ્રીસેટ પાસવર્ડ વપરાશમાં છે."),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -654,7 +654,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Accessible devices", "सुलभ डिवाइस"),
|
||||
("upgrade_remote_rustdesk_client_to_{}_tip", "रिमोट RustDesk क्लाइंट को संस्करण {} में अपग्रेड करें"),
|
||||
("d3d_render_tip", "D3D रेंडरिंग का उपयोग करें"),
|
||||
("Use D3D rendering", ""),
|
||||
("Printer", "प्रिंटर"),
|
||||
("printer-os-requirement-tip", "प्रिंटिंग के लिए Windows आवश्यक है।"),
|
||||
("printer-requires-installed-{}-client-tip", "इसके लिए क्लाइंट साइड पर {} इंस्टॉल होना चाहिए।"),
|
||||
|
|
@ -743,7 +742,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", "प्रदर्शित नाम"),
|
||||
("password-hidden-tip", "पासवर्ड सुरक्षा के लिए छिपा हुआ है।"),
|
||||
("preset-password-in-use-tip", "पूर्व-निर्धारित पासवर्ड उपयोग में है।"),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", "Kijelző név"),
|
||||
("password-hidden-tip", "Állandó jelszó lett beállítva (rejtett)."),
|
||||
("preset-password-in-use-tip", "Jelenleg az alapértelmezett jelszót használja."),
|
||||
("Enable privacy mode", "Adatvédelmi mód aktiválása"),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", "Visualizza nome"),
|
||||
("password-hidden-tip", "È impostata una password permanente (nascosta)."),
|
||||
("preset-password-in-use-tip", "È attualmente in uso la password preimpostata."),
|
||||
("Enable privacy mode", "Abilita modalità privacy"),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -739,11 +739,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Changelog", "更新履歴"),
|
||||
("keep-awake-during-outgoing-sessions-label", "送信セッション中は、画面のスリープを無効化する"),
|
||||
("keep-awake-during-incoming-sessions-label", "受信セッション中は、画面のスリープを無効化する"),
|
||||
("Continue with {}", "{} で続行する"),
|
||||
("Continue with {}", "{}で続行する"),
|
||||
("Display Name", "表示名"),
|
||||
("password-hidden-tip", "永続的なパスワードが設定されています (非表示)"),
|
||||
("preset-password-in-use-tip", "プリセットパスワードが現在使用されています"),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", "표시 이름"),
|
||||
("password-hidden-tip", "영구 비밀번호가 설정되었습니다 (숨김)."),
|
||||
("preset-password-in-use-tip", "현재 사전 설정된 비밀번호가 사용 중입니다."),
|
||||
("Enable privacy mode", "개인정보 보호 모드 사용함"),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -654,7 +654,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Accessible devices", "ലഭ്യമായ ഉപകരണങ്ങൾ"),
|
||||
("upgrade_remote_rustdesk_client_to_{}_tip", "റിമോട്ട് പതിപ്പ് {} ലേക്ക് മാറ്റുക"),
|
||||
("d3d_render_tip", "D3D റെൻഡറിംഗ് ഉപയോഗിക്കുക"),
|
||||
("Use D3D rendering", ""),
|
||||
("Printer", "പ്രിന്റർ"),
|
||||
("printer-os-requirement-tip", "പ്രിന്റിംഗിന് വിൻഡോസ് വേണം."),
|
||||
("printer-requires-installed-{}-client-tip", "ഇതിന് {} ക്ലയന്റ് ഇൻസ്റ്റാൾ ചെയ്യണം."),
|
||||
|
|
@ -743,7 +742,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", "ഡിസ്പ്ലേ പേര്"),
|
||||
("password-hidden-tip", "സുരക്ഷയ്ക്കായി പാസ്വേഡ് മറച്ചിരിക്കുന്നു."),
|
||||
("preset-password-in-use-tip", "പ്രീസെറ്റ് പാസ്വേഡ് ഉപയോഗത്തിലാണ്."),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", "Naam Weergeven"),
|
||||
("password-hidden-tip", "Er is een permanent wachtwoord ingesteld (verborgen)."),
|
||||
("preset-password-in-use-tip", "Het basis wachtwoord is momenteel in gebruik."),
|
||||
("Enable privacy mode", "Privacymodus inschakelen"),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", "Nazwa wyświetlana"),
|
||||
("password-hidden-tip", "Ustawiono (ukryto) stare hasło."),
|
||||
("preset-password-in-use-tip", "Obecnie używane jest hasło domyślne."),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -740,10 +740,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("keep-awake-during-outgoing-sessions-label", "Manter tela ativa durante sessões de saída"),
|
||||
("keep-awake-during-incoming-sessions-label", "Manter tela ativa durante sessões de entrada"),
|
||||
("Continue with {}", "Continuar com {}"),
|
||||
("Display Name", "Nome de Exibição"),
|
||||
("password-hidden-tip", "A senha permanente está definida como (oculta)."),
|
||||
("preset-password-in-use-tip", "A senha predefinida está sendo usada."),
|
||||
("Enable privacy mode", "Habilitar modo de privacidade"),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -540,7 +540,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("auto_disconnect_option_tip", "Deconectează automat sesiunile de la distanță după o perioadă de inactivitate."),
|
||||
("Connection failed due to inactivity", "Conexiunea a eșuat din cauza inactivității"),
|
||||
("Check for software update on startup", "Verifică actualizări la pornire"),
|
||||
("upgrade_rustdesk_server_pro_to_{}_tip", "Versiunea serverului RustDesk Pro este mai mică decât {}. Te rugăm să o actualizezi."),
|
||||
("upgrade_rustdesk_server_pro_{}_tip", "Versiunea serverului RustDesk Pro este mai mică decât {}. Te rugăm să o actualizezi."),
|
||||
("pull_group_failed_tip", "Sincronizarea grupului a eșuat. Verifică conexiunea la rețea sau autentifică-te din nou."),
|
||||
("Filter by intersection", "Filtrează prin intersecție"),
|
||||
("Remove wallpaper during incoming sessions", "Elimină imaginea de fundal în timpul sesiunilor primite"),
|
||||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", "Nume afișat"),
|
||||
("password-hidden-tip", "Parola este ascunsă din motive de securitate. Fă clic pe pictograma ochiului pentru a o afișa."),
|
||||
("preset-password-in-use-tip", "Se folosește o parolă prestabilită. Se recomandă setarea unei parole personalizate pentru securitate sporită."),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", "Отображаемое имя"),
|
||||
("password-hidden-tip", "Установлен постоянный пароль (скрытый)."),
|
||||
("preset-password-in-use-tip", "Установленный пароль сейчас используется."),
|
||||
("Enable privacy mode", "Использовать режим конфиденциальности"),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -741,9 +741,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("keep-awake-during-incoming-sessions-label", "Gelen oturumlar süresince ekranı açık tutun"),
|
||||
("Continue with {}", "{} ile devam et"),
|
||||
("Display Name", "Görünen Ad"),
|
||||
("password-hidden-tip", "Parola gizli"),
|
||||
("preset-password-in-use-tip", "Önceden ayarlanmış parola kullanılıyor"),
|
||||
("Enable privacy mode", "Gizlilik modunu etkinleştir"),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
("password-hidden-tip", "Şifre gizli"),
|
||||
("preset-password-in-use-tip", "Önceden ayarlanmış şifre kullanılıyor"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", "顯示名稱"),
|
||||
("password-hidden-tip", "固定密碼已設定(已隱藏)"),
|
||||
("preset-password-in-use-tip", "目前正在使用預設密碼"),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,7 +743,5 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
("allow-remote-toolbar-docking-any-edge", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,12 +29,6 @@ use wallpaper;
|
|||
pub const PA_SAMPLE_RATE: u32 = 48000;
|
||||
static mut UNMODIFIED: bool = true;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ActiveUserLookupCache {
|
||||
uid: String,
|
||||
username: String,
|
||||
}
|
||||
|
||||
const INVALID_TERM_VALUES: [&str; 3] = ["", "unknown", "dumb"];
|
||||
const SHELL_PROCESSES: [&str; 4] = ["bash", "zsh", "fish", "sh"];
|
||||
|
||||
|
|
@ -56,8 +50,6 @@ lazy_static::lazy_static! {
|
|||
}
|
||||
}
|
||||
};
|
||||
static ref ACTIVE_USER_LOOKUP_CACHE: std::sync::Mutex<Option<ActiveUserLookupCache>> =
|
||||
std::sync::Mutex::new(None);
|
||||
// https://github.com/rustdesk/rustdesk/issues/13705
|
||||
// Check if `sudo -E` actually preserves environment.
|
||||
//
|
||||
|
|
@ -90,27 +82,6 @@ lazy_static::lazy_static! {
|
|||
};
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn update_active_user_lookup_cache(desktop: &Desktop) {
|
||||
if let Ok(mut cache) = ACTIVE_USER_LOOKUP_CACHE.lock() {
|
||||
if desktop.uid.is_empty() || desktop.username.is_empty() {
|
||||
*cache = None;
|
||||
} else {
|
||||
*cache = Some(ActiveUserLookupCache {
|
||||
uid: desktop.uid.clone(),
|
||||
username: desktop.username.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn get_active_user_id_name_from_cache() -> Option<(String, String)> {
|
||||
let cache = ACTIVE_USER_LOOKUP_CACHE.lock().ok()?;
|
||||
let entry = cache.as_ref()?;
|
||||
Some((entry.uid.clone(), entry.username.clone()))
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
// XDO context - created via libxdo-sys (which uses dynamic loading stub).
|
||||
// If libxdo is not available, xdo will be null and xdo-based functions become no-ops.
|
||||
|
|
@ -818,7 +789,6 @@ pub fn start_os_service() {
|
|||
let mut last_restart = Instant::now();
|
||||
while running.load(Ordering::SeqCst) {
|
||||
desktop.refresh();
|
||||
update_active_user_lookup_cache(&desktop);
|
||||
|
||||
// Duplicate logic here with should_start_server
|
||||
// Login wayland will try to start a headless --server.
|
||||
|
|
@ -891,29 +861,13 @@ pub fn start_os_service() {
|
|||
}
|
||||
|
||||
#[inline]
|
||||
/// Returns the cached active `(uid, username)` snapshot when available.
|
||||
/// Callers that require a fresh seat0 lookup should call `get_values_of_seat0` directly.
|
||||
pub fn get_active_user_id_name() -> (String, String) {
|
||||
if let Some(id_name) = get_active_user_id_name_from_cache() {
|
||||
return id_name;
|
||||
}
|
||||
let vec_id_name = get_values_of_seat0(&[1, 2]);
|
||||
(vec_id_name[0].clone(), vec_id_name[1].clone())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
/// Returns the cached active uid when available.
|
||||
/// Callers that require a fresh seat0 lookup should call `get_values_of_seat0` directly.
|
||||
pub fn get_active_userid() -> String {
|
||||
if let Some((uid, _)) = get_active_user_id_name_from_cache() {
|
||||
return uid;
|
||||
}
|
||||
get_values_of_seat0(&[1])[0].clone()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
/// Returns the active uid from a fresh seat0 lookup, bypassing the service-loop cache.
|
||||
pub fn get_active_userid_fresh() -> String {
|
||||
get_values_of_seat0(&[1])[0].clone()
|
||||
}
|
||||
|
||||
|
|
@ -968,12 +922,7 @@ fn _get_display_manager() -> String {
|
|||
}
|
||||
|
||||
#[inline]
|
||||
/// Returns the cached active username when available.
|
||||
/// Callers that require a fresh seat0 lookup should call `get_values_of_seat0` directly.
|
||||
pub fn get_active_username() -> String {
|
||||
if let Some((_, username)) = get_active_user_id_name_from_cache() {
|
||||
return username;
|
||||
}
|
||||
get_values_of_seat0(&[2])[0].clone()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use super::{linux::*, ResultType};
|
|||
use crate::client::{
|
||||
LOGIN_MSG_DESKTOP_NO_DESKTOP, LOGIN_MSG_DESKTOP_SESSION_ANOTHER_USER,
|
||||
LOGIN_MSG_DESKTOP_SESSION_NOT_READY, LOGIN_MSG_DESKTOP_XORG_NOT_FOUND,
|
||||
LOGIN_MSG_DESKTOP_XSESSION_FAILED, LOGIN_MSG_PASSWORD_WRONG,
|
||||
LOGIN_MSG_DESKTOP_XSESSION_FAILED,
|
||||
};
|
||||
use hbb_common::{
|
||||
allow_err, bail, log,
|
||||
|
|
@ -94,49 +94,6 @@ fn detect_headless() -> Option<&'static str> {
|
|||
None
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
enum XSessionStartErrorKind {
|
||||
Auth,
|
||||
Env,
|
||||
}
|
||||
|
||||
const XSESSION_AUTH_FAILURE_DETAIL: &str = "authentication failed";
|
||||
|
||||
#[derive(Debug)]
|
||||
struct XSessionStartError {
|
||||
kind: XSessionStartErrorKind,
|
||||
detail: String,
|
||||
}
|
||||
|
||||
impl XSessionStartError {
|
||||
fn auth(detail: String) -> Self {
|
||||
Self {
|
||||
kind: XSessionStartErrorKind::Auth,
|
||||
detail,
|
||||
}
|
||||
}
|
||||
|
||||
fn env(detail: String) -> Self {
|
||||
Self {
|
||||
kind: XSessionStartErrorKind::Env,
|
||||
detail,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for XSessionStartError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.detail)
|
||||
}
|
||||
}
|
||||
|
||||
fn map_xsession_start_error_to_login_msg(kind: XSessionStartErrorKind) -> &'static str {
|
||||
match kind {
|
||||
XSessionStartErrorKind::Auth => LOGIN_MSG_PASSWORD_WRONG,
|
||||
XSessionStartErrorKind::Env => LOGIN_MSG_DESKTOP_XSESSION_FAILED,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_start_desktop(_username: &str, _passsword: &str) -> String {
|
||||
debug_assert!(crate::is_server());
|
||||
if _username.is_empty() {
|
||||
|
|
@ -179,21 +136,14 @@ pub fn try_start_desktop(_username: &str, _passsword: &str) -> String {
|
|||
}
|
||||
}
|
||||
Err(e) => {
|
||||
match e.kind {
|
||||
XSessionStartErrorKind::Auth => {
|
||||
log::warn!("Failed to authenticate xsession user {}", e);
|
||||
}
|
||||
XSessionStartErrorKind::Env => {
|
||||
log::error!("Failed to start xsession {}", e);
|
||||
}
|
||||
}
|
||||
map_xsession_start_error_to_login_msg(e.kind).to_owned()
|
||||
log::error!("Failed to start xsession {}", e);
|
||||
LOGIN_MSG_DESKTOP_XSESSION_FAILED.to_owned()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_start_x_session(username: &str, password: &str) -> Result<(String, bool), XSessionStartError> {
|
||||
fn try_start_x_session(username: &str, password: &str) -> ResultType<(String, bool)> {
|
||||
let mut desktop_manager = DESKTOP_MANAGER.lock().unwrap();
|
||||
if let Some(desktop_manager) = &mut (*desktop_manager) {
|
||||
if let Some(seat0_username) = desktop_manager.get_supported_display_seat0_username() {
|
||||
|
|
@ -211,9 +161,7 @@ fn try_start_x_session(username: &str, password: &str) -> Result<(String, bool),
|
|||
desktop_manager.is_running(),
|
||||
))
|
||||
} else {
|
||||
Err(XSessionStartError::env(
|
||||
crate::client::LOGIN_MSG_DESKTOP_NOT_INITED.to_owned(),
|
||||
))
|
||||
bail!(crate::client::LOGIN_MSG_DESKTOP_NOT_INITED);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -299,15 +247,10 @@ impl DesktopManager {
|
|||
self.is_child_running.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
fn try_start_x_session(
|
||||
&mut self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(), XSessionStartError> {
|
||||
fn try_start_x_session(&mut self, username: &str, password: &str) -> ResultType<()> {
|
||||
match get_user_by_name(username) {
|
||||
Some(userinfo) => {
|
||||
let mut client = pam::Client::with_password(&pam_get_service_name())
|
||||
.map_err(|e| XSessionStartError::env(format!("failed to init pam client, {}", e)))?;
|
||||
let mut client = pam::Client::with_password(&pam_get_service_name())?;
|
||||
client
|
||||
.conversation_mut()
|
||||
.set_credentials(username, password);
|
||||
|
|
@ -324,24 +267,17 @@ impl DesktopManager {
|
|||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
Err(XSessionStartError::env(format!(
|
||||
"failed to start x session, {}",
|
||||
e
|
||||
)))
|
||||
bail!("failed to start x session, {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_e) => {
|
||||
Err(XSessionStartError::auth(
|
||||
XSESSION_AUTH_FAILURE_DETAIL.to_owned(),
|
||||
))
|
||||
Err(e) => {
|
||||
bail!("failed to check user pass for {}, {}", username, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
Err(XSessionStartError::auth(
|
||||
XSESSION_AUTH_FAILURE_DETAIL.to_owned(),
|
||||
))
|
||||
bail!("failed to get userinfo of {}", username);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue