Merge branch 'master' into id-whitelist

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

View File

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

View File

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

View File

@@ -485,7 +485,8 @@ class _GeneralState extends State<_General> {
Widget other() {
final incomingOnly = bind.isIncomingOnly();
final outgoingOnly = bind.isOutgoingOnly();
final showAutoUpdate = isWindows && bind.mainIsInstalled();
final showAutoUpdate = (isWindows && bind.mainIsInstalled()) ||
(isMacOS && bind.mainIsInstalled() && bind.mainIsInstalledDaemon(prompt: false) && !bind.isCustomClient());
final children = <Widget>[
if (!isWeb && !incomingOnly)
_OptionCheckBox(context, 'Confirm before closing multiple tabs',

View File

@@ -0,0 +1,24 @@
class MacOSFullScreenFocusRecovery {
int _generation = 0;
int? _pendingGeneration;
int? get pendingGeneration => _pendingGeneration;
int queue() {
_generation += 1;
_pendingGeneration = _generation;
return _generation;
}
void cancel() {
_pendingGeneration = null;
}
bool isCurrent(int generation) => _pendingGeneration == generation;
bool consume(int generation) {
if (!isCurrent(generation)) return false;
_pendingGeneration = null;
return true;
}
}

View File

@@ -22,6 +22,7 @@ import '../../utils/image.dart';
import '../widgets/remote_toolbar.dart';
import '../widgets/kb_layout_type_chooser.dart';
import '../widgets/tabbar_widget.dart';
import 'macos_full_screen_focus_recovery.dart';
import 'package:flutter_hbb/native/custom_cursor.dart'
if (dart.library.html) 'package:flutter_hbb/web/custom_cursor.dart';
@@ -64,6 +65,13 @@ class RemotePage extends StatefulWidget {
FFI get ffi => (_lastState.value! as _RemotePageState)._ffi;
void releaseMacOSInputForTabTransfer() {
if (!isMacOS) return;
// Release before removing the source tab. Its delayed disposal must not
// disable a native keyboard hook already acquired by the destination page.
(_lastState.value! as _RemotePageState)._releaseMacOSRemoteInput();
}
@override
State<RemotePage> createState() {
final state = _RemotePageState(id);
@@ -76,10 +84,28 @@ class _RemotePageState extends State<RemotePage>
with
AutomaticKeepAliveClientMixin,
MultiWindowListener,
WidgetsBindingObserver,
TickerProviderStateMixin {
Timer? _timer;
String keyboardMode = "legacy";
bool _isWindowBlur = false;
// Known macOS remote-input trade-offs (kept simple intentionally):
// 1. Dialogs rely on FocusNode loss plus middleBlocked, not mirrored dialog
// state. Reproduce: activate remote input, open a dialog, then type.
// 2. Delayed fullscreen recovery can race a local-control focus change; no
// owner state is added. Reproduce: focus the toolbar during a Space switch.
// 3. Input-source switching releases native input without updating this
// page's cache. Reproduce: switch sources, then type before and after
// clicking the remote image; the click reasserts input.
// These latches compensate for out-of-order macOS focus events. Treat them
// as coupled when changing a transition or _syncMacOSKeyboardGrab().
AppLifecycleState? _macOSLifecycleState;
bool _macOSLocalFocusLost = false;
bool _macOSInputActive = false;
bool _macOSInputSuppressed = false;
final _macOSFullScreenFocusRecovery = MacOSFullScreenFocusRecovery();
bool _macOSExplicitFocusRequestPending = false;
StreamSubscription<DesktopTabState>? _tabStateSubscription;
final _cursorOverImage = false.obs;
late RxBool _showRemoteCursor;
late RxBool _zoomCursor;
@@ -122,6 +148,13 @@ class _RemotePageState extends State<RemotePage>
void initState() {
super.initState();
_ffi = FFI(widget.sessionId);
if (isMacOS) {
// SchedulerBinding.instance.lifecycleState is null in the first connection in a new window.
_macOSLifecycleState = SchedulerBinding.instance.lifecycleState;
WidgetsBinding.instance.addObserver(this);
_tabStateSubscription =
widget.tabController?.state.listen(_onMacOSTabStateChanged);
}
Get.put<FFI>(_ffi, tag: widget.id);
_ffi.imageModel.addCallbackOnFirstImage((String peerId) {
_ffi.canvasModel.activateLocalCursor();
@@ -231,19 +264,224 @@ class _RemotePageState extends State<RemotePage>
_pointerLockCenterDebounceTimer = null;
}
bool get _isSelectedTab {
final controller = widget.tabController;
if (controller == null) return true;
final tabState = controller.state.value;
final selected = tabState.selected;
return selected >= 0 &&
selected < tabState.tabs.length &&
tabState.tabs[selected].key == widget.id;
}
bool get _isMacOSKeyboardContextActive {
return stateGlobal.isFocused.value && !_isWindowBlur && _isSelectedTab;
}
void _onMacOSTabStateChanged(DesktopTabState _) {
if (!_isSelectedTab) {
_macOSFullScreenFocusRecovery.cancel();
_syncMacOSKeyboardGrab();
return;
}
// Tab listeners run synchronously. Defer the selected page so the previous
// page releases first; a late leave from it can disable the new session.
scheduleMicrotask(() {
if (mounted) {
_syncMacOSKeyboardGrab(reassert: true);
}
});
}
void _releaseMacOSRemoteInput() {
_macOSFullScreenFocusRecovery.cancel();
_macOSExplicitFocusRequestPending = false;
_macOSInputSuppressed = true;
_macOSLocalFocusLost = true;
_ffi.inputModel.enterOrLeave(false);
_macOSInputActive = false;
_rawKeyFocusNode.unfocus();
}
void _onMacOSFocusChange() {
// requestFocus() notifies later; only a recorded explicit request may clear
// the local-focus-loss latch.
if (_rawKeyFocusNode.hasPrimaryFocus) {
final explicitRequest = _macOSExplicitFocusRequestPending;
_macOSExplicitFocusRequestPending = false;
if (explicitRequest && _isMacOSKeyboardContextActive) {
_macOSLocalFocusLost = false;
}
_syncMacOSKeyboardGrab(allowInactiveLifecycle: explicitRequest);
} else {
if (_macOSInputActive) {
_ffi.inputModel.enterOrLeave(false);
_macOSInputActive = false;
}
if (_isMacOSKeyboardContextActive) {
_macOSLocalFocusLost = true;
}
}
}
// 1. Sync the keyboard grab state with the current context.
// 2. Call enterOrLeave() to update the input state in the FFI layer.
// 3. Request or unfocus the raw key focus node based on the current context.
// Flutter focus and native input are separate; native input activates only
// after the FocusNode has primary focus.
void _syncMacOSKeyboardGrab({
bool reassert = false,
bool allowInactiveLifecycle = false,
}) {
if (!isMacOS) return;
// A secondary engine may stay hidden while its window is visible, so
// explicit pointer/fullscreen recovery must bypass the global lifecycle.
final lifecycleAllowsInput = allowInactiveLifecycle ||
_macOSLifecycleState == null ||
_macOSLifecycleState == AppLifecycleState.resumed;
// Input stays pointer-gated except for focused fullscreen recovery, which
// compensates when macOS omits PointerEnter during a Space switch.
final shouldFocus = lifecycleAllowsInput &&
_isMacOSKeyboardContextActive &&
!_macOSInputSuppressed &&
_blockableOverlayState.middleBlocked.isFalse &&
_cursorOverImage.value &&
!_macOSLocalFocusLost;
final hasFocus = _rawKeyFocusNode.hasPrimaryFocus;
final shouldActivateInput = shouldFocus && hasFocus;
if (shouldActivateInput != _macOSInputActive ||
(shouldActivateInput && reassert)) {
_ffi.inputModel.enterOrLeave(shouldActivateInput);
}
_macOSInputActive = shouldActivateInput;
if (!shouldFocus) {
_macOSExplicitFocusRequestPending = false;
if (hasFocus) _rawKeyFocusNode.unfocus();
} else if (!hasFocus) {
_macOSExplicitFocusRequestPending = allowInactiveLifecycle;
_rawKeyFocusNode.requestFocus();
} else {
_macOSExplicitFocusRequestPending = false;
}
}
void _restoreMacOSKeyboardAfterFullScreen({
required int generation,
bool allowHiddenLifecycle = false,
}) {
// Fullscreen callbacks preserve recovery while hidden. Native window focus
// may bypass a stale hidden lifecycle for the newly visible Space.
if (!_macOSFullScreenFocusRecovery.isCurrent(generation) ||
(!allowHiddenLifecycle &&
_macOSLifecycleState == AppLifecycleState.hidden)) {
return;
}
final contextActive =
stateGlobal.isFocused.value && !_isWindowBlur && _isSelectedTab;
// macOS can focus a fullscreen Space without sending PointerEnter. Native
// window focus is authoritative here; a later blur cancels this generation
// before an off-screen window can restore input.
final shouldInferPointerInside = !_cursorOverImage.value &&
allowHiddenLifecycle &&
stateGlobal.fullscreen.isTrue &&
contextActive;
final canRestore = contextActive &&
_blockableOverlayState.middleBlocked.isFalse &&
(_cursorOverImage.value || shouldInferPointerInside);
if (!_macOSFullScreenFocusRecovery.consume(generation)) return;
if (!canRestore) {
// Consuming recovery here requires a later pointer/window/tab event.
return;
}
if (shouldInferPointerInside) {
_cursorOverImage.value = true;
}
_macOSLocalFocusLost = false;
stateGlobal.getInputSource(force: true);
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
}
void _scheduleMacOSKeyboardAfterFullScreen({
required int generation,
bool allowHiddenLifecycle = false,
}) {
// Fullscreen can deliver FocusNode loss after its callback; wait for frame
// completion and then advance one event-loop turn before restoring.
WidgetsBinding.instance.addPostFrameCallback((_) {
Timer.run(() {
if (mounted) {
_restoreMacOSKeyboardAfterFullScreen(
generation: generation,
allowHiddenLifecycle: allowHiddenLifecycle,
);
}
});
});
WidgetsBinding.instance.ensureVisualUpdate();
}
void _queueMacOSKeyboardAfterFullScreen({
bool allowHiddenLifecycle = false,
}) {
final generation = _macOSFullScreenFocusRecovery.queue();
if (_macOSLifecycleState == AppLifecycleState.paused ||
_macOSLifecycleState == AppLifecycleState.detached) {
_macOSFullScreenFocusRecovery.cancel();
return;
}
_scheduleMacOSKeyboardAfterFullScreen(
generation: generation,
allowHiddenLifecycle: allowHiddenLifecycle,
);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (!isMacOS || _macOSLifecycleState == state) return;
_macOSLifecycleState = state;
if (state == AppLifecycleState.resumed) {
_syncMacOSKeyboardGrab(reassert: true);
} else if (_macOSInputActive) {
_ffi.inputModel.enterOrLeave(false);
_macOSInputActive = false;
}
final generation = _macOSFullScreenFocusRecovery.pendingGeneration;
if (generation == null) return;
if (state == AppLifecycleState.inactive ||
state == AppLifecycleState.resumed) {
_scheduleMacOSKeyboardAfterFullScreen(generation: generation);
} else if (state == AppLifecycleState.paused ||
state == AppLifecycleState.detached) {
_macOSFullScreenFocusRecovery.cancel();
}
}
@override
void onWindowBlur() {
super.onWindowBlur();
// On windows, we use `focus` way to handle keyboard better.
// Now on Linux, there's some rdev issues which will break the input.
// We disable the `focus` way for non-Windows temporarily.
if (isWindows) {
// We disable the `focus` way for Linux temporarily.
if (isWindows || isMacOS) {
_isWindowBlur = true;
}
if (isMacOS) {
_macOSFullScreenFocusRecovery.cancel();
// A blur or Space switch may not emit PointerExit, so cursor state alone
// cannot prevent the old remote surface from reclaiming the keyboard.
_macOSLocalFocusLost = true;
}
if (isWindows) {
// unfocus the primary-focus when the whole window is lost focus,
// and let OS to handle events instead.
_rawKeyFocusNode.unfocus();
}
stateGlobal.isFocused.value = false;
_syncMacOSKeyboardGrab();
// When window loses focus, temporarily release relative mouse mode constraints
// to allow user to interact with other applications normally.
@@ -257,16 +495,41 @@ class _RemotePageState extends State<RemotePage>
void onWindowFocus() {
super.onWindowFocus();
// See [onWindowBlur].
if (isWindows) {
if (isWindows || isMacOS) {
_isWindowBlur = false;
}
if (isMacOS) stateGlobal.getInputSource(force: true);
stateGlobal.isFocused.value = true;
// Normal macOS windows wait for PointerEnter or PointerDown. A focused
// fullscreen Space queues delayed recovery; if this window blurs again, the
// pending recovery is cancelled before native input can reactivate.
// Regression: switch directly between fullscreen remote Spaces without
// moving or clicking; only the newly focused session may receive input.
if (isMacOS &&
stateGlobal.fullscreen.isTrue &&
!_ffi.inputModel.relativeMouseMode.value) {
// Native window focus is authoritative when a secondary engine retains a
// stale hidden lifecycle state after its fullscreen Space becomes visible.
_queueMacOSKeyboardAfterFullScreen(allowHiddenLifecycle: true);
}
// Restore relative mouse mode constraints when window regains focus.
if (_ffi.inputModel.relativeMouseMode.value) {
_rawKeyFocusNode.requestFocus();
if (isMacOS) {
// Native relative mode retains pointer capture and does not emit
// PointerEnter after window focus returns. Restore both latches unless
// a local overlay still owns input.
if (_blockableOverlayState.middleBlocked.isFalse) {
_cursorOverImage.value = true;
_macOSLocalFocusLost = false;
}
} else {
_rawKeyFocusNode.requestFocus();
}
_ffi.inputModel.onWindowFocus();
}
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
}
@override
@@ -327,6 +590,13 @@ class _RemotePageState extends State<RemotePage>
void onWindowMinimize() {
super.onWindowMinimize();
WakelockManager.disable(_uniqueKey);
if (isMacOS) {
_macOSFullScreenFocusRecovery.cancel();
_isWindowBlur = true;
_cursorOverImage.value = false;
stateGlobal.isFocused.value = false;
_syncMacOSKeyboardGrab();
}
// Release cursor constraints when minimized
if (_ffi.inputModel.relativeMouseMode.value) {
_ffi.inputModel.onWindowBlur();
@@ -338,6 +608,7 @@ class _RemotePageState extends State<RemotePage>
super.onWindowEnterFullScreen();
if (isMacOS) {
stateGlobal.setFullscreen(true);
_queueMacOSKeyboardAfterFullScreen();
}
}
@@ -346,6 +617,7 @@ class _RemotePageState extends State<RemotePage>
super.onWindowLeaveFullScreen();
if (isMacOS) {
stateGlobal.setFullscreen(false);
_queueMacOSKeyboardAfterFullScreen();
}
}
@@ -354,6 +626,14 @@ class _RemotePageState extends State<RemotePage>
final closeSession = closeSessionOnDispose.remove(widget.id) ?? true;
// https://github.com/flutter/flutter/issues/64935
if (isMacOS) {
// Tab moves release before transfer to avoid a late retained-session leave.
if (closeSession) {
_releaseMacOSRemoteInput();
}
_tabStateSubscription?.cancel();
WidgetsBinding.instance.removeObserver(this);
}
super.dispose();
debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}");
@@ -368,8 +648,9 @@ class _RemotePageState extends State<RemotePage>
_ffi.inputModel.onRelativeMouseModeDisabled = null;
// Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...).
_ffi.textureModel.onRemotePageDispose(closeSession);
if (closeSession) {
if (closeSession && !isMacOS) {
// ensure we leave this session, this is a double check
// enterOrLeave() is already called previously in _releaseMacOSRemoteInput() for macOS.
_ffi.inputModel.enterOrLeave(false);
}
DesktopMultiWindow.removeListener(this);
@@ -444,6 +725,8 @@ class _RemotePageState extends State<RemotePage>
} else {
_ffi.inputModel.enterOrLeave(false);
}
} else if (isMacOS) {
_onMacOSFocusChange();
}
},
inputModel: _ffi.inputModel,
@@ -549,7 +832,11 @@ class _RemotePageState extends State<RemotePage>
}
// See [onWindowBlur].
if (!isWindows) {
if (isMacOS) {
_macOSLocalFocusLost = false;
stateGlobal.getInputSource(force: true);
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
} else if (!isWindows) {
if (!_rawKeyFocusNode.hasFocus) {
_rawKeyFocusNode.requestFocus();
}
@@ -575,7 +862,9 @@ class _RemotePageState extends State<RemotePage>
}
// See [onWindowBlur].
if (!isWindows) {
if (isMacOS) {
_syncMacOSKeyboardGrab();
} else if (!isWindows) {
_ffi.inputModel.enterOrLeave(false);
}
}
@@ -600,17 +889,29 @@ class _RemotePageState extends State<RemotePage>
onEnter: onEnter,
onExit: onExit,
onPointerDown: (event) {
// A double check for blur status.
// A double check for blur status on Windows and macOS.
// Note: If there's an `onPointerDown` event is triggered, `_isWindowBlur` is expected being false.
// Sometimes the system does not send the necessary focus event to flutter. We should manually
// handle this inconsistent status by setting `_isWindowBlur` to false. So we can
// ensure the grab-key thread is running when our users are clicking the remote canvas.
if (_isWindowBlur) {
if ((isWindows || isMacOS) && _isWindowBlur) {
debugPrint(
"Unexpected status: onPointerDown is triggered while the remote window is in blur status");
_isWindowBlur = false;
}
if (!_rawKeyFocusNode.hasFocus) {
if (isMacOS) {
// Regions without matching enter/exit callbacks cannot safely own
// keyboard state.
if (onEnter == null || onExit == null) return;
if (!stateGlobal.isFocused.value) {
stateGlobal.isFocused.value = true;
}
_cursorOverImage.value = true;
_macOSLocalFocusLost = false;
stateGlobal.getInputSource(force: true);
_syncMacOSKeyboardGrab(
reassert: !isInputSourceFlutter, allowInactiveLifecycle: true);
} else if (!_rawKeyFocusNode.hasFocus) {
_rawKeyFocusNode.requestFocus();
}
},

View File

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

View File

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

View File

@@ -5,8 +5,11 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/widgets/dialog.dart';
import 'package:flutter_hbb/models/input_modifier_utils.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/terminal_model.dart';
import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:xterm/xterm.dart';
import '../../desktop/pages/terminal_connection_manager.dart';
@@ -42,6 +45,11 @@ class _TerminalPageState extends State<TerminalPage>
final GlobalKey _keyboardKey = GlobalKey();
double _keyboardHeight = 0;
late bool _showTerminalExtraKeys;
// Ctrl lock state for virtual keyboard: active key presses are mapped to control codes
bool _ctrlLocked = false;
bool _altLocked = false;
// Row3 expand/collapse state for compact keyboard layout
bool _row3Expanded = false;
// For iOS edge swipe gesture
double _swipeStartX = 0;
double _swipeCurrentX = 0;
@@ -94,6 +102,18 @@ class _TerminalPageState extends State<TerminalPage>
// terminal extra keys bar is unnecessary and disabled.
_showTerminalExtraKeys = !isWebDesktop &&
mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
_terminalModel.isCtrlLocked = () => _ctrlLocked;
_terminalModel.clearCtrlLock = () {
if (_ctrlLocked) setState(() => _ctrlLocked = false);
};
_terminalModel.isAltLocked = () => _altLocked;
_terminalModel.clearAltLock = () {
if (_altLocked) setState(() => _altLocked = false);
};
// Load Row3 expand/collapse state from persistent storage. The raw option
// read keeps Row3 collapsed when no value has been saved yet.
_row3Expanded =
bind.mainGetLocalOption(key: kOptionShowTerminalCtrlKeys) == 'Y';
// Initialize terminal connection
WidgetsBinding.instance.addPostFrameCallback((_) {
_ffi.dialogManager
@@ -148,6 +168,39 @@ class _TerminalPageState extends State<TerminalPage>
return EdgeInsets.only(left: 5.0, right: 5.0, top: topBottom, bottom: topBottom + _sysKeyboardHeight + _keyboardHeight);
}
/// Pastes clipboard text through TerminalModel so keyboard-only modifiers and
/// mobile Enter normalization never alter clipboard data.
Future<void> _pasteClipboardText() async {
final data = await Clipboard.getData(Clipboard.kTextPlain);
final text = data?.text;
if (text == null || !mounted) return;
await _terminalModel.pasteText(text);
if (mounted) {
_terminalModel.terminalController.clearSelection();
}
}
KeyEventResult _handleTerminalKeyEvent(FocusNode _, KeyEvent event) {
final hardwareKeyboard = HardwareKeyboard.instance;
final shouldPaste = shouldHandleTerminalPasteShortcut(
logicalKey: event.logicalKey,
isKeyDown: event is KeyDownEvent,
isKeyRepeat: event is KeyRepeatEvent,
controlPressed: hardwareKeyboard.isControlPressed,
metaPressed: hardwareKeyboard.isMetaPressed,
altPressed: hardwareKeyboard.isAltPressed,
shiftPressed: hardwareKeyboard.isShiftPressed,
modifierLockActive: _ctrlLocked || _altLocked,
);
if (!shouldPaste) return KeyEventResult.ignored;
// Only locked virtual modifiers need interception. Without a lock, keep
// xterm's default hardware paste behavior, including bracketed paste mode.
unawaited(_pasteClipboardText());
return KeyEventResult.handled;
}
@override
Widget build(BuildContext context) {
super.build(context);
@@ -185,6 +238,7 @@ class _TerminalPageState extends State<TerminalPage>
//
// Android works fine without this workaround.
deleteDetection: isIOS,
onKeyEvent: _handleTerminalKeyEvent,
padding: _calculatePadding(heightPx),
onSecondaryTapDown: (details, offset) async {
final selection = _terminalModel.terminalController.selection;
@@ -193,11 +247,7 @@ class _TerminalPageState extends State<TerminalPage>
_terminalModel.terminalController.clearSelection();
await Clipboard.setData(ClipboardData(text: text));
} else {
final data = await Clipboard.getData('text/plain');
final text = data?.text;
if (text != null) {
_terminalModel.terminal.paste(text);
}
await _pasteClipboardText();
}
},
);
@@ -324,66 +374,171 @@ class _TerminalPageState extends State<TerminalPage>
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Row 1 follows the latest reviewed PR layout.
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: _buildKeyboardKeyButtons(terminalKeyboardRow1Keys),
),
// Row 2 ends with the full-width Row3 collapse/expand toggle.
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildKeyButton('Esc'),
const SizedBox(width: 2),
_buildKeyButton('/'),
const SizedBox(width: 2),
_buildKeyButton('|'),
const SizedBox(width: 2),
_buildKeyButton('Home'),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton('End'),
const SizedBox(width: 2),
_buildKeyButton('PgUp'),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildKeyButton('Tab'),
const SizedBox(width: 2),
_buildKeyButton('Ctrl+C'),
const SizedBox(width: 2),
_buildKeyButton('~'),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton('PgDn'),
..._buildKeyboardKeyButtons(terminalKeyboardRow2Keys),
const SizedBox(width: terminalKeyboardKeySpacing),
_buildCollapseButton(),
],
),
// Row 3 restores paging keys and trailing alignment placeholders.
if (_row3Expanded)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
..._buildKeyboardKeyButtons(terminalKeyboardRow3Keys),
for (var i = 0;
i < terminalKeyboardRow3TrailingPlaceholderCount;
i++) ...[
const SizedBox(width: terminalKeyboardKeySpacing),
const SizedBox(width: terminalKeyboardKeyWidth),
],
],
),
],
),
),
);
}
// Ctrl toggle button with highlighted locked state
Widget _buildCtrlKeyButton() {
return _buildModifierToggleButton(
text: 'Ctrl',
semanticsLabel: 'Ctrl',
isLocked: _ctrlLocked,
onPressed: () => setState(() => _ctrlLocked = !_ctrlLocked),
);
}
// Alt toggle button with highlighted locked state
Widget _buildAltKeyButton() {
return _buildModifierToggleButton(
text: 'Alt',
semanticsLabel: 'Alt',
isLocked: _altLocked,
onPressed: () => setState(() => _altLocked = !_altLocked),
);
}
// Collapse/expand toggle button for Row3
void _toggleRow3Expanded() {
final willExpand = !_row3Expanded;
final shouldClearModifiers = shouldClearTerminalModifiersWhenRow3Collapses(
wasExpanded: _row3Expanded,
willExpand: willExpand,
ctrlLocked: _ctrlLocked,
altLocked: _altLocked,
);
setState(() {
_row3Expanded = willExpand;
if (shouldClearModifiers) {
_ctrlLocked = false;
_altLocked = false;
}
});
mainSetLocalBoolOption(kOptionShowTerminalCtrlKeys, willExpand);
// The floating keyboard height changes after Row3 is inserted/removed.
// Re-measure on the next frame so terminal padding uses the new height.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_showTerminalExtraKeys) return;
setState(() {
_updateKeyboardHeight();
});
});
}
Widget _buildCollapseButton() {
return Semantics(
label: translate('Show terminal extra keys'),
toggled: _row3Expanded,
child: ElevatedButton(
onPressed: _toggleRow3Expanded,
child: Text(_row3Expanded ? '' : ''),
style: ElevatedButton.styleFrom(
minimumSize: const Size(terminalKeyboardKeyWidth, 32),
padding: EdgeInsets.zero,
textStyle: const TextStyle(fontSize: 12),
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
/// Builds a fixed-width key sequence with the reviewed 2dp spacing.
List<Widget> _buildKeyboardKeyButtons(List<String> labels) {
return [
for (var i = 0; i < labels.length; i++) ...[
_buildKeyButton(labels[i]),
if (i < labels.length - 1)
const SizedBox(width: terminalKeyboardKeySpacing),
],
];
}
/// Build a modifier toggle button (Ctrl/Alt) with one-shot behavior.
/// When [isLocked] is true, the button highlights in blue and the next
/// single-character input is mapped to its modified equivalent.
Widget _buildModifierToggleButton({
required String text,
required String semanticsLabel,
required bool isLocked,
required VoidCallback onPressed,
}) {
return Semantics(
// Ctrl and Alt are technical key names and intentionally stay unchanged.
label: semanticsLabel,
toggled: isLocked,
child: ElevatedButton(
onPressed: onPressed,
child: Text(text),
style: ElevatedButton.styleFrom(
minimumSize: const Size(terminalKeyboardKeyWidth, 32),
padding: EdgeInsets.zero,
textStyle: const TextStyle(fontSize: 12),
backgroundColor: isLocked
? Colors.blue
: Theme.of(context).colorScheme.surfaceContainerHighest,
foregroundColor: isLocked
? Colors.white
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
Widget _buildKeyButton(String label) {
if (label == 'Ctrl') return _buildCtrlKeyButton();
if (label == 'Alt') return _buildAltKeyButton();
return ElevatedButton(
onPressed: () {
_sendKeyToTerminal(label);
},
child: Text(label),
style: ElevatedButton.styleFrom(
minimumSize: const Size(48, 32),
minimumSize: const Size(terminalKeyboardKeyWidth, 32),
padding: EdgeInsets.zero,
textStyle: const TextStyle(fontSize: 12),
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
}
void _sendKeyToTerminal(String key) {
String? send;
String send;
switch (key) {
case 'Esc':
@@ -427,9 +582,7 @@ class _TerminalPageState extends State<TerminalPage>
break;
}
if (send != null) {
_terminalModel.sendVirtualKey(send);
}
_terminalModel.sendVirtualKey(send);
}
// https://github.com/TerminalStudio/xterm.dart/issues/42#issuecomment-877495472

View File

@@ -0,0 +1,20 @@
/// Reviewed mobile terminal keyboard layout from PR #15532.
///
/// Keeping the key order outside the widget makes the intended layout explicit
/// and prevents behavior fixes from silently moving keys between rows.
const terminalKeyboardRow1Keys = ['Esc', '/', '|', 'Home', '', 'End', r'\'];
const terminalKeyboardRow2Keys = ['Tab', 'Ctrl+C', '~', '', '', ''];
const terminalKeyboardRow3Keys = ['Ctrl', 'Alt', '-', 'PgUp', 'PgDn'];
const terminalKeyboardKeyWidth = 48.0;
const terminalKeyboardKeySpacing = 2.0;
/// Empty 48dp slots keep expanded Row3 aligned with the two rows above it.
const terminalKeyboardRow3TrailingPlaceholderCount = 2;
/// Returns the fixed width occupied by a row of equally sized key slots.
double terminalKeyboardRowWidth(int slotCount) {
if (slotCount <= 0) return 0;
return slotCount * terminalKeyboardKeyWidth +
(slotCount - 1) * terminalKeyboardKeySpacing;
}

View File

@@ -1,4 +1,12 @@
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
/// Identifies where terminal input originated so paste data can bypass all
/// keyboard-only transformations.
enum TerminalInputSource {
keyboard,
paste,
}
/// Returns true when a stale mobile one-shot Shift state should be released
/// by replaying a tracked Shift key-down as a synthesized key-up.
@@ -36,3 +44,147 @@ bool shouldReleaseStaleMobileShift({
}
return true;
}
/// Applies the terminal Ctrl/Alt one-shot modifiers to a single input payload.
///
String applyTerminalInputModifiers(
String data, {
required bool ctrlLocked,
required bool altLocked,
}) {
var result = data;
if (ctrlLocked) {
result = _applyTerminalCtrlModifier(result);
}
if (altLocked) {
result = '\x1B$result';
}
return result;
}
/// Builds the exact payload xterm sends for paste, without applying modifiers.
String terminalPastePayload(String text, {required bool bracketedPasteMode}) {
if (!bracketedPasteMode) {
return text;
}
return '\x1B[200~$text\x1B[201~';
}
/// Returns whether one-shot Ctrl/Alt may transform and consume this input.
///
/// xterm emits terminal control keys as either one control byte or a longer
/// escape sequence. Neither form is ordinary text input, so a pending modifier
/// must survive until the user enters a printable character.
bool shouldApplyTerminalInputModifiers(String data) {
if (data.characters.length != 1) return false;
final codeUnit = data.codeUnitAt(0);
return codeUnit >= 0x20 && codeUnit != 0x7F;
}
/// Builds the payload sent to the remote terminal for keyboard and paste input.
///
/// Keyboard input keeps the mobile Enter workaround and one-shot Ctrl/Alt
/// mapping. Paste input deliberately bypasses both transformations so even a
/// one-character clipboard payload is preserved exactly.
String prepareTerminalInputPayload(
String data, {
required TerminalInputSource source,
required bool isMobileOrWebMobile,
required bool bracketedPasteMode,
required bool ctrlLocked,
required bool altLocked,
}) {
if (source == TerminalInputSource.paste) {
return terminalPastePayload(
data,
bracketedPasteMode: bracketedPasteMode,
);
}
var result = data;
if (isMobileOrWebMobile && result == '\n') {
result = '\r';
}
if ((ctrlLocked || altLocked) && shouldApplyTerminalInputModifiers(result)) {
result = applyTerminalInputModifiers(
result,
ctrlLocked: ctrlLocked,
altLocked: altLocked,
);
}
return result;
}
/// Returns true when a hardware paste shortcut must bypass keyboard modifiers.
///
/// xterm already handles hardware Ctrl/Cmd+V correctly in the common case. Only
/// intercept while a virtual Ctrl/Alt lock is active, because xterm can emit a
/// one-character paste as normal text when bracketed paste mode is disabled.
bool shouldHandleTerminalPasteShortcut({
required LogicalKeyboardKey logicalKey,
required bool isKeyDown,
required bool isKeyRepeat,
required bool controlPressed,
required bool metaPressed,
required bool altPressed,
required bool shiftPressed,
required bool modifierLockActive,
}) {
if (!modifierLockActive) return false;
if (!isKeyDown && !isKeyRepeat) return false;
if (logicalKey != LogicalKeyboardKey.keyV) return false;
if (altPressed || shiftPressed) return false;
return controlPressed != metaPressed;
}
/// Returns true when collapsing Row3 should also clear hidden modifier state.
bool shouldClearTerminalModifiersWhenRow3Collapses({
required bool wasExpanded,
required bool willExpand,
required bool ctrlLocked,
required bool altLocked,
}) {
return wasExpanded && !willExpand && (ctrlLocked || altLocked);
}
String _applyTerminalCtrlModifier(String data) {
// Ctrl mappings are defined only for ASCII scalars. A visible character can
// be multiple scalars (for example, a decomposed accent), so leave those
// graphemes untouched instead of rewriting only their ASCII base letter.
final graphemes = data.characters.toList(growable: false);
if (graphemes.length != 1) {
return data;
}
final runes = graphemes.single.runes.toList(growable: false);
if (runes.length != 1) {
return data;
}
final code = runes.single;
if (code >= 0x61 && code <= 0x7A) {
return String.fromCharCode(code - 0x60);
}
if (code >= 0x41 && code <= 0x5A) {
return String.fromCharCode(code - 0x40);
}
if (code == 0x20) {
return String.fromCharCode(0);
}
if (code == 0x5B) {
return String.fromCharCode(27);
}
if (code == 0x5C) {
return String.fromCharCode(28);
}
if (code == 0x5D) {
return String.fromCharCode(29);
}
if (code == 0x5E) {
return String.fromCharCode(30);
}
if (code == 0x5F || code == 0x2F) {
return String.fromCharCode(31);
}
return data;
}

View File

@@ -7,6 +7,7 @@ import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/main.dart';
import 'package:xterm/xterm.dart';
import 'input_modifier_utils.dart';
import 'model.dart';
import 'platform_model.dart';
@@ -22,7 +23,25 @@ class TerminalModel with ChangeNotifier {
bool _disposed = false;
/// Callback to check whether Ctrl modifier lock is currently active.
/// When active, keyboard input is mapped to control codes (e.g. 'b' → \x02).
bool Function()? isCtrlLocked;
/// Callback to clear Ctrl lock after a key is pressed (one-shot mode).
void Function()? clearCtrlLock;
/// Callback to check whether Alt modifier lock is currently active.
bool Function()? isAltLocked;
/// Callback to clear Alt lock after a key is pressed (one-shot mode).
void Function()? clearAltLock;
final _inputBuffer = <String>[];
/// Exposes buffered input only for lifecycle regression tests.
@visibleForTesting
int get debugBufferedInputCount => _inputBuffer.length;
// Buffer for output data received before terminal view has valid dimensions.
// This prevents NaN errors when writing to terminal before layout is complete.
final _pendingOutputChunks = <String>[];
@@ -42,6 +61,10 @@ class TerminalModel with ChangeNotifier {
VoidCallback? onClosed;
Future<void> _handleInput(String data) async {
// xterm can complete asynchronous input after the Flutter page has gone
// away. Stop before reading or clearing widget-owned modifier state.
if (_disposed) return;
// Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a
// real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'.
// - Peer Windows: '\r' works, '\n' is just a newline.
@@ -49,13 +72,44 @@ class TerminalModel with ChangeNotifier {
// (readline, prompt_toolkit, vim, TUI frameworks) expect '\r'.
// - Peer macOS: same as Linux, raw-mode apps expect '\r'
// (https://github.com/rustdesk/rustdesk/issues/14907).
// So on mobile / web-mobile, always normalize a lone '\n' to '\r'.
// We deliberately do not touch multi-character payloads (e.g. pasted text)
// so embedded newlines in pasted content are preserved.
final isMobileOrWebMobile = (isMobile || (isWeb && !isWebDesktop));
if (isMobileOrWebMobile && data == '\n') {
data = '\r';
// So on mobile / web-mobile, normalize the original lone '\n' to '\r'
// before modifier mappings. This keeps Ctrl+J mapped to LF instead of
// having the generated control code rewritten to CR afterward.
// Multi-character keyboard payloads, such as terminal escape sequences,
// remain unchanged. Paste input follows a separate preprocessing path.
final ctrlLocked = isCtrlLocked?.call() ?? false;
final altLocked = isAltLocked?.call() ?? false;
final modifiersActive = ctrlLocked || altLocked;
// Use the same predicate for transformation and consumption. Control keys
// and escape sequences must not silently consume a pending one-shot lock.
final shouldConsumeModifiers =
modifiersActive && shouldApplyTerminalInputModifiers(data);
data = prepareTerminalInputPayload(
data,
// IME soft-keyboard paste prompts currently arrive from xterm as normal
// text input with no paste-origin metadata. Keep them on the keyboard path;
// clipboard-content heuristics can misclassify ordinary typing.
source: TerminalInputSource.keyboard,
isMobileOrWebMobile: isMobile || (isWeb && !isWebDesktop),
bracketedPasteMode: terminal.bracketedPasteMode,
ctrlLocked: ctrlLocked,
altLocked: altLocked,
);
if (shouldConsumeModifiers) {
if (ctrlLocked) clearCtrlLock?.call();
if (altLocked) clearAltLock?.call();
}
return _sendInputPayload(data);
}
/// Sends an already prepared payload without applying keyboard semantics.
/// Both normal input and paste use this transport path after their source-
/// specific preprocessing has completed.
Future<void> _sendInputPayload(String data) async {
// Clipboard reads and native sends may complete after the terminal page has
// closed. Never send or re-buffer input once this model is disposed.
if (_disposed) return;
if (_terminalOpened) {
// Send user input to remote terminal
try {
@@ -176,6 +230,18 @@ class TerminalModel with ChangeNotifier {
return _handleInput(data);
}
Future<void> pasteText(String data) async {
final payload = prepareTerminalInputPayload(
data,
source: TerminalInputSource.paste,
isMobileOrWebMobile: false,
bracketedPasteMode: terminal.bracketedPasteMode,
ctrlLocked: false,
altLocked: false,
);
return _sendInputPayload(payload);
}
Future<void> closeTerminal() async {
if (_terminalOpened) {
try {
@@ -516,6 +582,14 @@ class TerminalModel with ChangeNotifier {
void dispose() {
if (_disposed) return;
_disposed = true;
terminal.onOutput = null;
terminal.onResize = null;
isCtrlLocked = null;
clearCtrlLock = null;
isAltLocked = null;
clearAltLock = null;
onResizeExternal = null;
onClosed = null;
// Clear buffers to free memory
_inputBuffer.clear();
_pendingOutputChunks.clear();

View File

@@ -122,4 +122,394 @@ void main() {
);
});
});
group('shouldApplyTerminalInputModifiers', () {
test('accepts ordinary single-character keyboard input', () {
expect(shouldApplyTerminalInputModifiers('a'), isTrue);
expect(shouldApplyTerminalInputModifiers(' '), isTrue);
expect(shouldApplyTerminalInputModifiers('/'), isTrue);
});
test('accepts supplementary-plane single-character keyboard input', () {
expect(shouldApplyTerminalInputModifiers('😀'), isTrue);
});
test('rejects terminal control bytes and multi-character sequences', () {
for (final input in ['\x00', '\x03', '\t', '\n', '\r', '\x1B', '\x7F']) {
expect(
shouldApplyTerminalInputModifiers(input),
isFalse,
reason: '${input.codeUnits} must not consume a one-shot modifier',
);
}
expect(shouldApplyTerminalInputModifiers('\x1B[A'), isFalse);
});
});
group('applyTerminalInputModifiers', () {
test('keeps decomposed graphemes intact under Ctrl', () {
const decomposedEAcute = 'e\u0301';
expect(
applyTerminalInputModifiers(
decomposedEAcute,
ctrlLocked: true,
altLocked: false,
),
decomposedEAcute,
);
});
test('keeps non-ASCII graphemes intact under Ctrl', () {
for (final input in ['é', '😀']) {
expect(
applyTerminalInputModifiers(
input,
ctrlLocked: true,
altLocked: false,
),
input,
);
}
});
test('maps Ctrl underscore to unit separator', () {
expect(
applyTerminalInputModifiers(
'_',
ctrlLocked: true,
altLocked: false,
),
'\x1F',
);
});
test('maps the complete Ctrl symbol range', () {
const mappings = {
'[': '\x1B',
r'\': '\x1C',
']': '\x1D',
'^': '\x1E',
'_': '\x1F',
'/': '\x1F',
};
for (final entry in mappings.entries) {
expect(
applyTerminalInputModifiers(
entry.key,
ctrlLocked: true,
altLocked: false,
),
entry.value,
reason: 'Ctrl+${entry.key} should map to ${entry.value.codeUnits}',
);
}
});
test('applies Ctrl before Alt for combined modifiers', () {
expect(
applyTerminalInputModifiers(
'b',
ctrlLocked: true,
altLocked: true,
),
'\x1B\x02',
);
});
});
group('terminalPastePayload', () {
test('wraps paste text when bracketed paste mode is active', () {
expect(
terminalPastePayload('d', bracketedPasteMode: true),
'\x1B[200~d\x1B[201~',
);
});
test('keeps a lone newline unchanged when bracketed paste is disabled', () {
expect(
terminalPastePayload('\n', bracketedPasteMode: false),
'\n',
);
});
});
group('prepareTerminalInputPayload', () {
test('normalizes a mobile keyboard Enter to carriage return', () {
expect(
prepareTerminalInputPayload(
'\n',
source: TerminalInputSource.keyboard,
isMobileOrWebMobile: true,
bracketedPasteMode: false,
ctrlLocked: false,
altLocked: false,
),
'\r',
);
});
test('keeps Ctrl+J as line feed on mobile', () {
expect(
prepareTerminalInputPayload(
'j',
source: TerminalInputSource.keyboard,
isMobileOrWebMobile: true,
bracketedPasteMode: false,
ctrlLocked: true,
altLocked: false,
),
'\n',
);
});
test('does not apply Alt to a terminal control byte', () {
expect(
prepareTerminalInputPayload(
'\x1B',
source: TerminalInputSource.keyboard,
isMobileOrWebMobile: true,
bracketedPasteMode: false,
ctrlLocked: false,
altLocked: true,
),
'\x1B',
);
});
test('keeps large keyboard payloads unchanged when modifiers are inactive',
() {
final payload = 'd' * (1024 * 1024);
expect(
prepareTerminalInputPayload(
payload,
source: TerminalInputSource.keyboard,
isMobileOrWebMobile: false,
bracketedPasteMode: false,
ctrlLocked: false,
altLocked: false,
),
payload,
);
});
test('keeps decomposed graphemes intact with locked keyboard modifiers',
() {
const decomposedEAcute = 'e\u0301';
expect(
prepareTerminalInputPayload(
decomposedEAcute,
source: TerminalInputSource.keyboard,
isMobileOrWebMobile: true,
bracketedPasteMode: false,
ctrlLocked: true,
altLocked: false,
),
decomposedEAcute,
);
});
test('preserves a lone pasted newline when modifiers are locked', () {
expect(
prepareTerminalInputPayload(
'\n',
source: TerminalInputSource.paste,
isMobileOrWebMobile: true,
bracketedPasteMode: false,
ctrlLocked: true,
altLocked: true,
),
'\n',
);
});
test('wraps paste without applying locked modifiers', () {
expect(
prepareTerminalInputPayload(
'd',
source: TerminalInputSource.paste,
isMobileOrWebMobile: true,
bracketedPasteMode: true,
ctrlLocked: true,
altLocked: true,
),
'\x1B[200~d\x1B[201~',
);
});
});
group('shouldHandleTerminalPasteShortcut', () {
test(
'keeps default xterm paste behavior when virtual modifiers are inactive',
() {
expect(
shouldHandleTerminalPasteShortcut(
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: true,
isKeyRepeat: false,
controlPressed: true,
metaPressed: false,
altPressed: false,
shiftPressed: false,
modifierLockActive: false,
),
isFalse,
);
});
test('handles Ctrl+V and Meta+V when a virtual modifier lock is active',
() {
expect(
shouldHandleTerminalPasteShortcut(
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: true,
isKeyRepeat: false,
controlPressed: true,
metaPressed: false,
altPressed: false,
shiftPressed: false,
modifierLockActive: true,
),
isTrue,
);
expect(
shouldHandleTerminalPasteShortcut(
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: true,
isKeyRepeat: false,
controlPressed: false,
metaPressed: true,
altPressed: false,
shiftPressed: false,
modifierLockActive: true,
),
isTrue,
);
});
test('handles paste shortcut repeats while a virtual lock is active', () {
expect(
shouldHandleTerminalPasteShortcut(
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: false,
isKeyRepeat: true,
controlPressed: true,
metaPressed: false,
altPressed: false,
shiftPressed: false,
modifierLockActive: true,
),
isTrue,
);
});
test('ignores key-up and unmodified V events', () {
expect(
shouldHandleTerminalPasteShortcut(
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: false,
isKeyRepeat: false,
controlPressed: true,
metaPressed: false,
altPressed: false,
shiftPressed: false,
modifierLockActive: true,
),
isFalse,
);
expect(
shouldHandleTerminalPasteShortcut(
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: true,
isKeyRepeat: false,
controlPressed: false,
metaPressed: false,
altPressed: false,
shiftPressed: false,
modifierLockActive: true,
),
isFalse,
);
});
test('ignores paste shortcuts with extra modifiers', () {
for (final state in [
(control: true, meta: false, alt: true, shift: false),
(control: true, meta: false, alt: false, shift: true),
(control: false, meta: true, alt: false, shift: true),
(control: true, meta: true, alt: false, shift: false),
]) {
expect(
shouldHandleTerminalPasteShortcut(
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: true,
isKeyRepeat: false,
controlPressed: state.control,
metaPressed: state.meta,
altPressed: state.alt,
shiftPressed: state.shift,
modifierLockActive: true,
),
isFalse,
);
}
});
test('ignores non-V key events', () {
expect(
shouldHandleTerminalPasteShortcut(
logicalKey: LogicalKeyboardKey.keyC,
isKeyDown: true,
isKeyRepeat: false,
controlPressed: true,
metaPressed: false,
altPressed: false,
shiftPressed: false,
modifierLockActive: true,
),
isFalse,
);
});
});
group('shouldClearTerminalModifiersWhenRow3Collapses', () {
test('clears visible modifier state when expanded row is collapsed', () {
expect(
shouldClearTerminalModifiersWhenRow3Collapses(
wasExpanded: true,
willExpand: false,
ctrlLocked: true,
altLocked: false,
),
isTrue,
);
});
test('does not clear modifiers when row expands', () {
expect(
shouldClearTerminalModifiersWhenRow3Collapses(
wasExpanded: false,
willExpand: true,
ctrlLocked: true,
altLocked: true,
),
isFalse,
);
});
test('clears Alt state when expanded row is collapsed', () {
expect(
shouldClearTerminalModifiersWhenRow3Collapses(
wasExpanded: true,
willExpand: false,
ctrlLocked: false,
altLocked: true,
),
isTrue,
);
});
});
}

View File

@@ -0,0 +1,40 @@
import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('mobile terminal keyboard layout', () {
test('keeps the latest key order from the reviewed PR layout', () {
expect(
terminalKeyboardRow1Keys,
['Esc', '/', '|', 'Home', '', 'End', r'\'],
);
expect(
terminalKeyboardRow2Keys,
['Tab', 'Ctrl+C', '~', '', '', ''],
);
expect(
terminalKeyboardRow3Keys,
['Ctrl', 'Alt', '-', 'PgUp', 'PgDn'],
);
});
test('keeps two trailing Row3 placeholders for row alignment', () {
expect(terminalKeyboardRow3TrailingPlaceholderCount, 2);
});
test('keeps every expanded row aligned at 348dp', () {
final rowWidths = [
terminalKeyboardRowWidth(terminalKeyboardRow1Keys.length),
terminalKeyboardRowWidth(terminalKeyboardRow2Keys.length + 1),
terminalKeyboardRowWidth(
terminalKeyboardRow3Keys.length +
terminalKeyboardRow3TrailingPlaceholderCount,
),
];
expect(terminalKeyboardKeyWidth, 48);
expect(terminalKeyboardKeySpacing, 2);
expect(rowWidths, everyElement(348));
});
});
}

View File

@@ -0,0 +1,51 @@
import 'dart:async';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/terminal_model.dart';
import 'package:flutter_test/flutter_test.dart';
class _FakeFFI implements FFI {
@override
String id = 'test-peer';
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
test('ignores paste that completes after the terminal model is disposed',
() async {
final model = TerminalModel(_FakeFFI());
final delayedClipboardText = Completer<String>();
// This mirrors Ctrl/Cmd+V: clipboard access starts first, then the page and
// model are disposed before the asynchronous read supplies its text.
final paste = delayedClipboardText.future.then(model.pasteText);
model.dispose();
delayedClipboardText.complete('late clipboard text');
await paste;
expect(model.debugBufferedInputCount, 0);
});
test('ignores terminal text input after the terminal model is disposed', () {
final model = TerminalModel(_FakeFFI());
var checkedCtrlLock = false;
var clearedCtrlLock = false;
model.isCtrlLocked = () {
checkedCtrlLock = true;
return true;
};
model.clearCtrlLock = () {
clearedCtrlLock = true;
};
model.dispose();
model.terminal.textInput('d');
expect(checkedCtrlLock, isFalse);
expect(clearedCtrlLock, isFalse);
expect(model.debugBufferedInputCount, 0);
});
}

View File

@@ -14,6 +14,7 @@
typedef char** (*FUNC_RUSTDESK_CORE_MAIN)(int*);
typedef void (*FUNC_RUSTDESK_FREE_ARGS)( char**, int);
typedef int (*FUNC_RUSTDESK_GET_APP_NAME)(wchar_t*, int);
typedef int (*FUNC_RUSTDESK_IS_DISABLE_INSTALLATION)();
/// Note: `--server`, `--service` are already handled in [core_main.rs].
const std::vector<std::string> parameters_white_list = {"--install", "--cm"};
@@ -62,6 +63,22 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
}
std::vector<std::string> rust_args(c_args, c_args + args_len);
free_c_args(c_args, args_len);
FUNC_RUSTDESK_IS_DISABLE_INSTALLATION rustdesk_is_disable_installation =
(FUNC_RUSTDESK_IS_DISABLE_INSTALLATION)GetProcAddress(hInstance, "rustdesk_is_disable_installation");
bool is_disable_installation =
rustdesk_is_disable_installation && rustdesk_is_disable_installation() != 0;
const auto installParam = std::string("--install");
// Flutter reads the original process command line, not only rust_args, so
// remove the `--install` injected by the portable wrapper here as well. This
// also lets `no-install.exe` continue as a portable app when installation is
// disabled. See: https://github.com/rustdesk/rustdesk-server-pro/issues/991#issuecomment-4978376890
if (is_disable_installation) {
command_line_arguments.erase(
std::remove(command_line_arguments.begin(),
command_line_arguments.end(),
installParam),
command_line_arguments.end());
}
std::wstring app_name = L"RustDesk";
FUNC_RUSTDESK_GET_APP_NAME get_rustdesk_app_name = (FUNC_RUSTDESK_GET_APP_NAME)GetProcAddress(hInstance, "get_rustdesk_app_name");
@@ -118,7 +135,6 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
is_cm_page = true;
}
bool is_install_page = false;
auto installParam = std::string("--install");
if (!command_line_arguments.empty() && command_line_arguments.front().compare(0, installParam.size(), installParam.c_str()) == 0) {
is_install_page = true;
}