Compare commits

...

3 Commits

Author SHA1 Message Date
fufesou
483fe80308 fix(terminal): fix new tab auto-focus and NaN error on data before layout (#14357)
- Fix new tab not auto-focusing: add FocusNode to TerminalView and
  request focus when tab is selected via tab state listener
- Fix NaN error when data arrives before terminal view layout: buffer
  output data until terminal view has valid dimensions, flush on first
  valid resize callback

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-02-20 14:44:25 +08:00
fufesou
34ceeac36e fix(terminal): fix tabKey parsing for peerIds containing underscores (#14354)
Terminal tab keys use the format "peerId_terminalId". The previous code
used split('_')[0] or startsWith('$peerId_') to extract the peerId,
which breaks when the peerId itself contains underscores.

This can happen in two scenarios:
- Hostname-based ID: when OPTION_ALLOW_HOSTNAME_AS_ID is enabled, the
  peerId is derived from the system hostname, which commonly contains
  underscores (e.g. "my_dev_machine").
- Custom ID: the validation regex ^[a-zA-Z][\w-]{5,15}$ allows
  underscores since \w matches [a-zA-Z0-9_], so IDs like "my_dev_01"
  are valid.

Fix all three parsing sites in terminal_tab_page.dart to use
lastIndexOf('_'), which is safe because terminalId is always a plain
integer with no underscores.
2026-02-19 23:45:06 +08:00
cui
20f11018ce fix: lte should be lt like in linux.rs (#14344) 2026-02-19 22:24:32 +08:00
4 changed files with 138 additions and 16 deletions

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common.dart';
@@ -15,6 +16,7 @@ class TerminalPage extends StatefulWidget {
required this.tabController,
required this.isSharedPassword,
required this.terminalId,
required this.tabKey,
this.forceRelay,
this.connToken,
}) : super(key: key);
@@ -25,6 +27,8 @@ 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);
FFI get ffi => (_lastState.value! as _TerminalPageState)._ffi;
@@ -42,11 +46,16 @@ class _TerminalPageState extends State<TerminalPage>
late FFI _ffi;
late TerminalModel _terminalModel;
double? _cellHeight;
final FocusNode _terminalFocusNode = FocusNode(canRequestFocus: false);
StreamSubscription<DesktopTabState>? _tabStateSubscription;
@override
void initState() {
super.initState();
// Listen for tab selection changes to request focus
_tabStateSubscription = widget.tabController.state.listen(_onTabStateChanged);
// Use shared FFI instance from connection manager
_ffi = TerminalConnectionManager.getConnection(
peerId: widget.id,
@@ -64,6 +73,13 @@ class _TerminalPageState extends State<TerminalPage>
_terminalModel.onResizeExternal = (w, h, pw, ph) {
_cellHeight = ph * 1.0;
// Enable focus once terminal has valid dimensions (first valid resize)
if (!_terminalFocusNode.canRequestFocus && w > 0 && h > 0) {
_terminalFocusNode.canRequestFocus = true;
// Auto-focus if this tab is currently selected
_requestFocusIfSelected();
}
// Schedule the setState for the next frame
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
@@ -99,14 +115,42 @@ class _TerminalPageState extends State<TerminalPage>
@override
void dispose() {
// Cancel tab state subscription to prevent memory leak
_tabStateSubscription?.cancel();
// Unregister terminal model from FFI
_ffi.unregisterTerminalModel(widget.terminalId);
_terminalModel.dispose();
_terminalFocusNode.dispose();
// Release connection reference instead of closing directly
TerminalConnectionManager.releaseConnection(widget.id);
super.dispose();
}
void _onTabStateChanged(DesktopTabState state) {
// Check if this tab is now selected and request focus
if (state.selected >= 0 && state.selected < state.tabs.length) {
final selectedTab = state.tabs[state.selected];
if (selectedTab.key == widget.tabKey && mounted) {
_requestFocusIfSelected();
}
}
}
void _requestFocusIfSelected() {
if (!mounted || !_terminalFocusNode.canRequestFocus) return;
// Use post-frame callback to ensure widget is fully laid out in focus tree
WidgetsBinding.instance.addPostFrameCallback((_) {
// Re-check conditions after frame: mounted, focusable, still selected, not already focused
if (!mounted || !_terminalFocusNode.canRequestFocus || _terminalFocusNode.hasFocus) return;
final state = widget.tabController.state.value;
if (state.selected >= 0 && state.selected < state.tabs.length) {
if (state.tabs[state.selected].key == widget.tabKey) {
_terminalFocusNode.requestFocus();
}
}
});
}
// This method ensures that the number of visible rows is an integer by computing the
// 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.
@@ -131,7 +175,9 @@ class _TerminalPageState extends State<TerminalPage>
return TerminalView(
_terminalModel.terminal,
controller: _terminalModel.terminalController,
autofocus: true,
focusNode: _terminalFocusNode,
// Note: autofocus is not used here because focus is managed manually
// via _onTabStateChanged() to handle tab switching properly.
backgroundOpacity: 0.7,
padding: _calculatePadding(heightPx),
onSecondaryTapDown: (details, offset) async {

View File

@@ -92,6 +92,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
key: ValueKey(tabKey),
id: peerId,
terminalId: terminalId,
tabKey: tabKey,
password: password,
isSharedPassword: isSharedPassword,
tabController: tabController,
@@ -194,7 +195,10 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
final currentTab = tabController.state.value.selectedTabInfo;
assert(call.arguments is String,
"Expected String arguments for kWindowEventActiveSession, got ${call.arguments.runtimeType}");
if (currentTab.key.startsWith(call.arguments)) {
// Use lastIndexOf to handle peerIds containing underscores
final lastUnderscore = currentTab.key.lastIndexOf('_');
if (lastUnderscore > 0 &&
currentTab.key.substring(0, lastUnderscore) == call.arguments) {
windowOnTop(windowId());
return true;
}
@@ -329,7 +333,10 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
void _addNewTerminal(String peerId, {int? terminalId}) {
// Find first tab for this peer to get connection parameters
final firstTab = tabController.state.value.tabs.firstWhere(
(tab) => tab.key.startsWith('$peerId\_'),
(tab) {
final last = tab.key.lastIndexOf('_');
return last > 0 && tab.key.substring(0, last) == peerId;
},
);
if (firstTab.page is TerminalPage) {
final page = firstTab.page as TerminalPage;
@@ -350,9 +357,10 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
void _addNewTerminalForCurrentPeer({int? terminalId}) {
final currentTab = tabController.state.value.selectedTabInfo;
final parts = currentTab.key.split('_');
if (parts.isNotEmpty) {
final peerId = parts[0];
final tabKey = currentTab.key;
final lastUnderscore = tabKey.lastIndexOf('_');
if (lastUnderscore > 0) {
final peerId = tabKey.substring(0, lastUnderscore);
_addNewTerminal(peerId, terminalId: terminalId);
}
}
@@ -369,9 +377,10 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
labelGetter: DesktopTab.tablabelGetter,
tabMenuBuilder: (key) {
// Extract peerId from tab key (format: "peerId_terminalId")
final parts = key.split('_');
if (parts.isEmpty) return Container();
final peerId = parts[0];
// Use lastIndexOf to handle peerIds containing underscores
final lastUnderscore = key.lastIndexOf('_');
if (lastUnderscore <= 0) return Container();
final peerId = key.substring(0, lastUnderscore);
return _tabMenuBuilder(peerId, () {});
},
));

View File

@@ -24,6 +24,13 @@ class TerminalModel with ChangeNotifier {
bool _disposed = false;
final _inputBuffer = <String>[];
// 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>[];
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 get isPeerWindows => parent.ffiModel.pi.platform == kPeerPlatformWindows;
@@ -74,6 +81,12 @@ class TerminalModel with ChangeNotifier {
// This piece of code must be placed before the conditional check in order to initialize properly.
onResizeExternal?.call(w, h, pw, ph);
// 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) {
_markViewReady();
}
if (_terminalOpened) {
// Notify remote terminal of resize
try {
@@ -141,7 +154,7 @@ class TerminalModel with ChangeNotifier {
debugPrint('[TerminalModel] Error calling sessionOpenTerminal: $e');
// Optionally show error to user
if (e is TimeoutException) {
terminal.write('Failed to open terminal: Connection timeout\r\n');
_writeToTerminal('Failed to open terminal: Connection timeout\r\n');
}
}
}
@@ -283,7 +296,7 @@ class TerminalModel with ChangeNotifier {
}));
}
} else {
terminal.write('Failed to open terminal: $message\r\n');
_writeToTerminal('Failed to open terminal: $message\r\n');
}
}
@@ -327,29 +340,83 @@ class TerminalModel with ChangeNotifier {
return;
}
terminal.write(text);
_writeToTerminal(text);
} catch (e) {
debugPrint('[TerminalModel] Failed to process terminal data: $e');
}
}
}
/// 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) {
if (!_terminalViewReady) {
// If a single chunk exceeds the cap, keep only its tail.
// Note: truncation may split a multi-byte ANSI escape sequence,
// which can cause a brief visual glitch on flush. This is acceptable
// because it only affects the pre-layout buffering window and the
// terminal will self-correct on subsequent output.
if (text.length >= _kMaxOutputBufferChars) {
final truncated =
text.substring(text.length - _kMaxOutputBufferChars);
_pendingOutputChunks
..clear()
..add(truncated);
_pendingOutputSize = truncated.length;
} else {
_pendingOutputChunks.add(text);
_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);
_pendingOutputSize -= removed.length;
}
}
return;
}
terminal.write(text);
}
void _flushOutputBuffer() {
if (_pendingOutputChunks.isEmpty) return;
debugPrint(
'[TerminalModel] Flushing $_pendingOutputSize buffered chars (${_pendingOutputChunks.length} chunks)');
for (final chunk in _pendingOutputChunks) {
terminal.write(chunk);
}
_pendingOutputChunks.clear();
_pendingOutputSize = 0;
}
/// Mark terminal view as ready and flush buffered output.
void _markViewReady() {
if (_terminalViewReady) return;
_terminalViewReady = true;
_flushOutputBuffer();
}
void _handleTerminalClosed(Map<String, dynamic> evt) {
final int exitCode = evt['exit_code'] ?? 0;
terminal.write('\r\nTerminal closed with exit code: $exitCode\r\n');
_writeToTerminal('\r\nTerminal closed with exit code: $exitCode\r\n');
_terminalOpened = false;
notifyListeners();
}
void _handleTerminalError(Map<String, dynamic> evt) {
final String message = evt['message'] ?? 'Unknown error';
terminal.write('\r\nTerminal error: $message\r\n');
_writeToTerminal('\r\nTerminal error: $message\r\n');
}
@override
void dispose() {
if (_disposed) return;
_disposed = true;
// Clear buffers to free memory
_inputBuffer.clear();
_pendingOutputChunks.clear();
_pendingOutputSize = 0;
// Terminal cleanup is handled server-side when service closes
super.dispose();
}

View File

@@ -107,9 +107,9 @@ pub fn get_focused_display(displays: Vec<DisplayInfo>) -> Option<usize> {
let center_x = rect.left + (rect.right - rect.left) / 2;
let center_y = rect.top + (rect.bottom - rect.top) / 2;
center_x >= display.x
&& center_x <= display.x + display.width
&& center_x < display.x + display.width
&& center_y >= display.y
&& center_y <= display.y + display.height
&& center_y < display.y + display.height
})
}
}