mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-02-25 20:18:47 +08:00
* feat(terminal): add reconnection buffer support for persistent sessions Fix two related issues: 1. Reconnecting to persistent sessions shows blank screen - server now automatically sends historical buffer on reconnection via SessionState machine with pending_buffer, eliminating the need for client-initiated buffer requests. 2. Terminal output before view ready causes NaN errors - buffer output chunks on client side until terminal view has valid dimensions, then flush in order on first valid resize. Rust side: - Introduce SessionState enum (Closed/Active) replacing bool is_opened - Auto-attach pending buffer on reconnection in handle_open() - Always drain output channel in read_outputs() to prevent overflow - Increase channel buffer from 100 to 500 - Optimize get_recent() to collect whole chunks (avoids ANSI truncation) - Extract create_terminal_data_response() helper (DRY) - Add reconnected flag to TerminalOpened protobuf message Flutter side: - Buffer output chunks until terminal view has valid dimensions - Flush buffered output on first valid resize via _markViewReady() - Clear terminal on reconnection to avoid duplicate output from buffer replay - Fix max_bytes type (u32) to match protobuf definition - Pass reconnected field through FlutterHandler event Signed-off-by: fufesou <linlong1266@gmail.com> * fix(terminal): add two-phase SIGWINCH for TUI app redraw and session remap on reconnection Fix TUI apps (top, htop) not redrawing after reconnection. A single resize-then-restore is too fast for ncurses to detect a size change, so split across two read_outputs() polling cycles (~30ms apart) to force a full redraw. Also fix reconnection failure when client terminal_id doesn't match any surviving server-side session ID by remapping the lowest surviving session to the requested ID. Rust side: - Add two-phase SIGWINCH state machine (SigwinchPhase: TempResize → Restore → Idle) with retry logic (max 3 attempts per phase) - Add do_sigwinch_resize() for cross-platform PTY resize (direct PTY and Windows helper mode) - Add session remap logic for non-contiguous terminal_id reconnection - Extract try_send_output() helper with rate-limited drop logging (DRY) - Add 3-byte limit to UTF-8 continuation byte skipping in get_recent() to prevent runaway on non-UTF-8 binary data - Remove reconnected flag from flutter.rs (unused on client side) Flutter side: - Add reconnection screen clear and deferred flush logic - Filter self from persistent_sessions restore list - Add comments for web-related changes Signed-off-by: fufesou <linlong1266@gmail.com> --------- Signed-off-by: fufesou <linlong1266@gmail.com>
434 lines
14 KiB
Dart
434 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_hbb/common.dart';
|
|
import 'package:flutter_hbb/consts.dart';
|
|
import 'package:flutter_hbb/main.dart';
|
|
import 'package:xterm/xterm.dart';
|
|
|
|
import 'model.dart';
|
|
import 'platform_model.dart';
|
|
|
|
class TerminalModel with ChangeNotifier {
|
|
final String id; // peer id
|
|
final FFI parent;
|
|
final int terminalId;
|
|
late final Terminal terminal;
|
|
late final TerminalController terminalController;
|
|
|
|
bool _terminalOpened = false;
|
|
bool get terminalOpened => _terminalOpened;
|
|
|
|
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;
|
|
|
|
void Function(int w, int h, int pw, int ph)? onResizeExternal;
|
|
|
|
Future<void> _handleInput(String data) async {
|
|
// 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 && isPeerWindows && data == '\n') {
|
|
data = '\r';
|
|
}
|
|
if (_terminalOpened) {
|
|
// Send user input to remote terminal
|
|
try {
|
|
await bind.sessionSendTerminalInput(
|
|
sessionId: parent.sessionId,
|
|
terminalId: terminalId,
|
|
data: data,
|
|
);
|
|
} catch (e) {
|
|
debugPrint('[TerminalModel] Error sending terminal input: $e');
|
|
}
|
|
} else {
|
|
debugPrint('[TerminalModel] Terminal not opened yet, buffering input');
|
|
_inputBuffer.add(data);
|
|
}
|
|
}
|
|
|
|
TerminalModel(this.parent, [this.terminalId = 0]) : id = parent.id {
|
|
terminal = Terminal(maxLines: 10000);
|
|
terminalController = TerminalController();
|
|
|
|
// Setup terminal callbacks
|
|
terminal.onOutput = _handleInput;
|
|
|
|
terminal.onResize = (w, h, pw, ph) async {
|
|
// Validate all dimensions before using them
|
|
if (w > 0 && h > 0 && pw > 0 && ph > 0) {
|
|
debugPrint(
|
|
'[TerminalModel] Terminal resized to ${w}x$h (pixel: ${pw}x$ph)');
|
|
|
|
// 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 {
|
|
await bind.sessionResizeTerminal(
|
|
sessionId: parent.sessionId,
|
|
terminalId: terminalId,
|
|
rows: h,
|
|
cols: w,
|
|
);
|
|
} catch (e) {
|
|
debugPrint('[TerminalModel] Error resizing terminal: $e');
|
|
}
|
|
}
|
|
} else {
|
|
debugPrint(
|
|
'[TerminalModel] Invalid terminal dimensions: ${w}x$h (pixel: ${pw}x$ph)');
|
|
}
|
|
};
|
|
}
|
|
|
|
void onReady() {
|
|
parent.dialogManager.dismissAll();
|
|
|
|
// Fire and forget - don't block onReady
|
|
openTerminal().catchError((e) {
|
|
debugPrint('[TerminalModel] Error opening terminal: $e');
|
|
});
|
|
}
|
|
|
|
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
|
|
|
|
// Get terminal dimensions, ensuring they are valid
|
|
int rows = 24;
|
|
int cols = 80;
|
|
|
|
if (terminal.viewHeight > 0) {
|
|
rows = terminal.viewHeight;
|
|
}
|
|
if (terminal.viewWidth > 0) {
|
|
cols = terminal.viewWidth;
|
|
}
|
|
|
|
debugPrint(
|
|
'[TerminalModel] Opening terminal $terminalId, sessionId: ${parent.sessionId}, size: ${cols}x$rows');
|
|
try {
|
|
await bind
|
|
.sessionOpenTerminal(
|
|
sessionId: parent.sessionId,
|
|
terminalId: terminalId,
|
|
rows: rows,
|
|
cols: cols,
|
|
)
|
|
.timeout(
|
|
const Duration(seconds: 5),
|
|
onTimeout: () {
|
|
throw TimeoutException(
|
|
'sessionOpenTerminal timed out after 5 seconds');
|
|
},
|
|
);
|
|
debugPrint('[TerminalModel] sessionOpenTerminal called successfully');
|
|
} catch (e) {
|
|
debugPrint('[TerminalModel] Error calling sessionOpenTerminal: $e');
|
|
// Optionally show error to user
|
|
if (e is TimeoutException) {
|
|
_writeToTerminal('Failed to open terminal: Connection timeout\r\n');
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> sendVirtualKey(String data) async {
|
|
return _handleInput(data);
|
|
}
|
|
|
|
Future<void> closeTerminal() async {
|
|
if (_terminalOpened) {
|
|
try {
|
|
await bind
|
|
.sessionCloseTerminal(
|
|
sessionId: parent.sessionId,
|
|
terminalId: terminalId,
|
|
)
|
|
.timeout(
|
|
const Duration(seconds: 3),
|
|
onTimeout: () {
|
|
throw TimeoutException(
|
|
'sessionCloseTerminal timed out after 3 seconds');
|
|
},
|
|
);
|
|
debugPrint('[TerminalModel] sessionCloseTerminal called successfully');
|
|
} catch (e) {
|
|
debugPrint('[TerminalModel] Error calling sessionCloseTerminal: $e');
|
|
// Continue with cleanup even if close fails
|
|
}
|
|
_terminalOpened = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
static int getTerminalIdFromEvt(Map<String, dynamic> evt) {
|
|
if (evt.containsKey('terminal_id')) {
|
|
final v = evt['terminal_id'];
|
|
if (v is int) {
|
|
// Desktop and mobile send terminal_id as an int
|
|
return v;
|
|
} else if (v is String) {
|
|
// Web sends terminal_id as a string
|
|
final parsed = int.tryParse(v);
|
|
if (parsed != null) {
|
|
return parsed;
|
|
} else {
|
|
debugPrint(
|
|
'[TerminalModel] Failed to parse terminal_id as integer: $v. Expected a numeric string.');
|
|
return 0;
|
|
}
|
|
} else {
|
|
// Unexpected type, log and handle gracefully
|
|
debugPrint(
|
|
'[TerminalModel] Unexpected terminal_id type: ${v.runtimeType}, value: $v. Expected int or String.');
|
|
return 0;
|
|
}
|
|
} else {
|
|
debugPrint('[TerminalModel] Event does not contain terminal_id');
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
static bool getSuccessFromEvt(Map<String, dynamic> evt) {
|
|
if (evt.containsKey('success')) {
|
|
final v = evt['success'];
|
|
if (v is bool) {
|
|
// Desktop and mobile
|
|
return v;
|
|
} else if (v is String) {
|
|
// Web
|
|
return v.toLowerCase() == 'true';
|
|
} else {
|
|
// Unexpected type, log and handle gracefully
|
|
debugPrint(
|
|
'[TerminalModel] Unexpected success type: ${v.runtimeType}, value: $v. Expected bool or String.');
|
|
return false;
|
|
}
|
|
} else {
|
|
debugPrint('[TerminalModel] Event does not contain success');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
void handleTerminalResponse(Map<String, dynamic> evt) {
|
|
final String? type = evt['type'];
|
|
final int evtTerminalId = getTerminalIdFromEvt(evt);
|
|
|
|
// Only handle events for this terminal
|
|
if (evtTerminalId != terminalId) {
|
|
debugPrint(
|
|
'[TerminalModel] Ignoring event for terminal $evtTerminalId (not mine)');
|
|
return;
|
|
}
|
|
|
|
switch (type) {
|
|
case 'opened':
|
|
_handleTerminalOpened(evt);
|
|
break;
|
|
case 'data':
|
|
_handleTerminalData(evt);
|
|
break;
|
|
case 'closed':
|
|
_handleTerminalClosed(evt);
|
|
break;
|
|
case 'error':
|
|
_handleTerminalError(evt);
|
|
break;
|
|
}
|
|
}
|
|
|
|
void _handleTerminalOpened(Map<String, dynamic> evt) {
|
|
final bool success = getSuccessFromEvt(evt);
|
|
final String message = evt['message']?.toString() ?? '';
|
|
final String? serviceId = evt['service_id']?.toString();
|
|
|
|
debugPrint(
|
|
'[TerminalModel] Terminal opened response: success=$success, message=$message, service_id=$serviceId');
|
|
|
|
if (success) {
|
|
_terminalOpened = true;
|
|
|
|
// 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),
|
|
// mark view ready now to avoid output stuck in buffer indefinitely.
|
|
if (!_terminalViewReady &&
|
|
terminal.viewWidth > 0 &&
|
|
terminal.viewHeight > 0) {
|
|
_markViewReady();
|
|
}
|
|
|
|
// Process any buffered input
|
|
_processBufferedInputAsync().then((_) {
|
|
notifyListeners();
|
|
}).catchError((e) {
|
|
debugPrint('[TerminalModel] Error processing buffered input: $e');
|
|
notifyListeners();
|
|
});
|
|
|
|
final persistentSessions =
|
|
evt['persistent_sessions'] as List<dynamic>? ?? [];
|
|
if (kWindowId != null && persistentSessions.isNotEmpty) {
|
|
DesktopMultiWindow.invokeMethod(
|
|
kWindowId!,
|
|
kWindowEventRestoreTerminalSessions,
|
|
jsonEncode({
|
|
'persistent_sessions': persistentSessions,
|
|
}));
|
|
}
|
|
} else {
|
|
_writeToTerminal('Failed to open terminal: $message\r\n');
|
|
}
|
|
}
|
|
|
|
Future<void> _processBufferedInputAsync() async {
|
|
final buffer = List<String>.from(_inputBuffer);
|
|
_inputBuffer.clear();
|
|
|
|
for (final data in buffer) {
|
|
try {
|
|
await bind.sessionSendTerminalInput(
|
|
sessionId: parent.sessionId,
|
|
terminalId: terminalId,
|
|
data: data,
|
|
);
|
|
} catch (e) {
|
|
debugPrint('[TerminalModel] Error sending buffered input: $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
void _handleTerminalData(Map<String, dynamic> evt) {
|
|
final data = evt['data'];
|
|
|
|
if (data != null) {
|
|
try {
|
|
String text = '';
|
|
if (data is String) {
|
|
// Try to decode as base64 first
|
|
try {
|
|
final bytes = base64Decode(data);
|
|
text = utf8.decode(bytes, allowMalformed: true);
|
|
} catch (e) {
|
|
// If base64 decode fails, treat as plain text
|
|
text = data;
|
|
}
|
|
} else if (data is List) {
|
|
// Handle if data comes as byte array
|
|
text = utf8.decode(List<int>.from(data), allowMalformed: true);
|
|
} else {
|
|
debugPrint('[TerminalModel] Unknown data type: ${data.runtimeType}');
|
|
return;
|
|
}
|
|
|
|
_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;
|
|
_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';
|
|
_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();
|
|
}
|
|
}
|