mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-05-08 07:08:09 +03:00
Compare commits
1 Commits
master
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ad54fa846 |
@@ -716,17 +716,6 @@ closeConnection({String? id}) {
|
|||||||
stateGlobal.isInMainPage = true;
|
stateGlobal.isInMainPage = true;
|
||||||
} else {
|
} else {
|
||||||
final controller = Get.find<DesktopTabController>();
|
final controller = Get.find<DesktopTabController>();
|
||||||
if (controller.tabType == DesktopTabType.terminal &&
|
|
||||||
controller.onCloseWindow != null) {
|
|
||||||
// Terminal windows are scoped to one peer. The optional id passed to
|
|
||||||
// closeConnection() is that peer id, not a terminal tab key
|
|
||||||
// (${peerId}_${terminalId}). Closing from terminal dialogs should close
|
|
||||||
// the peer's whole terminal window, including all terminal tabs.
|
|
||||||
unawaited(controller.onCloseWindow!().catchError((e, _) {
|
|
||||||
debugPrint('[closeConnection] Failed to close terminal window: $e');
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
controller.closeBy(id);
|
controller.closeBy(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4190,7 +4179,8 @@ Widget? buildAvatarWidget({
|
|||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) => fallback ?? SizedBox.shrink(),
|
errorBuilder: (_, __, ___) =>
|
||||||
|
fallback ?? SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ 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
|
/// Tab key for focus management, passed from parent to avoid duplicate construction
|
||||||
final String tabKey;
|
final String tabKey;
|
||||||
final SimpleWrapper<State<TerminalPage>?> _lastState = SimpleWrapper(null);
|
final SimpleWrapper<State<TerminalPage>?> _lastState = SimpleWrapper(null);
|
||||||
@@ -44,9 +43,6 @@ class TerminalPage extends StatefulWidget {
|
|||||||
|
|
||||||
class _TerminalPageState extends State<TerminalPage>
|
class _TerminalPageState extends State<TerminalPage>
|
||||||
with AutomaticKeepAliveClientMixin {
|
with AutomaticKeepAliveClientMixin {
|
||||||
static const EdgeInsets _defaultTerminalPadding =
|
|
||||||
EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
|
|
||||||
|
|
||||||
late FFI _ffi;
|
late FFI _ffi;
|
||||||
late TerminalModel _terminalModel;
|
late TerminalModel _terminalModel;
|
||||||
double? _cellHeight;
|
double? _cellHeight;
|
||||||
@@ -159,27 +155,13 @@ class _TerminalPageState extends State<TerminalPage>
|
|||||||
// extra space left after dividing the available height by the height of a single
|
// 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.
|
||||||
EdgeInsets _calculatePadding(double heightPx) {
|
EdgeInsets _calculatePadding(double heightPx) {
|
||||||
final cellHeight = _cellHeight;
|
if (_cellHeight == null) {
|
||||||
if (!heightPx.isFinite ||
|
return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
|
||||||
heightPx <= 0 ||
|
|
||||||
cellHeight == null ||
|
|
||||||
!cellHeight.isFinite ||
|
|
||||||
cellHeight <= 0) {
|
|
||||||
return _defaultTerminalPadding;
|
|
||||||
}
|
|
||||||
final rows = (heightPx / cellHeight).floor();
|
|
||||||
if (rows <= 0) {
|
|
||||||
return _defaultTerminalPadding;
|
|
||||||
}
|
|
||||||
final extraSpace = heightPx - rows * cellHeight;
|
|
||||||
if (!extraSpace.isFinite || extraSpace < 0) {
|
|
||||||
return _defaultTerminalPadding;
|
|
||||||
}
|
}
|
||||||
|
final rows = (heightPx / _cellHeight!).floor();
|
||||||
|
final extraSpace = heightPx - rows * _cellHeight!;
|
||||||
final topBottom = extraSpace / 2.0;
|
final topBottom = extraSpace / 2.0;
|
||||||
return EdgeInsets.symmetric(
|
return EdgeInsets.symmetric(horizontal: 5.0, vertical: topBottom);
|
||||||
horizontal: _defaultTerminalPadding.horizontal / 2,
|
|
||||||
vertical: topBottom,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
.setTitle(getWindowNameWithId(id));
|
.setTitle(getWindowNameWithId(id));
|
||||||
};
|
};
|
||||||
tabController.onRemoved = (_, id) => onRemoveId(id);
|
tabController.onRemoved = (_, id) => onRemoveId(id);
|
||||||
tabController.onCloseWindow = _closeWindowFromConnection;
|
|
||||||
final terminalId = params['terminalId'] ?? _nextTerminalId++;
|
final terminalId = params['terminalId'] ?? _nextTerminalId++;
|
||||||
tabController.add(_createTerminalTab(
|
tabController.add(_createTerminalTab(
|
||||||
peerId: params['id'],
|
peerId: params['id'],
|
||||||
@@ -145,8 +144,6 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
_windowClosing = true;
|
_windowClosing = true;
|
||||||
final tabKeys = tabController.state.value.tabs.map((t) => t.key).toList();
|
final tabKeys = tabController.state.value.tabs.map((t) => t.key).toList();
|
||||||
// Remove all UI tabs immediately (same instant behavior as the old tabController.clear())
|
// Remove all UI tabs immediately (same instant behavior as the old tabController.clear())
|
||||||
// Keep the cleanup target lookup below synchronous before its first await:
|
|
||||||
// it relies on the current frame still retaining each TerminalPage's FFI/model.
|
|
||||||
tabController.clear();
|
tabController.clear();
|
||||||
// Run session cleanup in parallel with bounded timeout (closeTerminal() has internal 3s timeout).
|
// Run session cleanup in parallel with bounded timeout (closeTerminal() has internal 3s timeout).
|
||||||
// Skip tabs already being closed by a concurrent _closeTab() to avoid duplicate FFI calls.
|
// Skip tabs already being closed by a concurrent _closeTab() to avoid duplicate FFI calls.
|
||||||
@@ -371,34 +368,8 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
final persistentSessions =
|
final persistentSessions =
|
||||||
args['persistent_sessions'] as List<dynamic>? ?? [];
|
args['persistent_sessions'] as List<dynamic>? ?? [];
|
||||||
final sortedSessions = persistentSessions.whereType<int>().toList()..sort();
|
final sortedSessions = persistentSessions.whereType<int>().toList()..sort();
|
||||||
var peerId = args['peer_id'] as String? ?? '';
|
|
||||||
if (peerId.isEmpty) {
|
|
||||||
if (tabController.state.value.tabs.isEmpty ||
|
|
||||||
tabController.state.value.selected >=
|
|
||||||
tabController.state.value.tabs.length) {
|
|
||||||
debugPrint('[TerminalTabPage] Skip restore: no selected tab');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final currentTab = tabController.state.value.selectedTabInfo;
|
|
||||||
final parsed = _parseTabKey(currentTab.key);
|
|
||||||
if (parsed == null) return;
|
|
||||||
peerId = parsed.$1;
|
|
||||||
}
|
|
||||||
final existingTerminalIds = tabController.state.value.tabs
|
|
||||||
.map((tab) => _parseTabKey(tab.key))
|
|
||||||
.where((parsed) => parsed != null && parsed.$1 == peerId)
|
|
||||||
.map((parsed) => parsed!.$2)
|
|
||||||
.toSet();
|
|
||||||
if (existingTerminalIds.isEmpty) {
|
|
||||||
debugPrint(
|
|
||||||
'[TerminalTabPage] Skip restore: no seed tab for peer $peerId');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (final terminalId in sortedSessions) {
|
for (final terminalId in sortedSessions) {
|
||||||
if (!existingTerminalIds.add(terminalId)) {
|
_addNewTerminalForCurrentPeer(terminalId: terminalId);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
_addNewTerminal(peerId, terminalId: terminalId);
|
|
||||||
// A delay is required to ensure the UI has sufficient time to update
|
// A delay is required to ensure the UI has sufficient time to update
|
||||||
// before adding the next terminal. Without this delay, `_TerminalPageState::dispose()`
|
// before adding the next terminal. Without this delay, `_TerminalPageState::dispose()`
|
||||||
// may be called prematurely while the tab widget is still in the tab controller.
|
// may be called prematurely while the tab widget is still in the tab controller.
|
||||||
@@ -575,11 +546,6 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _closeWindowFromConnection() async {
|
|
||||||
await _closeAllTabs();
|
|
||||||
await WindowController.fromWindowId(windowId()).close();
|
|
||||||
}
|
|
||||||
|
|
||||||
int windowId() {
|
int windowId() {
|
||||||
return widget.params["windowId"];
|
return widget.params["windowId"];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ class DesktopTabController {
|
|||||||
/// index, key
|
/// index, key
|
||||||
Function(int, String)? onRemoved;
|
Function(int, String)? onRemoved;
|
||||||
Function(String)? onSelected;
|
Function(String)? onSelected;
|
||||||
Future<void> Function()? onCloseWindow;
|
|
||||||
|
|
||||||
DesktopTabController(
|
DesktopTabController(
|
||||||
{required this.tabType, this.onRemoved, this.onSelected});
|
{required this.tabType, this.onRemoved, this.onSelected});
|
||||||
|
|||||||
@@ -27,30 +27,25 @@ class TerminalModel with ChangeNotifier {
|
|||||||
// Buffer for output data received before terminal view has valid dimensions.
|
// Buffer for output data received before terminal view has valid dimensions.
|
||||||
// This prevents NaN errors when writing to terminal before layout is complete.
|
// This prevents NaN errors when writing to terminal before layout is complete.
|
||||||
final _pendingOutputChunks = <String>[];
|
final _pendingOutputChunks = <String>[];
|
||||||
final _pendingOutputSuppressFlags = <bool>[];
|
|
||||||
int _pendingOutputSize = 0;
|
int _pendingOutputSize = 0;
|
||||||
static const int _kMaxOutputBufferChars = 8 * 1024;
|
static const int _kMaxOutputBufferChars = 8 * 1024;
|
||||||
// View ready state: true when terminal has valid dimensions, safe to write
|
// View ready state: true when terminal has valid dimensions, safe to write
|
||||||
bool _terminalViewReady = false;
|
bool _terminalViewReady = false;
|
||||||
bool _markViewReadyScheduled = false;
|
|
||||||
bool _suppressTerminalOutput = false;
|
bool get isPeerWindows => parent.ffiModel.pi.platform == kPeerPlatformWindows;
|
||||||
bool _suppressNextTerminalDataOutput = false;
|
|
||||||
|
|
||||||
void Function(int w, int h, int pw, int ph)? onResizeExternal;
|
void Function(int w, int h, int pw, int ph)? onResizeExternal;
|
||||||
|
|
||||||
Future<void> _handleInput(String data) async {
|
Future<void> _handleInput(String data) async {
|
||||||
// Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a
|
// If we press the `Enter` button on Android,
|
||||||
// real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'.
|
// `data` can be '\r' or '\n' when using different keyboards.
|
||||||
// - Peer Windows: '\r' works, '\n' is just a newline.
|
// Android -> Windows. '\r' works, but '\n' does not. '\n' is just a newline.
|
||||||
// - Peer Linux: canonical-mode shells accept both, but raw-mode apps
|
// Android -> Linux. Both '\r' and '\n' work as expected (execute a command).
|
||||||
// (readline, prompt_toolkit, vim, TUI frameworks) expect '\r'.
|
// So when we receive '\n', we may need to convert it to '\r' to ensure compatibility.
|
||||||
// - Peer macOS: same as Linux, raw-mode apps expect '\r'
|
// Desktop -> Desktop works fine.
|
||||||
// (https://github.com/rustdesk/rustdesk/issues/14907).
|
// Check if we are on mobile or web(mobile), and convert '\n' to '\r'.
|
||||||
// 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));
|
final isMobileOrWebMobile = (isMobile || (isWeb && !isWebDesktop));
|
||||||
if (isMobileOrWebMobile && data == '\n') {
|
if (isMobileOrWebMobile && isPeerWindows && data == '\n') {
|
||||||
data = '\r';
|
data = '\r';
|
||||||
}
|
}
|
||||||
if (_terminalOpened) {
|
if (_terminalOpened) {
|
||||||
@@ -75,10 +70,7 @@ class TerminalModel with ChangeNotifier {
|
|||||||
terminalController = TerminalController();
|
terminalController = TerminalController();
|
||||||
|
|
||||||
// Setup terminal callbacks
|
// Setup terminal callbacks
|
||||||
terminal.onOutput = (data) {
|
terminal.onOutput = _handleInput;
|
||||||
if (_suppressTerminalOutput) return;
|
|
||||||
_handleInput(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
terminal.onResize = (w, h, pw, ph) async {
|
terminal.onResize = (w, h, pw, ph) async {
|
||||||
// Validate all dimensions before using them
|
// Validate all dimensions before using them
|
||||||
@@ -92,7 +84,7 @@ class TerminalModel with ChangeNotifier {
|
|||||||
// Mark terminal view as ready and flush any buffered output on first valid resize.
|
// 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.
|
// Must be after onResizeExternal so the view layer has valid dimensions before flushing.
|
||||||
if (!_terminalViewReady) {
|
if (!_terminalViewReady) {
|
||||||
_scheduleMarkViewReady();
|
_markViewReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_terminalOpened) {
|
if (_terminalOpened) {
|
||||||
@@ -118,16 +110,14 @@ class TerminalModel with ChangeNotifier {
|
|||||||
void onReady() {
|
void onReady() {
|
||||||
parent.dialogManager.dismissAll();
|
parent.dialogManager.dismissAll();
|
||||||
|
|
||||||
// Fire and forget - don't block onReady. If the transport reconnects while
|
// Fire and forget - don't block onReady
|
||||||
// this model is still open, re-send OpenTerminal so the remote service marks
|
openTerminal().catchError((e) {
|
||||||
// the persistent session active again and resumes output streaming.
|
|
||||||
openTerminal(force: _terminalOpened).catchError((e) {
|
|
||||||
debugPrint('[TerminalModel] Error opening terminal: $e');
|
debugPrint('[TerminalModel] Error opening terminal: $e');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> openTerminal({bool force = false}) async {
|
Future<void> openTerminal() async {
|
||||||
if (_terminalOpened && !force) return;
|
if (_terminalOpened) return;
|
||||||
// Request the remote side to open a terminal with default shell
|
// Request the remote side to open a terminal with default shell
|
||||||
// The remote side will decide which shell to use based on its OS
|
// The remote side will decide which shell to use based on its OS
|
||||||
|
|
||||||
@@ -285,12 +275,9 @@ class TerminalModel with ChangeNotifier {
|
|||||||
if (success) {
|
if (success) {
|
||||||
_terminalOpened = true;
|
_terminalOpened = true;
|
||||||
|
|
||||||
// On reconnect, the server may replay recent output. That replay can include
|
// On reconnect ("Reconnected to existing terminal"), server may replay recent output.
|
||||||
// terminal queries like DSR/DA; xterm answers them through onOutput as
|
// If this TerminalView instance is reused (not rebuilt), duplicate lines can appear.
|
||||||
// "^[[1;1R^[[2;2R^[[>0;0;0c", which must not be sent back to the peer.
|
// We intentionally accept this tradeoff for now to keep logic simple.
|
||||||
final replayTerminalOutput = evt['replay_terminal_output'];
|
|
||||||
_suppressNextTerminalDataOutput = replayTerminalOutput == true ||
|
|
||||||
message == 'Reconnected to existing terminal with pending output';
|
|
||||||
|
|
||||||
// Fallback: if terminal view is not yet ready but already has valid
|
// Fallback: if terminal view is not yet ready but already has valid
|
||||||
// dimensions (e.g. layout completed before open response arrived),
|
// dimensions (e.g. layout completed before open response arrived),
|
||||||
@@ -298,7 +285,7 @@ class TerminalModel with ChangeNotifier {
|
|||||||
if (!_terminalViewReady &&
|
if (!_terminalViewReady &&
|
||||||
terminal.viewWidth > 0 &&
|
terminal.viewWidth > 0 &&
|
||||||
terminal.viewHeight > 0) {
|
terminal.viewHeight > 0) {
|
||||||
_scheduleMarkViewReady();
|
_markViewReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process any buffered input
|
// Process any buffered input
|
||||||
@@ -310,16 +297,12 @@ class TerminalModel with ChangeNotifier {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final persistentSessions =
|
final persistentSessions =
|
||||||
(evt['persistent_sessions'] as List<dynamic>? ?? [])
|
evt['persistent_sessions'] as List<dynamic>? ?? [];
|
||||||
.whereType<int>()
|
|
||||||
.where((id) => !parent.terminalModels.containsKey(id))
|
|
||||||
.toList();
|
|
||||||
if (kWindowId != null && persistentSessions.isNotEmpty) {
|
if (kWindowId != null && persistentSessions.isNotEmpty) {
|
||||||
DesktopMultiWindow.invokeMethod(
|
DesktopMultiWindow.invokeMethod(
|
||||||
kWindowId!,
|
kWindowId!,
|
||||||
kWindowEventRestoreTerminalSessions,
|
kWindowEventRestoreTerminalSessions,
|
||||||
jsonEncode({
|
jsonEncode({
|
||||||
'peer_id': id,
|
|
||||||
'persistent_sessions': persistentSessions,
|
'persistent_sessions': persistentSessions,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -349,8 +332,6 @@ class TerminalModel with ChangeNotifier {
|
|||||||
final data = evt['data'];
|
final data = evt['data'];
|
||||||
|
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
final suppressTerminalOutput = _suppressNextTerminalDataOutput;
|
|
||||||
_suppressNextTerminalDataOutput = false;
|
|
||||||
try {
|
try {
|
||||||
String text = '';
|
String text = '';
|
||||||
if (data is String) {
|
if (data is String) {
|
||||||
@@ -370,7 +351,7 @@ class TerminalModel with ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_writeToTerminal(text, suppressTerminalOutput: suppressTerminalOutput);
|
_writeToTerminal(text);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[TerminalModel] Failed to process terminal data: $e');
|
debugPrint('[TerminalModel] Failed to process terminal data: $e');
|
||||||
}
|
}
|
||||||
@@ -380,10 +361,7 @@ class TerminalModel with ChangeNotifier {
|
|||||||
/// Write text to terminal, buffering if the view is not yet ready.
|
/// Write text to terminal, buffering if the view is not yet ready.
|
||||||
/// All terminal output should go through this method to avoid NaN errors
|
/// All terminal output should go through this method to avoid NaN errors
|
||||||
/// from writing before the terminal view has valid layout dimensions.
|
/// from writing before the terminal view has valid layout dimensions.
|
||||||
void _writeToTerminal(
|
void _writeToTerminal(String text) {
|
||||||
String text, {
|
|
||||||
bool suppressTerminalOutput = false,
|
|
||||||
}) {
|
|
||||||
if (!_terminalViewReady) {
|
if (!_terminalViewReady) {
|
||||||
// If a single chunk exceeds the cap, keep only its tail.
|
// If a single chunk exceeds the cap, keep only its tail.
|
||||||
// Note: truncation may split a multi-byte ANSI escape sequence,
|
// Note: truncation may split a multi-byte ANSI escape sequence,
|
||||||
@@ -395,73 +373,34 @@ class TerminalModel with ChangeNotifier {
|
|||||||
_pendingOutputChunks
|
_pendingOutputChunks
|
||||||
..clear()
|
..clear()
|
||||||
..add(truncated);
|
..add(truncated);
|
||||||
_pendingOutputSuppressFlags
|
|
||||||
..clear()
|
|
||||||
..add(suppressTerminalOutput);
|
|
||||||
_pendingOutputSize = truncated.length;
|
_pendingOutputSize = truncated.length;
|
||||||
} else {
|
} else {
|
||||||
_pendingOutputChunks.add(text);
|
_pendingOutputChunks.add(text);
|
||||||
_pendingOutputSuppressFlags.add(suppressTerminalOutput);
|
|
||||||
_pendingOutputSize += text.length;
|
_pendingOutputSize += text.length;
|
||||||
// Drop oldest chunks if exceeds limit (whole chunks to preserve ANSI sequences)
|
// Drop oldest chunks if exceeds limit (whole chunks to preserve ANSI sequences)
|
||||||
while (_pendingOutputSize > _kMaxOutputBufferChars &&
|
while (_pendingOutputSize > _kMaxOutputBufferChars &&
|
||||||
_pendingOutputChunks.length > 1) {
|
_pendingOutputChunks.length > 1) {
|
||||||
final removed = _pendingOutputChunks.removeAt(0);
|
final removed = _pendingOutputChunks.removeAt(0);
|
||||||
_pendingOutputSuppressFlags.removeAt(0);
|
|
||||||
_pendingOutputSize -= removed.length;
|
_pendingOutputSize -= removed.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_writeTerminalChunk(text, suppressTerminalOutput: suppressTerminalOutput);
|
terminal.write(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _flushOutputBuffer() {
|
void _flushOutputBuffer() {
|
||||||
if (_pendingOutputChunks.isEmpty) return;
|
if (_pendingOutputChunks.isEmpty) return;
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'[TerminalModel] Flushing $_pendingOutputSize buffered chars (${_pendingOutputChunks.length} chunks)');
|
'[TerminalModel] Flushing $_pendingOutputSize buffered chars (${_pendingOutputChunks.length} chunks)');
|
||||||
for (var i = 0; i < _pendingOutputChunks.length; i++) {
|
for (final chunk in _pendingOutputChunks) {
|
||||||
_writeTerminalChunk(
|
terminal.write(chunk);
|
||||||
_pendingOutputChunks[i],
|
|
||||||
suppressTerminalOutput: _pendingOutputSuppressFlags[i],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
_pendingOutputChunks.clear();
|
_pendingOutputChunks.clear();
|
||||||
_pendingOutputSuppressFlags.clear();
|
|
||||||
_pendingOutputSize = 0;
|
_pendingOutputSize = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _writeTerminalChunk(
|
|
||||||
String text, {
|
|
||||||
required bool suppressTerminalOutput,
|
|
||||||
}) {
|
|
||||||
if (!suppressTerminalOutput) {
|
|
||||||
terminal.write(text);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final previous = _suppressTerminalOutput;
|
|
||||||
_suppressTerminalOutput = true;
|
|
||||||
try {
|
|
||||||
terminal.write(text);
|
|
||||||
} finally {
|
|
||||||
_suppressTerminalOutput = previous;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mark terminal view as ready and flush buffered output.
|
/// Mark terminal view as ready and flush buffered output.
|
||||||
void _scheduleMarkViewReady() {
|
|
||||||
if (_disposed || _terminalViewReady || _markViewReadyScheduled) return;
|
|
||||||
_markViewReadyScheduled = true;
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
_markViewReadyScheduled = false;
|
|
||||||
if (_disposed || _terminalViewReady) return;
|
|
||||||
if (terminal.viewWidth > 0 && terminal.viewHeight > 0) {
|
|
||||||
_markViewReady();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
WidgetsBinding.instance.ensureVisualUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _markViewReady() {
|
void _markViewReady() {
|
||||||
if (_terminalViewReady) return;
|
if (_terminalViewReady) return;
|
||||||
_terminalViewReady = true;
|
_terminalViewReady = true;
|
||||||
@@ -487,10 +426,7 @@ class TerminalModel with ChangeNotifier {
|
|||||||
// Clear buffers to free memory
|
// Clear buffers to free memory
|
||||||
_inputBuffer.clear();
|
_inputBuffer.clear();
|
||||||
_pendingOutputChunks.clear();
|
_pendingOutputChunks.clear();
|
||||||
_pendingOutputSuppressFlags.clear();
|
|
||||||
_pendingOutputSize = 0;
|
_pendingOutputSize = 0;
|
||||||
_markViewReadyScheduled = false;
|
|
||||||
_suppressNextTerminalDataOutput = false;
|
|
||||||
// Terminal cleanup is handled server-side when service closes
|
// Terminal cleanup is handled server-side when service closes
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
Submodule libs/hbb_common updated: 42af0f0aed...6490a8655c
@@ -1745,9 +1745,6 @@ pub struct LoginConfigHandler {
|
|||||||
pub direct: Option<bool>,
|
pub direct: Option<bool>,
|
||||||
pub received: bool,
|
pub received: bool,
|
||||||
switch_uuid: Option<String>,
|
switch_uuid: Option<String>,
|
||||||
#[cfg(feature = "flutter")]
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
||||||
switch_back_allowed: bool,
|
|
||||||
pub save_ab_password_to_recent: bool, // true: connected with ab password
|
pub save_ab_password_to_recent: bool, // true: connected with ab password
|
||||||
pub other_server: Option<(String, String, String)>,
|
pub other_server: Option<(String, String, String)>,
|
||||||
pub custom_fps: Arc<Mutex<Option<usize>>>,
|
pub custom_fps: Arc<Mutex<Option<usize>>>,
|
||||||
@@ -1864,11 +1861,6 @@ impl LoginConfigHandler {
|
|||||||
|
|
||||||
self.direct = None;
|
self.direct = None;
|
||||||
self.received = false;
|
self.received = false;
|
||||||
#[cfg(feature = "flutter")]
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
||||||
{
|
|
||||||
self.switch_back_allowed = false;
|
|
||||||
}
|
|
||||||
self.switch_uuid = switch_uuid;
|
self.switch_uuid = switch_uuid;
|
||||||
self.adapter_luid = adapter_luid;
|
self.adapter_luid = adapter_luid;
|
||||||
self.selected_windows_session_id = None;
|
self.selected_windows_session_id = None;
|
||||||
@@ -1882,23 +1874,6 @@ impl LoginConfigHandler {
|
|||||||
self.is_terminal_admin = is_terminal_admin;
|
self.is_terminal_admin = is_terminal_admin;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "flutter")]
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
||||||
pub fn allow_switch_back_once(&mut self) {
|
|
||||||
self.switch_back_allowed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "flutter")]
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
||||||
pub fn consume_switch_back_permission(&mut self) -> bool {
|
|
||||||
if self.switch_back_allowed {
|
|
||||||
self.switch_back_allowed = false;
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if the client should auto login.
|
/// Check if the client should auto login.
|
||||||
/// Return password if the client should auto login, otherwise return empty string.
|
/// Return password if the client should auto login, otherwise return empty string.
|
||||||
pub fn should_auto_login(&self) -> String {
|
pub fn should_auto_login(&self) -> String {
|
||||||
@@ -3402,36 +3377,6 @@ pub fn handle_login_error(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "flutter")]
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
||||||
async fn consume_local_switch_sides_uuid(id: &str, uuid: &Uuid) -> bool {
|
|
||||||
let Ok(mut conn) = crate::ipc::connect(1000, "").await else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let uuid = uuid.to_string();
|
|
||||||
if conn
|
|
||||||
.send(&crate::ipc::Data::SwitchSidesUuid(
|
|
||||||
uuid.clone(),
|
|
||||||
id.to_owned(),
|
|
||||||
None,
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
match conn.next_timeout(1000).await {
|
|
||||||
Ok(Some(crate::ipc::Data::SwitchSidesUuid(
|
|
||||||
returned_uuid,
|
|
||||||
returned_id,
|
|
||||||
Some(true),
|
|
||||||
))) => {
|
|
||||||
returned_uuid == uuid && returned_id == id
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle hash message sent by peer.
|
/// Handle hash message sent by peer.
|
||||||
/// Hash will be used for login.
|
/// Hash will be used for login.
|
||||||
///
|
///
|
||||||
@@ -3452,22 +3397,12 @@ pub async fn handle_hash(
|
|||||||
// Take care of password application order
|
// Take care of password application order
|
||||||
|
|
||||||
// switch_uuid
|
// switch_uuid
|
||||||
#[cfg(feature = "flutter")]
|
let uuid = lc.write().unwrap().switch_uuid.take();
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
if let Some(uuid) = uuid {
|
||||||
{
|
if let Ok(uuid) = uuid::Uuid::from_str(&uuid) {
|
||||||
let uuid = lc.write().unwrap().switch_uuid.take();
|
send_switch_login_request(lc.clone(), peer, uuid).await;
|
||||||
if let Some(uuid) = uuid {
|
lc.write().unwrap().password_source = Default::default();
|
||||||
if let Ok(uuid) = uuid::Uuid::from_str(&uuid) {
|
return;
|
||||||
let id = lc.read().unwrap().id.clone();
|
|
||||||
if !consume_local_switch_sides_uuid(&id, &uuid).await {
|
|
||||||
log::warn!("Ignored untrusted switch_uuid");
|
|
||||||
} else {
|
|
||||||
lc.write().unwrap().allow_switch_back_once();
|
|
||||||
send_switch_login_request(lc.clone(), peer, uuid).await;
|
|
||||||
lc.write().unwrap().password_source = Default::default();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// last password
|
// last password
|
||||||
|
|||||||
@@ -1923,23 +1923,9 @@ impl<T: InvokeUiSession> Remote<T> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(feature = "flutter")]
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
||||||
Some(misc::Union::SwitchBack(_)) => {
|
Some(misc::Union::SwitchBack(_)) => {
|
||||||
let allow_switch_back = self
|
#[cfg(feature = "flutter")]
|
||||||
.handler
|
self.handler.switch_back(&self.handler.get_id());
|
||||||
.lc
|
|
||||||
.write()
|
|
||||||
.unwrap()
|
|
||||||
.consume_switch_back_permission();
|
|
||||||
if allow_switch_back {
|
|
||||||
self.handler.switch_back(&self.handler.get_id());
|
|
||||||
} else {
|
|
||||||
log::warn!(
|
|
||||||
"Ignored unsolicited SwitchBack from {}",
|
|
||||||
self.handler.get_id()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#[cfg(all(feature = "flutter", feature = "plugin_framework"))]
|
#[cfg(all(feature = "flutter", feature = "plugin_framework"))]
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
|
|||||||
@@ -1135,10 +1135,6 @@ impl InvokeUiSession for FlutterHandler {
|
|||||||
("message", json!(&opened.message)),
|
("message", json!(&opened.message)),
|
||||||
("pid", json!(opened.pid)),
|
("pid", json!(opened.pid)),
|
||||||
("service_id", json!(&opened.service_id)),
|
("service_id", json!(&opened.service_id)),
|
||||||
(
|
|
||||||
"replay_terminal_output",
|
|
||||||
json!(opened.replay_terminal_output),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
if !opened.persistent_sessions.is_empty() {
|
if !opened.persistent_sessions.is_empty() {
|
||||||
event_data.push(("persistent_sessions", json!(opened.persistent_sessions)));
|
event_data.push(("persistent_sessions", json!(opened.persistent_sessions)));
|
||||||
|
|||||||
@@ -2213,7 +2213,7 @@ pub fn cm_elevate_portable(conn_id: i32) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn cm_switch_back(conn_id: i32) {
|
pub fn cm_switch_back(conn_id: i32) {
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "ios")))]
|
||||||
crate::ui_cm_interface::switch_back(conn_id);
|
crate::ui_cm_interface::switch_back(conn_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
22
src/ipc.rs
22
src/ipc.rs
@@ -285,14 +285,7 @@ pub enum Data {
|
|||||||
Empty,
|
Empty,
|
||||||
Disconnected,
|
Disconnected,
|
||||||
DataPortableService(DataPortableService),
|
DataPortableService(DataPortableService),
|
||||||
#[cfg(feature = "flutter")]
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
||||||
SwitchSidesRequest(String),
|
SwitchSidesRequest(String),
|
||||||
#[cfg(feature = "flutter")]
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
||||||
SwitchSidesUuid(String, String, Option<bool>),
|
|
||||||
#[cfg(feature = "flutter")]
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
||||||
SwitchSidesBack,
|
SwitchSidesBack,
|
||||||
UrlLink(String),
|
UrlLink(String),
|
||||||
VoiceCallIncoming,
|
VoiceCallIncoming,
|
||||||
@@ -778,8 +771,6 @@ async fn handle(data: Data, stream: &mut Connection) {
|
|||||||
Data::TestRendezvousServer => {
|
Data::TestRendezvousServer => {
|
||||||
crate::test_rendezvous_server();
|
crate::test_rendezvous_server();
|
||||||
}
|
}
|
||||||
#[cfg(feature = "flutter")]
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
||||||
Data::SwitchSidesRequest(id) => {
|
Data::SwitchSidesRequest(id) => {
|
||||||
let uuid = uuid::Uuid::new_v4();
|
let uuid = uuid::Uuid::new_v4();
|
||||||
crate::server::insert_switch_sides_uuid(id, uuid.clone());
|
crate::server::insert_switch_sides_uuid(id, uuid.clone());
|
||||||
@@ -789,19 +780,6 @@ async fn handle(data: Data, stream: &mut Connection) {
|
|||||||
.await
|
.await
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
#[cfg(feature = "flutter")]
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
||||||
Data::SwitchSidesUuid(uuid, id, None) => {
|
|
||||||
let allowed = uuid
|
|
||||||
.parse::<uuid::Uuid>()
|
|
||||||
.map(|uuid| crate::server::remove_pending_switch_sides_uuid(&id, &uuid))
|
|
||||||
.unwrap_or(false);
|
|
||||||
allow_err!(
|
|
||||||
stream
|
|
||||||
.send(&Data::SwitchSidesUuid(uuid, id, Some(allowed)))
|
|
||||||
.await
|
|
||||||
);
|
|
||||||
}
|
|
||||||
#[cfg(all(feature = "flutter", feature = "plugin_framework"))]
|
#[cfg(all(feature = "flutter", feature = "plugin_framework"))]
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
Data::Plugin(plugin) => crate::plugin::ipc::handle_plugin(plugin, stream).await,
|
Data::Plugin(plugin) => crate::plugin::ipc::handle_plugin(plugin, stream).await,
|
||||||
|
|||||||
@@ -743,6 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||||||
("Display Name", "Anzeigename"),
|
("Display Name", "Anzeigename"),
|
||||||
("password-hidden-tip", "Ein permanentes Passwort wurde festgelegt (ausgeblendet)."),
|
("password-hidden-tip", "Ein permanentes Passwort wurde festgelegt (ausgeblendet)."),
|
||||||
("preset-password-in-use-tip", "Das voreingestellte Passwort wird derzeit verwendet."),
|
("preset-password-in-use-tip", "Das voreingestellte Passwort wird derzeit verwendet."),
|
||||||
("Enable privacy mode", "Datenschutzmodus aktivieren"),
|
("Enable privacy mode", ""),
|
||||||
].iter().cloned().collect();
|
].iter().cloned().collect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -743,6 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||||||
("Display Name", "Nom d’affichage"),
|
("Display Name", "Nom d’affichage"),
|
||||||
("password-hidden-tip", "Le mot de passe permanent est défini (masqué)."),
|
("password-hidden-tip", "Le mot de passe permanent est défini (masqué)."),
|
||||||
("preset-password-in-use-tip", "Le mot de passe prédéfini est actuellement utilisé."),
|
("preset-password-in-use-tip", "Le mot de passe prédéfini est actuellement utilisé."),
|
||||||
("Enable privacy mode", "Activer le mode de confidentialité"),
|
("Enable privacy mode", ""),
|
||||||
].iter().cloned().collect();
|
].iter().cloned().collect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -743,6 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||||||
("Display Name", "Visualizza nome"),
|
("Display Name", "Visualizza nome"),
|
||||||
("password-hidden-tip", "È impostata una password permanente (nascosta)."),
|
("password-hidden-tip", "È impostata una password permanente (nascosta)."),
|
||||||
("preset-password-in-use-tip", "È attualmente in uso la password preimpostata."),
|
("preset-password-in-use-tip", "È attualmente in uso la password preimpostata."),
|
||||||
("Enable privacy mode", "Abilita modalità privacy"),
|
("Enable privacy mode", ""),
|
||||||
].iter().cloned().collect();
|
].iter().cloned().collect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -743,6 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||||||
("Display Name", "Отображаемое имя"),
|
("Display Name", "Отображаемое имя"),
|
||||||
("password-hidden-tip", "Установлен постоянный пароль (скрытый)."),
|
("password-hidden-tip", "Установлен постоянный пароль (скрытый)."),
|
||||||
("preset-password-in-use-tip", "Установленный пароль сейчас используется."),
|
("preset-password-in-use-tip", "Установленный пароль сейчас используется."),
|
||||||
("Enable privacy mode", "Использовать режим конфиденциальности"),
|
("Enable privacy mode", ""),
|
||||||
].iter().cloned().collect();
|
].iter().cloned().collect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -741,8 +741,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||||||
("keep-awake-during-incoming-sessions-label", "Gelen oturumlar süresince ekranı açık tutun"),
|
("keep-awake-during-incoming-sessions-label", "Gelen oturumlar süresince ekranı açık tutun"),
|
||||||
("Continue with {}", "{} ile devam et"),
|
("Continue with {}", "{} ile devam et"),
|
||||||
("Display Name", "Görünen Ad"),
|
("Display Name", "Görünen Ad"),
|
||||||
("password-hidden-tip", "Parola gizli"),
|
("password-hidden-tip", "Şifre gizli"),
|
||||||
("preset-password-in-use-tip", "Önceden ayarlanmış parola kullanılıyor"),
|
("preset-password-in-use-tip", "Önceden ayarlanmış şifre kullanılıyor"),
|
||||||
("Enable privacy mode", "Gizlilik modunu etkinleştir"),
|
("Enable privacy mode", ""),
|
||||||
].iter().cloned().collect();
|
].iter().cloned().collect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,17 +73,11 @@ lazy_static::lazy_static! {
|
|||||||
static ref ALIVE_CONNS: Arc::<Mutex<Vec<i32>>> = Default::default();
|
static ref ALIVE_CONNS: Arc::<Mutex<Vec<i32>>> = Default::default();
|
||||||
pub static ref AUTHED_CONNS: Arc::<Mutex<Vec<AuthedConn>>> = Default::default();
|
pub static ref AUTHED_CONNS: Arc::<Mutex<Vec<AuthedConn>>> = Default::default();
|
||||||
pub static ref CONTROL_PERMISSIONS_ARRAY: Arc::<Mutex<Vec<(i32, ControlPermissions)>>> = Default::default();
|
pub static ref CONTROL_PERMISSIONS_ARRAY: Arc::<Mutex<Vec<(i32, ControlPermissions)>>> = Default::default();
|
||||||
|
static ref SWITCH_SIDES_UUID: Arc::<Mutex<HashMap<String, (Instant, uuid::Uuid)>>> = Default::default();
|
||||||
static ref WAKELOCK_SENDER: Arc::<Mutex<std::sync::mpsc::Sender<(usize, usize)>>> = Arc::new(Mutex::new(start_wakelock_thread()));
|
static ref WAKELOCK_SENDER: Arc::<Mutex<std::sync::mpsc::Sender<(usize, usize)>>> = Arc::new(Mutex::new(start_wakelock_thread()));
|
||||||
static ref WAKELOCK_KEEP_AWAKE_OPTION: Arc::<Mutex<Option<bool>>> = Default::default();
|
static ref WAKELOCK_KEEP_AWAKE_OPTION: Arc::<Mutex<Option<bool>>> = Default::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "flutter")]
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
||||||
lazy_static::lazy_static! {
|
|
||||||
static ref SWITCH_SIDES_UUID: Arc::<Mutex<HashMap<String, (Instant, uuid::Uuid)>>> = Default::default();
|
|
||||||
static ref PENDING_SWITCH_SIDES_UUID: Arc::<Mutex<HashMap<String, (Instant, uuid::Uuid)>>> = Default::default();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||||
if a.len() != b.len() {
|
if a.len() != b.len() {
|
||||||
return false;
|
return false;
|
||||||
@@ -781,8 +775,6 @@ impl Connection {
|
|||||||
log::error!("Failed to start portable service from cm: {:?}", e);
|
log::error!("Failed to start portable service from cm: {:?}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(feature = "flutter")]
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
||||||
ipc::Data::SwitchSidesBack => {
|
ipc::Data::SwitchSidesBack => {
|
||||||
let mut misc = Misc::new();
|
let mut misc = Misc::new();
|
||||||
misc.set_switch_back(SwitchBack::default());
|
misc.set_switch_back(SwitchBack::default());
|
||||||
@@ -2587,7 +2579,6 @@ impl Connection {
|
|||||||
}
|
}
|
||||||
} else if let Some(message::Union::SwitchSidesResponse(_s)) = msg.union {
|
} else if let Some(message::Union::SwitchSidesResponse(_s)) = msg.union {
|
||||||
#[cfg(feature = "flutter")]
|
#[cfg(feature = "flutter")]
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
||||||
if let Some(lr) = _s.lr.clone().take() {
|
if let Some(lr) = _s.lr.clone().take() {
|
||||||
self.handle_login_request_without_validation(&lr).await;
|
self.handle_login_request_without_validation(&lr).await;
|
||||||
SWITCH_SIDES_UUID
|
SWITCH_SIDES_UUID
|
||||||
@@ -3303,13 +3294,8 @@ impl Connection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(feature = "flutter")]
|
#[cfg(feature = "flutter")]
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
||||||
Some(misc::Union::SwitchSidesRequest(s)) => {
|
Some(misc::Union::SwitchSidesRequest(s)) => {
|
||||||
if let Ok(uuid) = uuid::Uuid::from_slice(&s.uuid.to_vec()[..]) {
|
if let Ok(uuid) = uuid::Uuid::from_slice(&s.uuid.to_vec()[..]) {
|
||||||
crate::server::insert_pending_switch_sides_uuid(
|
|
||||||
self.lr.my_id.clone(),
|
|
||||||
uuid.clone(),
|
|
||||||
);
|
|
||||||
crate::run_me(vec![
|
crate::run_me(vec![
|
||||||
"--connect",
|
"--connect",
|
||||||
&self.lr.my_id,
|
&self.lr.my_id,
|
||||||
@@ -4952,8 +4938,6 @@ impl Connection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "flutter")]
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
||||||
pub fn insert_switch_sides_uuid(id: String, uuid: uuid::Uuid) {
|
pub fn insert_switch_sides_uuid(id: String, uuid: uuid::Uuid) {
|
||||||
SWITCH_SIDES_UUID
|
SWITCH_SIDES_UUID
|
||||||
.lock()
|
.lock()
|
||||||
@@ -4961,27 +4945,6 @@ pub fn insert_switch_sides_uuid(id: String, uuid: uuid::Uuid) {
|
|||||||
.insert(id, (tokio::time::Instant::now(), uuid));
|
.insert(id, (tokio::time::Instant::now(), uuid));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "flutter")]
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
||||||
pub fn insert_pending_switch_sides_uuid(id: String, uuid: uuid::Uuid) {
|
|
||||||
let mut uuids = PENDING_SWITCH_SIDES_UUID.lock().unwrap();
|
|
||||||
uuids.retain(|_, (instant, _)| instant.elapsed() < Duration::from_secs(10));
|
|
||||||
uuids.insert(id, (tokio::time::Instant::now(), uuid));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "flutter")]
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
||||||
pub fn remove_pending_switch_sides_uuid(id: &str, uuid: &uuid::Uuid) -> bool {
|
|
||||||
let mut uuids = PENDING_SWITCH_SIDES_UUID.lock().unwrap();
|
|
||||||
uuids.retain(|_, (instant, _)| instant.elapsed() < Duration::from_secs(10));
|
|
||||||
if uuids.get(id).map(|(_, stored_uuid)| stored_uuid == uuid) == Some(true) {
|
|
||||||
uuids.remove(id);
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
async fn start_ipc(
|
async fn start_ipc(
|
||||||
mut rx_to_cm: mpsc::UnboundedReceiver<ipc::Data>,
|
mut rx_to_cm: mpsc::UnboundedReceiver<ipc::Data>,
|
||||||
|
|||||||
@@ -318,35 +318,6 @@ pub fn get_default_shell() -> String {
|
|||||||
std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string())
|
std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn utf8_shell_args(shell: &str) -> Vec<String> {
|
|
||||||
let name = std::path::Path::new(shell)
|
|
||||||
.file_name()
|
|
||||||
.and_then(|name| name.to_str())
|
|
||||||
.unwrap_or(shell)
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
|
|
||||||
if name == "cmd.exe" || name == "cmd" {
|
|
||||||
return vec!["/K".to_string(), "chcp 65001 >NUL".to_string()];
|
|
||||||
}
|
|
||||||
|
|
||||||
if name == "pwsh.exe" || name == "pwsh" || name == "powershell.exe" {
|
|
||||||
return vec![
|
|
||||||
"-NoLogo".to_string(),
|
|
||||||
"-NoExit".to_string(),
|
|
||||||
"-Command".to_string(),
|
|
||||||
"chcp.com 65001 > $null; [Console]::InputEncoding = [System.Text.Encoding]::UTF8; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8".to_string(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn configure_utf8_shell_command(shell: &str, cmd: &mut CommandBuilder) {
|
|
||||||
for arg in utf8_shell_args(shell) {
|
|
||||||
cmd.arg(arg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the SID of the user from a token.
|
/// Get the SID of the user from a token.
|
||||||
/// Returns a Vec<u8> containing the SID bytes.
|
/// Returns a Vec<u8> containing the SID bytes.
|
||||||
pub fn get_user_sid_from_token(user_token: UserToken) -> Result<Vec<u8>> {
|
pub fn get_user_sid_from_token(user_token: UserToken) -> Result<Vec<u8>> {
|
||||||
@@ -860,8 +831,7 @@ pub fn run_terminal_helper(args: &[String]) -> Result<()> {
|
|||||||
let shell = get_default_shell();
|
let shell = get_default_shell();
|
||||||
log::debug!("Using shell: {}", shell);
|
log::debug!("Using shell: {}", shell);
|
||||||
|
|
||||||
let mut cmd = CommandBuilder::new(&shell);
|
let cmd = CommandBuilder::new(&shell);
|
||||||
configure_utf8_shell_command(&shell, &mut cmd);
|
|
||||||
let mut child = pty_pair
|
let mut child = pty_pair
|
||||||
.slave
|
.slave
|
||||||
.spawn_command(cmd)
|
.spawn_command(cmd)
|
||||||
|
|||||||
@@ -20,11 +20,10 @@ use std::{
|
|||||||
// Windows-specific imports from terminal_helper module
|
// Windows-specific imports from terminal_helper module
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
use super::terminal_helper::{
|
use super::terminal_helper::{
|
||||||
configure_utf8_shell_command, create_named_pipe_server, encode_helper_message,
|
create_named_pipe_server, encode_helper_message, encode_resize_message,
|
||||||
encode_resize_message, is_helper_process_running, launch_terminal_helper_with_token,
|
is_helper_process_running, launch_terminal_helper_with_token, wait_for_pipe_connection,
|
||||||
wait_for_pipe_connection, HelperProcessGuard, OwnedHandle, SendableHandle, WinCloseHandle,
|
HelperProcessGuard, OwnedHandle, SendableHandle, WinCloseHandle, WinTerminateProcess,
|
||||||
WinTerminateProcess, WinWaitForSingleObject, MSG_TYPE_DATA, PIPE_CONNECTION_TIMEOUT_MS,
|
WinWaitForSingleObject, MSG_TYPE_DATA, PIPE_CONNECTION_TIMEOUT_MS, WIN_WAIT_OBJECT_0,
|
||||||
WIN_WAIT_OBJECT_0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_OUTPUT_BUFFER_SIZE: usize = 1024 * 1024; // 1MB per terminal
|
const MAX_OUTPUT_BUFFER_SIZE: usize = 1024 * 1024; // 1MB per terminal
|
||||||
@@ -134,26 +133,6 @@ fn get_default_shell() -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
fn locale_value_is_utf8(value: &str) -> bool {
|
|
||||||
let value = value.to_ascii_uppercase();
|
|
||||||
value.contains("UTF-8") || value.contains("UTF8")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
fn should_force_process_utf8_ctype() -> bool {
|
|
||||||
if let Ok(value) = std::env::var("LC_ALL") {
|
|
||||||
return !locale_value_is_utf8(&value);
|
|
||||||
}
|
|
||||||
if let Ok(value) = std::env::var("LC_CTYPE") {
|
|
||||||
return !locale_value_is_utf8(&value);
|
|
||||||
}
|
|
||||||
if let Ok(value) = std::env::var("LANG") {
|
|
||||||
return !locale_value_is_utf8(&value);
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_service_specified_user(service_id: &str) -> Option<bool> {
|
pub fn is_service_specified_user(service_id: &str) -> Option<bool> {
|
||||||
get_service(service_id).map(|s| s.lock().unwrap().is_specified_user)
|
get_service(service_id).map(|s| s.lock().unwrap().is_specified_user)
|
||||||
}
|
}
|
||||||
@@ -456,7 +435,6 @@ impl OutputBuffer {
|
|||||||
// Find first newline in new data
|
// Find first newline in new data
|
||||||
if let Some(newline_pos) = data.iter().position(|&b| b == b'\n') {
|
if let Some(newline_pos) = data.iter().position(|&b| b == b'\n') {
|
||||||
last_line.extend_from_slice(&data[..=newline_pos]);
|
last_line.extend_from_slice(&data[..=newline_pos]);
|
||||||
self.total_size += newline_pos + 1;
|
|
||||||
start = newline_pos + 1;
|
start = newline_pos + 1;
|
||||||
self.last_line_incomplete = false;
|
self.last_line_incomplete = false;
|
||||||
} else {
|
} else {
|
||||||
@@ -495,28 +473,7 @@ impl OutputBuffer {
|
|||||||
// Trim old data if buffer is too large
|
// Trim old data if buffer is too large
|
||||||
while self.total_size > MAX_OUTPUT_BUFFER_SIZE || self.lines.len() > MAX_BUFFER_LINES {
|
while self.total_size > MAX_OUTPUT_BUFFER_SIZE || self.lines.len() > MAX_BUFFER_LINES {
|
||||||
if let Some(removed) = self.lines.pop_front() {
|
if let Some(removed) = self.lines.pop_front() {
|
||||||
if removed.len() > self.total_size {
|
self.total_size -= removed.len();
|
||||||
log::error!(
|
|
||||||
"OutputBuffer total_size underflow avoided: total_size={}, removed_len={}, lines_len={}",
|
|
||||||
self.total_size,
|
|
||||||
removed.len(),
|
|
||||||
self.lines.len()
|
|
||||||
);
|
|
||||||
self.total_size = self.lines.iter().map(|line| line.len()).sum();
|
|
||||||
} else {
|
|
||||||
self.total_size -= removed.len();
|
|
||||||
}
|
|
||||||
if self.lines.is_empty() {
|
|
||||||
self.last_line_incomplete = false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log::error!(
|
|
||||||
"OutputBuffer trim invariant broken: total_size={}, lines_len=0",
|
|
||||||
self.total_size
|
|
||||||
);
|
|
||||||
self.total_size = 0;
|
|
||||||
self.last_line_incomplete = false;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -574,97 +531,6 @@ impl OutputBuffer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find the largest prefix of `buf` that does not end in the middle of a UTF-8
|
|
||||||
/// code point. Invalid bytes are treated as complete so they can continue
|
|
||||||
/// downstream and be rendered with replacement characters if needed.
|
|
||||||
fn find_utf8_split_point(buf: &[u8]) -> usize {
|
|
||||||
if buf.is_empty() {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let start = buf.len().saturating_sub(3);
|
|
||||||
for i in (start..buf.len()).rev() {
|
|
||||||
let b = buf[i];
|
|
||||||
if b & 0x80 == 0 {
|
|
||||||
return buf.len();
|
|
||||||
}
|
|
||||||
if b & 0xC0 == 0x80 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let seq_len = if b & 0xE0 == 0xC0 {
|
|
||||||
2
|
|
||||||
} else if b & 0xF0 == 0xE0 {
|
|
||||||
3
|
|
||||||
} else if b & 0xF8 == 0xF0 {
|
|
||||||
4
|
|
||||||
} else {
|
|
||||||
return buf.len();
|
|
||||||
};
|
|
||||||
|
|
||||||
return if buf.len() - i >= seq_len {
|
|
||||||
buf.len()
|
|
||||||
} else {
|
|
||||||
i
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
buf.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Terminal output currently follows a UTF-8 text model end to end: the service
|
|
||||||
// keeps replay buffers on UTF-8 boundaries, and Flutter decodes payload bytes as
|
|
||||||
// UTF-8 before writing to xterm. This accumulator only prevents splitting a
|
|
||||||
// trailing UTF-8 code point across PTY reads. Supporting non-UTF-8 terminals
|
|
||||||
// would need a separate design covering remote encoding detection, Flutter
|
|
||||||
// decoding, replay truncation, and input transcoding.
|
|
||||||
#[derive(Default)]
|
|
||||||
struct Utf8ChunkAccumulator {
|
|
||||||
remainder: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Utf8ChunkAccumulator {
|
|
||||||
fn push_chunk(&mut self, mut data: Vec<u8>) -> Option<Vec<u8>> {
|
|
||||||
if data.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let had_remainder = !self.remainder.is_empty();
|
|
||||||
if had_remainder {
|
|
||||||
let mut combined = std::mem::take(&mut self.remainder);
|
|
||||||
combined.extend_from_slice(&data);
|
|
||||||
data = combined;
|
|
||||||
}
|
|
||||||
|
|
||||||
let split = find_utf8_split_point(&data);
|
|
||||||
if split == data.len() {
|
|
||||||
return Some(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only hold back a candidate incomplete suffix when we have evidence that
|
|
||||||
// the bytes before it are already UTF-8 text. If split is 0, the whole
|
|
||||||
// read may be the start of a UTF-8 character, so keep it for the next read.
|
|
||||||
if !had_remainder && split > 0 && std::str::from_utf8(&data[..split]).is_err() {
|
|
||||||
return Some(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.remainder = data.split_off(split);
|
|
||||||
if data.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn finish(&mut self) -> Option<Vec<u8>> {
|
|
||||||
if self.remainder.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(std::mem::take(&mut self.remainder))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Try to send data through the output channel with rate-limited drop logging.
|
/// Try to send data through the output channel with rate-limited drop logging.
|
||||||
/// Returns `true` if the caller should break out of the read loop (channel disconnected).
|
/// Returns `true` if the caller should break out of the read loop (channel disconnected).
|
||||||
fn try_send_output(
|
fn try_send_output(
|
||||||
@@ -704,11 +570,7 @@ fn try_send_output(
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
Err(mpsc::TrySendError::Disconnected(_)) => {
|
Err(mpsc::TrySendError::Disconnected(_)) => {
|
||||||
log::debug!(
|
log::debug!("Terminal {}{} output channel disconnected", terminal_id, label);
|
||||||
"Terminal {}{} output channel disconnected",
|
|
||||||
terminal_id,
|
|
||||||
label
|
|
||||||
);
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1075,35 +937,15 @@ impl TerminalServiceProxy {
|
|||||||
if let Some(session_arc) = service.sessions.get(&open.terminal_id) {
|
if let Some(session_arc) = service.sessions.get(&open.terminal_id) {
|
||||||
// Reconnect to existing terminal
|
// Reconnect to existing terminal
|
||||||
let mut session = session_arc.lock().unwrap();
|
let mut session = session_arc.lock().unwrap();
|
||||||
// Directly enter Active state with pending replay for immediate streaming.
|
// Directly enter Active state with pending buffer for immediate streaming.
|
||||||
// The replay combines output_buffer history and the channel backlog that was
|
// Historical buffer is sent first by read_outputs(), then real-time data follows.
|
||||||
// already pending at reconnect time so the client can suppress stale xterm
|
// No overlap: pending_buffer comes from output_buffer (pre-disconnect history),
|
||||||
// query answers without requiring a protobuf schema change.
|
// while received_data in read_outputs() comes from the channel (post-reconnect).
|
||||||
// During disconnect, read_outputs() is not called; channel data can still be lost
|
// During disconnect, the run loop (sp.ok()) exits so read_outputs() stops being
|
||||||
// if output_rx fills before reconnect drains it.
|
// called; output_buffer is not updated, and channel data may be lost if it fills up.
|
||||||
let mut buffer = session
|
let buffer = session
|
||||||
.output_buffer
|
.output_buffer
|
||||||
.get_recent(DEFAULT_RECONNECT_BUFFER_BYTES);
|
.get_recent(DEFAULT_RECONNECT_BUFFER_BYTES);
|
||||||
let mut reconnect_backlog = Vec::new();
|
|
||||||
if let Some(output_rx) = &session.output_rx {
|
|
||||||
// Cap reconnect-time drain so a chatty PTY cannot keep OpenTerminal
|
|
||||||
// inside this loop indefinitely. Remaining output is drained by read_outputs().
|
|
||||||
for _ in 0..CHANNEL_BUFFER_SIZE {
|
|
||||||
let Ok(data) = output_rx.try_recv() else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
reconnect_backlog.push(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let has_reconnect_backlog = !reconnect_backlog.is_empty();
|
|
||||||
for data in reconnect_backlog {
|
|
||||||
session.output_buffer.append(&data);
|
|
||||||
}
|
|
||||||
if has_reconnect_backlog {
|
|
||||||
buffer = session
|
|
||||||
.output_buffer
|
|
||||||
.get_recent(DEFAULT_RECONNECT_BUFFER_BYTES);
|
|
||||||
}
|
|
||||||
let has_pending = !buffer.is_empty();
|
let has_pending = !buffer.is_empty();
|
||||||
session.state = SessionState::Active {
|
session.state = SessionState::Active {
|
||||||
pending_buffer: if has_pending { Some(buffer) } else { None },
|
pending_buffer: if has_pending { Some(buffer) } else { None },
|
||||||
@@ -1117,14 +959,9 @@ impl TerminalServiceProxy {
|
|||||||
let mut opened = TerminalOpened::new();
|
let mut opened = TerminalOpened::new();
|
||||||
opened.terminal_id = open.terminal_id;
|
opened.terminal_id = open.terminal_id;
|
||||||
opened.success = true;
|
opened.success = true;
|
||||||
opened.message = if has_pending {
|
opened.message = "Reconnected to existing terminal".to_string();
|
||||||
"Reconnected to existing terminal with pending output".to_string()
|
|
||||||
} else {
|
|
||||||
"Reconnected to existing terminal".to_string()
|
|
||||||
};
|
|
||||||
opened.pid = session.pid;
|
opened.pid = session.pid;
|
||||||
opened.service_id = self.service_id.clone();
|
opened.service_id = self.service_id.clone();
|
||||||
opened.replay_terminal_output = has_pending;
|
|
||||||
if service.needs_session_sync {
|
if service.needs_session_sync {
|
||||||
if service.sessions.len() > 1 {
|
if service.sessions.len() > 1 {
|
||||||
// No need to include the current terminal in the list.
|
// No need to include the current terminal in the list.
|
||||||
@@ -1179,9 +1016,6 @@ impl TerminalServiceProxy {
|
|||||||
#[allow(unused_mut)]
|
#[allow(unused_mut)]
|
||||||
let mut cmd = CommandBuilder::new(&shell);
|
let mut cmd = CommandBuilder::new(&shell);
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
configure_utf8_shell_command(&shell, &mut cmd);
|
|
||||||
|
|
||||||
// macOS-specific terminal configuration
|
// macOS-specific terminal configuration
|
||||||
// 1. Use login shell (-l) to load user's shell profile (~/.zprofile, ~/.bash_profile)
|
// 1. Use login shell (-l) to load user's shell profile (~/.zprofile, ~/.bash_profile)
|
||||||
// This ensures PATH includes Homebrew paths (/opt/homebrew/bin, /usr/local/bin)
|
// This ensures PATH includes Homebrew paths (/opt/homebrew/bin, /usr/local/bin)
|
||||||
@@ -1202,12 +1036,6 @@ impl TerminalServiceProxy {
|
|||||||
};
|
};
|
||||||
cmd.env("TERM", term);
|
cmd.env("TERM", term);
|
||||||
log::debug!("Set TERM={} for macOS PTY", term);
|
log::debug!("Set TERM={} for macOS PTY", term);
|
||||||
|
|
||||||
if should_force_process_utf8_ctype() {
|
|
||||||
cmd.env_remove("LC_ALL");
|
|
||||||
cmd.env("LC_CTYPE", "en_US.UTF-8");
|
|
||||||
log::debug!("Set LC_CTYPE=en_US.UTF-8 for macOS PTY");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: On Windows with user_token, we use helper mode (handle_open_with_helper)
|
// Note: On Windows with user_token, we use helper mode (handle_open_with_helper)
|
||||||
@@ -1258,7 +1086,6 @@ impl TerminalServiceProxy {
|
|||||||
let reader_thread = thread::spawn(move || {
|
let reader_thread = thread::spawn(move || {
|
||||||
let mut reader = reader;
|
let mut reader = reader;
|
||||||
let mut buf = vec![0u8; 4096];
|
let mut buf = vec![0u8; 4096];
|
||||||
let mut utf8_chunks = Utf8ChunkAccumulator::default();
|
|
||||||
let mut drop_count: u64 = 0;
|
let mut drop_count: u64 = 0;
|
||||||
// Initialize to > 5s ago so the first drop triggers a warning immediately.
|
// Initialize to > 5s ago so the first drop triggers a warning immediately.
|
||||||
let mut last_drop_warn = Instant::now() - Duration::from_secs(6);
|
let mut last_drop_warn = Instant::now() - Duration::from_secs(6);
|
||||||
@@ -1268,25 +1095,13 @@ impl TerminalServiceProxy {
|
|||||||
// EOF
|
// EOF
|
||||||
// This branch can be reached when the child process exits on macOS.
|
// This branch can be reached when the child process exits on macOS.
|
||||||
// But not on Linux and Windows in my tests.
|
// But not on Linux and Windows in my tests.
|
||||||
if let Some(data) = utf8_chunks.finish() {
|
|
||||||
let _ = try_send_output(
|
|
||||||
&output_tx,
|
|
||||||
data,
|
|
||||||
terminal_id,
|
|
||||||
"",
|
|
||||||
&mut drop_count,
|
|
||||||
&mut last_drop_warn,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Ok(n) => {
|
Ok(n) => {
|
||||||
if exiting.load(Ordering::SeqCst) {
|
if exiting.load(Ordering::SeqCst) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let Some(data) = utf8_chunks.push_chunk(buf[..n].to_vec()) else {
|
let data = buf[..n].to_vec();
|
||||||
continue;
|
|
||||||
};
|
|
||||||
// Use try_send to avoid blocking the reader thread when channel is full.
|
// Use try_send to avoid blocking the reader thread when channel is full.
|
||||||
// During disconnect, the run loop (sp.ok()) stops and read_outputs() is
|
// During disconnect, the run loop (sp.ok()) stops and read_outputs() is
|
||||||
// no longer called, so the channel won't be drained. Blocking send would
|
// no longer called, so the channel won't be drained. Blocking send would
|
||||||
@@ -1493,23 +1308,12 @@ impl TerminalServiceProxy {
|
|||||||
let terminal_id = open.terminal_id;
|
let terminal_id = open.terminal_id;
|
||||||
let reader_thread = thread::spawn(move || {
|
let reader_thread = thread::spawn(move || {
|
||||||
let mut buf = vec![0u8; 4096];
|
let mut buf = vec![0u8; 4096];
|
||||||
let mut utf8_chunks = Utf8ChunkAccumulator::default();
|
|
||||||
let mut drop_count: u64 = 0;
|
let mut drop_count: u64 = 0;
|
||||||
// Initialize to > 5s ago so the first drop triggers a warning immediately.
|
// Initialize to > 5s ago so the first drop triggers a warning immediately.
|
||||||
let mut last_drop_warn = Instant::now() - Duration::from_secs(6);
|
let mut last_drop_warn = Instant::now() - Duration::from_secs(6);
|
||||||
loop {
|
loop {
|
||||||
match output_pipe.read(&mut buf) {
|
match output_pipe.read(&mut buf) {
|
||||||
Ok(0) => {
|
Ok(0) => {
|
||||||
if let Some(data) = utf8_chunks.finish() {
|
|
||||||
let _ = try_send_output(
|
|
||||||
&output_tx,
|
|
||||||
data,
|
|
||||||
terminal_id,
|
|
||||||
" (helper)",
|
|
||||||
&mut drop_count,
|
|
||||||
&mut last_drop_warn,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// EOF - helper process exited
|
// EOF - helper process exited
|
||||||
log::debug!("Terminal {} helper output EOF", terminal_id);
|
log::debug!("Terminal {} helper output EOF", terminal_id);
|
||||||
break;
|
break;
|
||||||
@@ -1518,9 +1322,7 @@ impl TerminalServiceProxy {
|
|||||||
if exiting.load(Ordering::SeqCst) {
|
if exiting.load(Ordering::SeqCst) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let Some(data) = utf8_chunks.push_chunk(buf[..n].to_vec()) else {
|
let data = buf[..n].to_vec();
|
||||||
continue;
|
|
||||||
};
|
|
||||||
// Use try_send to avoid blocking the reader thread (same as direct PTY mode)
|
// Use try_send to avoid blocking the reader thread (same as direct PTY mode)
|
||||||
if try_send_output(
|
if try_send_output(
|
||||||
&output_tx,
|
&output_tx,
|
||||||
@@ -1660,28 +1462,20 @@ impl TerminalServiceProxy {
|
|||||||
data: &TerminalData,
|
data: &TerminalData,
|
||||||
) -> Result<Option<TerminalResponse>> {
|
) -> Result<Option<TerminalResponse>> {
|
||||||
if let Some(session_arc) = session {
|
if let Some(session_arc) = session {
|
||||||
let input = {
|
let mut session = session_arc.lock().unwrap();
|
||||||
let mut session = session_arc.lock().unwrap();
|
session.update_activity();
|
||||||
session.update_activity();
|
if let Some(input_tx) = &session.input_tx {
|
||||||
if let Some(input_tx) = session.input_tx.clone() {
|
// Encode data for helper mode or send raw for direct PTY mode
|
||||||
// Encode data for helper mode or send raw for direct PTY mode
|
#[cfg(target_os = "windows")]
|
||||||
#[cfg(target_os = "windows")]
|
let msg = if session.is_helper_mode {
|
||||||
let msg = if session.is_helper_mode {
|
encode_helper_message(MSG_TYPE_DATA, &data.data)
|
||||||
encode_helper_message(MSG_TYPE_DATA, &data.data)
|
|
||||||
} else {
|
|
||||||
data.data.to_vec()
|
|
||||||
};
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
let msg = data.data.to_vec();
|
|
||||||
|
|
||||||
Some((input_tx, msg))
|
|
||||||
} else {
|
} else {
|
||||||
None
|
data.data.to_vec()
|
||||||
}
|
};
|
||||||
};
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let msg = data.data.to_vec();
|
||||||
|
|
||||||
if let Some((input_tx, msg)) = input {
|
// Send data to writer thread
|
||||||
// Send outside the session lock; SyncSender::send can block when full.
|
|
||||||
if let Err(e) = input_tx.send(msg) {
|
if let Err(e) = input_tx.send(msg) {
|
||||||
log::error!(
|
log::error!(
|
||||||
"Failed to send data to terminal {}: {}",
|
"Failed to send data to terminal {}: {}",
|
||||||
@@ -1889,6 +1683,10 @@ impl TerminalServiceProxy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if has_activity {
|
||||||
|
session.update_activity();
|
||||||
|
}
|
||||||
|
|
||||||
// Update buffer (always buffer for reconnection support)
|
// Update buffer (always buffer for reconnection support)
|
||||||
for data in &received_data {
|
for data in &received_data {
|
||||||
session.output_buffer.append(data);
|
session.output_buffer.append(data);
|
||||||
@@ -1898,7 +1696,7 @@ impl TerminalServiceProxy {
|
|||||||
// Data is already buffered above and will be sent on next reconnection.
|
// Data is already buffered above and will be sent on next reconnection.
|
||||||
// Use a scoped block to limit the mutable borrow of session.state,
|
// Use a scoped block to limit the mutable borrow of session.state,
|
||||||
// so we can immutably borrow other session fields afterwards.
|
// so we can immutably borrow other session fields afterwards.
|
||||||
let (replay_buffer, sigwinch_action) = {
|
let sigwinch_action = {
|
||||||
let (pending_buffer, sigwinch) = match &mut session.state {
|
let (pending_buffer, sigwinch) = match &mut session.state {
|
||||||
SessionState::Active {
|
SessionState::Active {
|
||||||
pending_buffer,
|
pending_buffer,
|
||||||
@@ -1907,12 +1705,19 @@ impl TerminalServiceProxy {
|
|||||||
_ => continue,
|
_ => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
let replay_buffer = pending_buffer.take();
|
// Send pending buffer response first (set on reconnection in handle_open).
|
||||||
|
// This ensures historical buffer is sent before any real-time data.
|
||||||
|
if let Some(buffer) = pending_buffer.take() {
|
||||||
|
if !buffer.is_empty() {
|
||||||
|
responses
|
||||||
|
.push(Self::create_terminal_data_response(terminal_id, buffer));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Two-phase SIGWINCH: see SigwinchPhase doc comments for rationale.
|
// Two-phase SIGWINCH: see SigwinchPhase doc comments for rationale.
|
||||||
// Each phase is a single PTY resize, spaced ~30ms apart by the polling
|
// Each phase is a single PTY resize, spaced ~30ms apart by the polling
|
||||||
// interval, ensuring the TUI app sees a real size change on each signal.
|
// interval, ensuring the TUI app sees a real size change on each signal.
|
||||||
let sigwinch_action = match sigwinch {
|
match sigwinch {
|
||||||
SigwinchPhase::TempResize { retries } => {
|
SigwinchPhase::TempResize { retries } => {
|
||||||
if *retries == 0 {
|
if *retries == 0 {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
@@ -1940,19 +1745,8 @@ impl TerminalServiceProxy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
SigwinchPhase::Idle => None,
|
SigwinchPhase::Idle => None,
|
||||||
};
|
|
||||||
(replay_buffer, sigwinch_action)
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(buffer) = replay_buffer {
|
|
||||||
if !buffer.is_empty() {
|
|
||||||
responses.push(Self::create_terminal_data_response(terminal_id, buffer));
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
if has_activity {
|
|
||||||
session.update_activity();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute SIGWINCH resize outside the mutable borrow scope of session.state.
|
// Execute SIGWINCH resize outside the mutable borrow scope of session.state.
|
||||||
if let Some(action) = sigwinch_action {
|
if let Some(action) = sigwinch_action {
|
||||||
@@ -2051,116 +1845,3 @@ impl TerminalServiceProxy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{find_utf8_split_point, OutputBuffer, Utf8ChunkAccumulator, MAX_BUFFER_LINES};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn utf8_split_point_returns_full_len_for_complete_input() {
|
|
||||||
assert_eq!(find_utf8_split_point(b"hello"), 5);
|
|
||||||
assert_eq!(find_utf8_split_point("中文".as_bytes()), "中文".len());
|
|
||||||
assert_eq!(find_utf8_split_point("😀".as_bytes()), "😀".len());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn utf8_split_point_detects_incomplete_trailing_sequence() {
|
|
||||||
let data = [b'a', 0xE4, 0xB8];
|
|
||||||
assert_eq!(find_utf8_split_point(&data), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn utf8_split_point_keeps_malformed_prefix_but_buffers_trailing_lead_byte() {
|
|
||||||
let data = [0xFF, 0xE4];
|
|
||||||
assert_eq!(find_utf8_split_point(&data), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn utf8_split_point_treats_orphan_continuations_as_complete() {
|
|
||||||
let data = [0x80, 0x81, 0x82];
|
|
||||||
assert_eq!(find_utf8_split_point(&data), data.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn utf8_chunk_accumulator_reassembles_split_multibyte_output() {
|
|
||||||
let full = "你好世界".as_bytes();
|
|
||||||
let mut chunker = Utf8ChunkAccumulator::default();
|
|
||||||
let mut output = Vec::new();
|
|
||||||
|
|
||||||
for chunk in full.chunks(5) {
|
|
||||||
if let Some(data) = chunker.push_chunk(chunk.to_vec()) {
|
|
||||||
output.extend_from_slice(&data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(data) = chunker.finish() {
|
|
||||||
output.extend_from_slice(&data);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_eq!(output, full);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn utf8_chunk_accumulator_buffers_leading_split_multibyte_output() {
|
|
||||||
let mut chunker = Utf8ChunkAccumulator::default();
|
|
||||||
|
|
||||||
assert!(chunker.push_chunk(vec![0xE4]).is_none());
|
|
||||||
assert!(chunker.push_chunk(vec![0xB8]).is_none());
|
|
||||||
assert_eq!(
|
|
||||||
chunker.push_chunk(vec![0xAD]),
|
|
||||||
Some("中".as_bytes().to_vec())
|
|
||||||
);
|
|
||||||
assert!(chunker.finish().is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn utf8_chunk_accumulator_flushes_incomplete_tail_on_finish() {
|
|
||||||
let mut chunker = Utf8ChunkAccumulator::default();
|
|
||||||
assert_eq!(chunker.push_chunk(vec![b'a', 0xE4]), Some(vec![b'a']));
|
|
||||||
assert_eq!(chunker.finish(), Some(vec![0xE4]));
|
|
||||||
assert!(chunker.finish().is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn utf8_chunk_accumulator_does_not_stall_on_malformed_bytes() {
|
|
||||||
let mut chunker = Utf8ChunkAccumulator::default();
|
|
||||||
assert_eq!(chunker.push_chunk(vec![0xFF]), Some(vec![0xFF]));
|
|
||||||
assert!(chunker.finish().is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn utf8_chunk_accumulator_buffers_lone_utf8_lead_bytes() {
|
|
||||||
let mut chunker = Utf8ChunkAccumulator::default();
|
|
||||||
assert!(chunker.push_chunk(vec![0xE4]).is_none());
|
|
||||||
assert_eq!(chunker.finish(), Some(vec![0xE4]));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn utf8_chunk_accumulator_does_not_hold_back_non_utf8_prefixes() {
|
|
||||||
let mut chunker = Utf8ChunkAccumulator::default();
|
|
||||||
assert_eq!(chunker.push_chunk(vec![0xFF, 0xE4]), Some(vec![0xFF, 0xE4]));
|
|
||||||
assert!(chunker.finish().is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn output_buffer_trim_after_incomplete_merge_does_not_underflow() {
|
|
||||||
let mut buffer = OutputBuffer::new();
|
|
||||||
|
|
||||||
// Create an incomplete line first.
|
|
||||||
buffer.append(b"hello");
|
|
||||||
|
|
||||||
// Merge a large chunk that contains the first newline at the tail.
|
|
||||||
// This exercises the "append to last incomplete line" branch.
|
|
||||||
let mut large = vec![b'a'; 30_000];
|
|
||||||
large.push(b'\n');
|
|
||||||
buffer.append(&large);
|
|
||||||
|
|
||||||
// Exceed MAX_BUFFER_LINES so trim pops the first large merged line.
|
|
||||||
for _ in 0..=MAX_BUFFER_LINES {
|
|
||||||
buffer.append(b"x\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
let actual_size: usize = buffer.lines.iter().map(|line| line.len()).sum();
|
|
||||||
assert_eq!(buffer.total_size, actual_size);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -464,7 +464,7 @@ pub fn has_active_clients() -> bool {
|
|||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
#[cfg(feature = "flutter")]
|
#[cfg(feature = "flutter")]
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "ios")))]
|
||||||
pub fn switch_back(id: i32) {
|
pub fn switch_back(id: i32) {
|
||||||
if let Some(client) = CLIENTS.read().unwrap().get(&id) {
|
if let Some(client) = CLIENTS.read().unwrap().get(&id) {
|
||||||
allow_err!(client.tx.send(Data::SwitchSidesBack));
|
allow_err!(client.tx.send(Data::SwitchSidesBack));
|
||||||
|
|||||||
@@ -1464,11 +1464,10 @@ impl<T: InvokeUiSession> Session<T> {
|
|||||||
self.send(Data::ElevateWithLogon(username, password));
|
self.send(Data::ElevateWithLogon(username, password));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(target_os = "android", target_os = "ios", not(feature = "flutter")))]
|
#[cfg(any(target_os = "ios"))]
|
||||||
pub fn switch_sides(&self) {}
|
pub fn switch_sides(&self) {}
|
||||||
|
|
||||||
#[cfg(feature = "flutter")]
|
#[cfg(not(any(target_os = "ios")))]
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
||||||
#[tokio::main(flavor = "current_thread")]
|
#[tokio::main(flavor = "current_thread")]
|
||||||
pub async fn switch_sides(&self) {
|
pub async fn switch_sides(&self) {
|
||||||
match crate::ipc::connect(1000, "").await {
|
match crate::ipc::connect(1000, "").await {
|
||||||
|
|||||||
Reference in New Issue
Block a user