mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-03-07 20:30:08 +03:00
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>
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_hbb/common.dart';
|
import 'package:flutter_hbb/common.dart';
|
||||||
@@ -15,6 +16,7 @@ class TerminalPage extends StatefulWidget {
|
|||||||
required this.tabController,
|
required this.tabController,
|
||||||
required this.isSharedPassword,
|
required this.isSharedPassword,
|
||||||
required this.terminalId,
|
required this.terminalId,
|
||||||
|
required this.tabKey,
|
||||||
this.forceRelay,
|
this.forceRelay,
|
||||||
this.connToken,
|
this.connToken,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
@@ -25,6 +27,8 @@ class TerminalPage extends StatefulWidget {
|
|||||||
final bool? isSharedPassword;
|
final bool? isSharedPassword;
|
||||||
final String? connToken;
|
final String? connToken;
|
||||||
final int terminalId;
|
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);
|
final SimpleWrapper<State<TerminalPage>?> _lastState = SimpleWrapper(null);
|
||||||
|
|
||||||
FFI get ffi => (_lastState.value! as _TerminalPageState)._ffi;
|
FFI get ffi => (_lastState.value! as _TerminalPageState)._ffi;
|
||||||
@@ -42,11 +46,16 @@ class _TerminalPageState extends State<TerminalPage>
|
|||||||
late FFI _ffi;
|
late FFI _ffi;
|
||||||
late TerminalModel _terminalModel;
|
late TerminalModel _terminalModel;
|
||||||
double? _cellHeight;
|
double? _cellHeight;
|
||||||
|
final FocusNode _terminalFocusNode = FocusNode(canRequestFocus: false);
|
||||||
|
StreamSubscription<DesktopTabState>? _tabStateSubscription;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
|
// Listen for tab selection changes to request focus
|
||||||
|
_tabStateSubscription = widget.tabController.state.listen(_onTabStateChanged);
|
||||||
|
|
||||||
// Use shared FFI instance from connection manager
|
// Use shared FFI instance from connection manager
|
||||||
_ffi = TerminalConnectionManager.getConnection(
|
_ffi = TerminalConnectionManager.getConnection(
|
||||||
peerId: widget.id,
|
peerId: widget.id,
|
||||||
@@ -64,6 +73,13 @@ class _TerminalPageState extends State<TerminalPage>
|
|||||||
_terminalModel.onResizeExternal = (w, h, pw, ph) {
|
_terminalModel.onResizeExternal = (w, h, pw, ph) {
|
||||||
_cellHeight = ph * 1.0;
|
_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
|
// Schedule the setState for the next frame
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -99,14 +115,42 @@ class _TerminalPageState extends State<TerminalPage>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
// Cancel tab state subscription to prevent memory leak
|
||||||
|
_tabStateSubscription?.cancel();
|
||||||
// Unregister terminal model from FFI
|
// Unregister terminal model from FFI
|
||||||
_ffi.unregisterTerminalModel(widget.terminalId);
|
_ffi.unregisterTerminalModel(widget.terminalId);
|
||||||
_terminalModel.dispose();
|
_terminalModel.dispose();
|
||||||
|
_terminalFocusNode.dispose();
|
||||||
// Release connection reference instead of closing directly
|
// Release connection reference instead of closing directly
|
||||||
TerminalConnectionManager.releaseConnection(widget.id);
|
TerminalConnectionManager.releaseConnection(widget.id);
|
||||||
super.dispose();
|
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
|
// 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
|
// 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.
|
// terminal row (`_cellHeight`) and distributing it evenly as top and bottom padding.
|
||||||
@@ -131,7 +175,9 @@ class _TerminalPageState extends State<TerminalPage>
|
|||||||
return TerminalView(
|
return TerminalView(
|
||||||
_terminalModel.terminal,
|
_terminalModel.terminal,
|
||||||
controller: _terminalModel.terminalController,
|
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,
|
backgroundOpacity: 0.7,
|
||||||
padding: _calculatePadding(heightPx),
|
padding: _calculatePadding(heightPx),
|
||||||
onSecondaryTapDown: (details, offset) async {
|
onSecondaryTapDown: (details, offset) async {
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
key: ValueKey(tabKey),
|
key: ValueKey(tabKey),
|
||||||
id: peerId,
|
id: peerId,
|
||||||
terminalId: terminalId,
|
terminalId: terminalId,
|
||||||
|
tabKey: tabKey,
|
||||||
password: password,
|
password: password,
|
||||||
isSharedPassword: isSharedPassword,
|
isSharedPassword: isSharedPassword,
|
||||||
tabController: tabController,
|
tabController: tabController,
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ class TerminalModel with ChangeNotifier {
|
|||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
|
||||||
final _inputBuffer = <String>[];
|
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;
|
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.
|
// This piece of code must be placed before the conditional check in order to initialize properly.
|
||||||
onResizeExternal?.call(w, h, pw, ph);
|
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) {
|
if (_terminalOpened) {
|
||||||
// Notify remote terminal of resize
|
// Notify remote terminal of resize
|
||||||
try {
|
try {
|
||||||
@@ -141,7 +154,7 @@ class TerminalModel with ChangeNotifier {
|
|||||||
debugPrint('[TerminalModel] Error calling sessionOpenTerminal: $e');
|
debugPrint('[TerminalModel] Error calling sessionOpenTerminal: $e');
|
||||||
// Optionally show error to user
|
// Optionally show error to user
|
||||||
if (e is TimeoutException) {
|
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 {
|
} 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
terminal.write(text);
|
_writeToTerminal(text);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[TerminalModel] Failed to process terminal data: $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) {
|
void _handleTerminalClosed(Map<String, dynamic> evt) {
|
||||||
final int exitCode = evt['exit_code'] ?? 0;
|
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;
|
_terminalOpened = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleTerminalError(Map<String, dynamic> evt) {
|
void _handleTerminalError(Map<String, dynamic> evt) {
|
||||||
final String message = evt['message'] ?? 'Unknown error';
|
final String message = evt['message'] ?? 'Unknown error';
|
||||||
terminal.write('\r\nTerminal error: $message\r\n');
|
_writeToTerminal('\r\nTerminal error: $message\r\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
|
// Clear buffers to free memory
|
||||||
|
_inputBuffer.clear();
|
||||||
|
_pendingOutputChunks.clear();
|
||||||
|
_pendingOutputSize = 0;
|
||||||
// Terminal cleanup is handled server-side when service closes
|
// Terminal cleanup is handled server-side when service closes
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user