Compare commits

...

4 Commits

Author SHA1 Message Date
bovirus
17a3f2ae52 Italian language update (#14375) 2026-02-23 16:37:53 +08:00
RustDesk
6c3515588f - UI display: display_name first (#14358)
* - UI display: display_name first
  - Fallback: name
  - Technical identity: still name

  ### What changed

  - Added account display helpers and display_name state in user model:
      - flutter/lib/models/user_model.dart:16
  - Account/logout label now uses display_name (@name) when both exist:
      - flutter/lib/mobile/pages/settings_page.dart:689
      - flutter/lib/desktop/pages/desktop_setting_page.dart:2016
      - flutter/lib/desktop/pages/desktop_setting_page.dart:2135
  - Desktop Account info now shows both when applicable:
      - Display Name: ...
      - Username: ...
      - flutter/lib/desktop/pages/desktop_setting_page.dart:2039
  - Previously done group-list behavior remains:
      - group user list displays display_name with name fallback
      - flutter/lib/common/widgets/my_group.dart:187
  - Persistence path for display_name remains enabled (including group cache/submodule field):
      - libs/hbb_common/src/config.rs:2347
  - src/client.rs:2630
  - LoginRequest.my_name now resolves as:
      1. OPTION_DISPLAY_NAME (manual override)
      2. user_info.display_name
      3. user_info.name
      4. OS username fallback

* 1. GUID key (...Uninstall\{GUID}) is MSI-native metadata generated by Windows Installer.
  2. Non-GUID key (...Uninstall\RustDesk) is explicitly written by RustDesk’s MSI compatibility component in res/msi/Package/Components/Regs.wxs:44, populated by preprocess.py --arp from .github/workflows/
     flutter-build.yml:262.

  So they were not using the same EstimatedSize logic:

  - MSI GUID key: MSI-calculated size (KB).
  - RustDesk key: custom script value from res/msi/preprocess.py:339 (previously bytes, now fixed to KB).

  That mismatch is exactly why you saw different sizes.

* improve display name handling

  - Append (@username) when multiple users share the same display name
  - Trim whitespace from display_name before comparison and display
  - Add missing translate() for Logout button on desktop

Signed-off-by: 21pages <sunboeasy@gmail.com>

* group peer filter match both user's display name and user's name

Signed-off-by: 21pages <sunboeasy@gmail.com>

* case-insensitive search in group peer filter

Signed-off-by: 21pages <sunboeasy@gmail.com>

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
Co-authored-by: 21pages <sunboeasy@gmail.com>
2026-02-22 14:59:25 +08:00
fufesou
4d2d2118a2 Fix/terminal tab close persistent (#14359)
* fix(terminal): ensure tab close is resilient to session cleanup failures

- Wrap _closeTerminalSessionIfNeeded in isolated try/catch so that
  tabController.closeBy always executes even if FFI calls throw
- Add clarifying comment in handleWindowCloseButton for single-tab
  audit dialog flow

* fix(terminal): fix session reconnect ID mismatch and tab close race condition

Remap surviving persistent sessions to client-requested terminal IDs on
reconnect, preventing new shell creation when IDs are non-contiguous.
Snapshot peerTabCount before async operations in _closeTab to avoid race
with concurrent _closeAllTabs clearing the tab controller.
Remove debug log statements.

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-02-21 11:06:13 +08:00
fufesou
483fe80308 fix(terminal): fix new tab auto-focus and NaN error on data before layout (#14357)
- Fix new tab not auto-focusing: add FocusNode to TerminalView and
  request focus when tab is selected via tab state listener
- Fix NaN error when data arrives before terminal view layout: buffer
  output data until terminal view has valid dimensions, flush on first
  valid resize callback

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-02-20 14:44:25 +08:00
62 changed files with 459 additions and 67 deletions

View File

@@ -25,6 +25,7 @@ enum UserStatus { kDisabled, kNormal, kUnverified }
// Is all the fields of the user needed?
class UserPayload {
String name = '';
String displayName = '';
String email = '';
String note = '';
String? verifier;
@@ -33,6 +34,7 @@ class UserPayload {
UserPayload.fromJson(Map<String, dynamic> json)
: name = json['name'] ?? '',
displayName = json['display_name'] ?? '',
email = json['email'] ?? '',
note = json['note'] ?? '',
verifier = json['verifier'],
@@ -46,6 +48,7 @@ class UserPayload {
Map<String, dynamic> toJson() {
final Map<String, dynamic> map = {
'name': name,
'display_name': displayName,
'status': status == UserStatus.kDisabled
? 0
: status == UserStatus.kUnverified
@@ -58,9 +61,14 @@ class UserPayload {
Map<String, dynamic> toGroupCacheJson() {
final Map<String, dynamic> map = {
'name': name,
'display_name': displayName,
};
return map;
}
String get displayNameOrName {
return displayName.trim().isEmpty ? name : displayName;
}
}
class PeerPayload {

View File

@@ -158,12 +158,18 @@ class _MyGroupState extends State<MyGroup> {
return Obx(() {
final userItems = gFFI.groupModel.users.where((p0) {
if (searchAccessibleItemNameText.isNotEmpty) {
return p0.name
.toLowerCase()
.contains(searchAccessibleItemNameText.value.toLowerCase());
final search = searchAccessibleItemNameText.value.toLowerCase();
return p0.name.toLowerCase().contains(search) ||
p0.displayNameOrName.toLowerCase().contains(search);
}
return true;
}).toList();
// Count occurrences of each displayNameOrName to detect duplicates
final displayNameCount = <String, int>{};
for (final u in userItems) {
final dn = u.displayNameOrName;
displayNameCount[dn] = (displayNameCount[dn] ?? 0) + 1;
}
final deviceGroupItems = gFFI.groupModel.deviceGroups.where((p0) {
if (searchAccessibleItemNameText.isNotEmpty) {
return p0.name
@@ -177,7 +183,8 @@ class _MyGroupState extends State<MyGroup> {
itemCount: deviceGroupItems.length + userItems.length,
itemBuilder: (context, index) => index < deviceGroupItems.length
? _buildDeviceGroupItem(deviceGroupItems[index])
: _buildUserItem(userItems[index - deviceGroupItems.length]));
: _buildUserItem(userItems[index - deviceGroupItems.length],
displayNameCount));
var maxHeight = max(MediaQuery.of(context).size.height / 6, 100.0);
return Obx(() => stateGlobal.isPortrait.isFalse
? listView(false)
@@ -185,8 +192,14 @@ class _MyGroupState extends State<MyGroup> {
});
}
Widget _buildUserItem(UserPayload user) {
Widget _buildUserItem(UserPayload user, Map<String, int> displayNameCount) {
final username = user.name;
final dn = user.displayNameOrName;
final isDuplicate = (displayNameCount[dn] ?? 0) > 1;
final displayName =
isDuplicate && user.displayName.trim().isNotEmpty
? '${user.displayName} (@$username)'
: dn;
return InkWell(onTap: () {
isSelectedDeviceGroup.value = false;
if (selectedAccessibleItemName.value != username) {
@@ -222,14 +235,14 @@ class _MyGroupState extends State<MyGroup> {
alignment: Alignment.center,
child: Center(
child: Text(
username.characters.first.toUpperCase(),
displayName.characters.first.toUpperCase(),
style: TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
),
),
).marginOnly(right: 4),
if (isMe) Flexible(child: Text(username)),
if (isMe) Flexible(child: Text(displayName)),
if (isMe)
Flexible(
child: Container(
@@ -246,7 +259,7 @@ class _MyGroupState extends State<MyGroup> {
),
),
),
if (!isMe) Expanded(child: Text(username)),
if (!isMe) Expanded(child: Text(displayName)),
],
).paddingSymmetric(vertical: 4),
),

View File

@@ -570,11 +570,14 @@ class MyGroupPeerView extends BasePeersView {
static bool filter(Peer peer) {
final model = gFFI.groupModel;
if (model.searchAccessibleItemNameText.isNotEmpty) {
final text = model.searchAccessibleItemNameText.value;
final searchPeersOfUser = peer.loginName.contains(text) &&
model.users.any((user) => user.name == peer.loginName);
final searchPeersOfDeviceGroup = peer.device_group_name.contains(text) &&
model.deviceGroups.any((g) => g.name == peer.device_group_name);
final text = model.searchAccessibleItemNameText.value.toLowerCase();
final searchPeersOfUser = model.users.any((user) =>
user.name == peer.loginName &&
(user.name.toLowerCase().contains(text) ||
user.displayNameOrName.toLowerCase().contains(text)));
final searchPeersOfDeviceGroup =
peer.device_group_name.toLowerCase().contains(text) &&
model.deviceGroups.any((g) => g.name == peer.device_group_name);
if (!searchPeersOfUser && !searchPeersOfDeviceGroup) {
return false;
}

View File

@@ -2016,7 +2016,9 @@ class _AccountState extends State<_Account> {
Widget accountAction() {
return Obx(() => _Button(
gFFI.userModel.userName.value.isEmpty ? 'Login' : 'Logout',
gFFI.userModel.userName.value.isEmpty
? 'Login'
: '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})',
() => {
gFFI.userModel.userName.value.isEmpty
? loginDialog()
@@ -2037,6 +2039,10 @@ class _AccountState extends State<_Account> {
offstage: gFFI.userModel.userName.value.isEmpty,
child: Column(
children: [
if (gFFI.userModel.displayName.value.trim().isNotEmpty &&
gFFI.userModel.displayName.value.trim() !=
gFFI.userModel.userName.value.trim())
text('Display Name', gFFI.userModel.displayName.value.trim()),
text('Username', gFFI.userModel.userName.value),
// text('Group', gFFI.groupModel.groupName.value),
],
@@ -2130,7 +2136,9 @@ class _PluginState extends State<_Plugin> {
Widget accountAction() {
return Obx(() => _Button(
gFFI.userModel.userName.value.isEmpty ? 'Login' : 'Logout',
gFFI.userModel.userName.value.isEmpty
? 'Login'
: '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})',
() => {
gFFI.userModel.userName.value.isEmpty
? loginDialog()

View File

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

View File

@@ -34,6 +34,8 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
static const IconData selectedIcon = Icons.terminal;
static const IconData unselectedIcon = Icons.terminal_outlined;
int _nextTerminalId = 1;
// Lightweight idempotency guard for async close operations
final Set<String> _closingTabs = {};
_TerminalTabPageState(Map<String, dynamic> params) {
Get.put(DesktopTabController(tabType: DesktopTabType.terminal));
@@ -70,28 +72,12 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
label: tabLabel,
selectedIcon: selectedIcon,
unselectedIcon: unselectedIcon,
onTabCloseButton: () async {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: tabKey,
tabController: tabController,
)) {
return;
}
// Close the terminal session first
final ffi = TerminalConnectionManager.getExistingConnection(peerId);
if (ffi != null) {
final terminalModel = ffi.terminalModels[terminalId];
if (terminalModel != null) {
await terminalModel.closeTerminal();
}
}
// Then close the tab
tabController.closeBy(tabKey);
},
onTabCloseButton: () => _closeTab(tabKey),
page: TerminalPage(
key: ValueKey(tabKey),
id: peerId,
terminalId: terminalId,
tabKey: tabKey,
password: password,
isSharedPassword: isSharedPassword,
tabController: tabController,
@@ -101,6 +87,149 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
);
}
/// Unified tab close handler for all close paths (button, shortcut, programmatic).
/// Shows audit dialog, cleans up session if not persistent, then removes the UI tab.
Future<void> _closeTab(String tabKey) async {
// Idempotency guard: skip if already closing this tab
if (_closingTabs.contains(tabKey)) return;
_closingTabs.add(tabKey);
try {
// Snapshot peerTabCount BEFORE any await to avoid race with concurrent
// _closeAllTabs clearing tabController (which would make the live count
// drop to 0 and incorrectly trigger session persistence).
// Note: the snapshot may become stale if other individual tabs are closed
// during the audit dialog, but this is an acceptable trade-off.
int? snapshotPeerTabCount;
final parsed = _parseTabKey(tabKey);
if (parsed != null) {
final (peerId, _) = parsed;
snapshotPeerTabCount = tabController.state.value.tabs.where((t) {
final p = _parseTabKey(t.key);
return p != null && p.$1 == peerId;
}).length;
}
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: tabKey,
tabController: tabController,
)) {
return;
}
// Close terminal session if not in persistent mode.
// Wrapped separately so session cleanup failure never blocks UI tab removal.
try {
await _closeTerminalSessionIfNeeded(tabKey,
peerTabCount: snapshotPeerTabCount);
} catch (e) {
debugPrint('[TerminalTabPage] Session cleanup failed for $tabKey: $e');
}
// Always close the tab from UI, regardless of session cleanup result
tabController.closeBy(tabKey);
} catch (e) {
debugPrint('[TerminalTabPage] Error closing tab $tabKey: $e');
} finally {
_closingTabs.remove(tabKey);
}
}
/// Close all tabs with session cleanup.
/// Used for window-level close operations (onDestroy, handleWindowCloseButton).
/// UI tabs are removed immediately; session cleanup runs in parallel with a
/// bounded timeout so window close is not blocked indefinitely.
Future<void> _closeAllTabs() async {
final tabKeys = tabController.state.value.tabs.map((t) => t.key).toList();
// Remove all UI tabs immediately (same instant behavior as the old tabController.clear())
tabController.clear();
// 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.
final futures = tabKeys
.where((tabKey) => !_closingTabs.contains(tabKey))
.map((tabKey) async {
try {
await _closeTerminalSessionIfNeeded(tabKey, persistAll: true);
} catch (e) {
debugPrint('[TerminalTabPage] Session cleanup failed for $tabKey: $e');
}
}).toList();
if (futures.isNotEmpty) {
await Future.wait(futures).timeout(
const Duration(seconds: 4),
onTimeout: () {
debugPrint(
'[TerminalTabPage] Session cleanup timed out for batch close');
return [];
},
);
}
}
/// Close the terminal session on server side based on persistent mode.
///
/// [persistAll] controls behavior when persistent mode is enabled:
/// - `true` (window close): persist all sessions, don't close any.
/// - `false` (tab close): only persist the last session for the peer,
/// close others so only the most recent disconnected session survives.
Future<void> _closeTerminalSessionIfNeeded(String tabKey,
{bool persistAll = false, int? peerTabCount}) async {
final parsed = _parseTabKey(tabKey);
if (parsed == null) return;
final (peerId, terminalId) = parsed;
final ffi = TerminalConnectionManager.getExistingConnection(peerId);
if (ffi == null) return;
final isPersistent = bind.sessionGetToggleOptionSync(
sessionId: ffi.sessionId,
arg: kOptionTerminalPersistent,
);
if (isPersistent) {
if (persistAll) {
// Window close: persist all sessions
return;
}
// Tab close: only persist if this is the last tab for this peer.
// Use the snapshot value if provided (avoids race with concurrent tab removal).
final effectivePeerTabCount = peerTabCount ??
tabController.state.value.tabs.where((t) {
final p = _parseTabKey(t.key);
return p != null && p.$1 == peerId;
}).length;
if (effectivePeerTabCount <= 1) {
// Last tab for this peer — persist the session
return;
}
// Not the last tab — fall through to close the session
}
final terminalModel = ffi.terminalModels[terminalId];
if (terminalModel != null) {
// closeTerminal() has internal 3s timeout, no need for external timeout
await terminalModel.closeTerminal();
}
}
/// Parse tabKey (format: "peerId_terminalId") into its components.
/// Note: peerId may contain underscores, so we use lastIndexOf('_').
/// Returns null if tabKey format is invalid.
(String peerId, int terminalId)? _parseTabKey(String tabKey) {
final lastUnderscore = tabKey.lastIndexOf('_');
if (lastUnderscore <= 0) {
debugPrint('[TerminalTabPage] Invalid tabKey format: $tabKey');
return null;
}
final terminalIdStr = tabKey.substring(lastUnderscore + 1);
final terminalId = int.tryParse(terminalIdStr);
if (terminalId == null) {
debugPrint('[TerminalTabPage] Invalid terminalId in tabKey: $tabKey');
return null;
}
final peerId = tabKey.substring(0, lastUnderscore);
return (peerId, terminalId);
}
Widget _tabMenuBuilder(String peerId, CancelFunc cancelFunc) {
final List<MenuEntryBase<String>> menu = [];
const EdgeInsets padding = EdgeInsets.only(left: 8.0, right: 5.0);
@@ -184,7 +313,8 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
} else if (call.method == kWindowEventRestoreTerminalSessions) {
_restoreSessions(call.arguments);
} else if (call.method == "onDestroy") {
tabController.clear();
// Clean up sessions before window destruction (bounded wait)
await _closeAllTabs();
} else if (call.method == kWindowActionRebuild) {
reloadCurrentWindow();
} else if (call.method == kWindowEventActiveSession) {
@@ -268,7 +398,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
// macOS: Cmd+W (standard for close tab)
final currentTab = tabController.state.value.selectedTabInfo;
if (tabController.state.value.tabs.length > 1) {
tabController.closeBy(currentTab.key);
_closeTab(currentTab.key);
return true;
}
} else if (!isMacOS &&
@@ -277,7 +407,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
// Other platforms: Ctrl+Shift+W (to avoid conflict with Ctrl+W word delete)
final currentTab = tabController.state.value.selectedTabInfo;
if (tabController.state.value.tabs.length > 1) {
tabController.closeBy(currentTab.key);
_closeTab(currentTab.key);
return true;
}
}
@@ -356,12 +486,10 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
void _addNewTerminalForCurrentPeer({int? terminalId}) {
final currentTab = tabController.state.value.selectedTabInfo;
final tabKey = currentTab.key;
final lastUnderscore = tabKey.lastIndexOf('_');
if (lastUnderscore > 0) {
final peerId = tabKey.substring(0, lastUnderscore);
_addNewTerminal(peerId, terminalId: terminalId);
}
final parsed = _parseTabKey(currentTab.key);
if (parsed == null) return;
final (peerId, _) = parsed;
_addNewTerminal(peerId, terminalId: terminalId);
}
@override
@@ -375,11 +503,9 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
selectedBorderColor: MyTheme.accent,
labelGetter: DesktopTab.tablabelGetter,
tabMenuBuilder: (key) {
// Extract peerId from tab key (format: "peerId_terminalId")
// Use lastIndexOf to handle peerIds containing underscores
final lastUnderscore = key.lastIndexOf('_');
if (lastUnderscore <= 0) return Container();
final peerId = key.substring(0, lastUnderscore);
final parsed = _parseTabKey(key);
if (parsed == null) return Container();
final (peerId, _) = parsed;
return _tabMenuBuilder(peerId, () {});
},
));
@@ -434,7 +560,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
}
}
if (connLength <= 1) {
tabController.clear();
await _closeAllTabs();
return true;
} else {
final bool res;
@@ -445,7 +571,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
res = await closeConfirmDialog();
}
if (res) {
tabController.clear();
await _closeAllTabs();
}
return res;
}

View File

@@ -688,7 +688,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
SettingsTile(
title: Obx(() => Text(gFFI.userModel.userName.value.isEmpty
? translate('Login')
: '${translate('Logout')} (${gFFI.userModel.userName.value})')),
: '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})')),
leading: Icon(Icons.person),
onPressed: (context) {
if (gFFI.userModel.userName.value.isEmpty) {

View File

@@ -24,6 +24,13 @@ class TerminalModel with ChangeNotifier {
bool _disposed = false;
final _inputBuffer = <String>[];
// Buffer for output data received before terminal view has valid dimensions.
// This prevents NaN errors when writing to terminal before layout is complete.
final _pendingOutputChunks = <String>[];
int _pendingOutputSize = 0;
static const int _kMaxOutputBufferChars = 8 * 1024;
// View ready state: true when terminal has valid dimensions, safe to write
bool _terminalViewReady = false;
bool get isPeerWindows => parent.ffiModel.pi.platform == kPeerPlatformWindows;
@@ -74,6 +81,12 @@ class TerminalModel with ChangeNotifier {
// This piece of code must be placed before the conditional check in order to initialize properly.
onResizeExternal?.call(w, h, pw, ph);
// Mark terminal view as ready and flush any buffered output on first valid resize.
// Must be after onResizeExternal so the view layer has valid dimensions before flushing.
if (!_terminalViewReady) {
_markViewReady();
}
if (_terminalOpened) {
// Notify remote terminal of resize
try {
@@ -141,7 +154,7 @@ class TerminalModel with ChangeNotifier {
debugPrint('[TerminalModel] Error calling sessionOpenTerminal: $e');
// Optionally show error to user
if (e is TimeoutException) {
terminal.write('Failed to open terminal: Connection timeout\r\n');
_writeToTerminal('Failed to open terminal: Connection timeout\r\n');
}
}
}
@@ -283,7 +296,7 @@ class TerminalModel with ChangeNotifier {
}));
}
} else {
terminal.write('Failed to open terminal: $message\r\n');
_writeToTerminal('Failed to open terminal: $message\r\n');
}
}
@@ -327,29 +340,83 @@ class TerminalModel with ChangeNotifier {
return;
}
terminal.write(text);
_writeToTerminal(text);
} catch (e) {
debugPrint('[TerminalModel] Failed to process terminal data: $e');
}
}
}
/// Write text to terminal, buffering if the view is not yet ready.
/// All terminal output should go through this method to avoid NaN errors
/// from writing before the terminal view has valid layout dimensions.
void _writeToTerminal(String text) {
if (!_terminalViewReady) {
// If a single chunk exceeds the cap, keep only its tail.
// Note: truncation may split a multi-byte ANSI escape sequence,
// which can cause a brief visual glitch on flush. This is acceptable
// because it only affects the pre-layout buffering window and the
// terminal will self-correct on subsequent output.
if (text.length >= _kMaxOutputBufferChars) {
final truncated =
text.substring(text.length - _kMaxOutputBufferChars);
_pendingOutputChunks
..clear()
..add(truncated);
_pendingOutputSize = truncated.length;
} else {
_pendingOutputChunks.add(text);
_pendingOutputSize += text.length;
// Drop oldest chunks if exceeds limit (whole chunks to preserve ANSI sequences)
while (_pendingOutputSize > _kMaxOutputBufferChars &&
_pendingOutputChunks.length > 1) {
final removed = _pendingOutputChunks.removeAt(0);
_pendingOutputSize -= removed.length;
}
}
return;
}
terminal.write(text);
}
void _flushOutputBuffer() {
if (_pendingOutputChunks.isEmpty) return;
debugPrint(
'[TerminalModel] Flushing $_pendingOutputSize buffered chars (${_pendingOutputChunks.length} chunks)');
for (final chunk in _pendingOutputChunks) {
terminal.write(chunk);
}
_pendingOutputChunks.clear();
_pendingOutputSize = 0;
}
/// Mark terminal view as ready and flush buffered output.
void _markViewReady() {
if (_terminalViewReady) return;
_terminalViewReady = true;
_flushOutputBuffer();
}
void _handleTerminalClosed(Map<String, dynamic> evt) {
final int exitCode = evt['exit_code'] ?? 0;
terminal.write('\r\nTerminal closed with exit code: $exitCode\r\n');
_writeToTerminal('\r\nTerminal closed with exit code: $exitCode\r\n');
_terminalOpened = false;
notifyListeners();
}
void _handleTerminalError(Map<String, dynamic> evt) {
final String message = evt['message'] ?? 'Unknown error';
terminal.write('\r\nTerminal error: $message\r\n');
_writeToTerminal('\r\nTerminal error: $message\r\n');
}
@override
void dispose() {
if (_disposed) return;
_disposed = true;
// Clear buffers to free memory
_inputBuffer.clear();
_pendingOutputChunks.clear();
_pendingOutputSize = 0;
// Terminal cleanup is handled server-side when service closes
super.dispose();
}

View File

@@ -16,9 +16,23 @@ bool refreshingUser = false;
class UserModel {
final RxString userName = ''.obs;
final RxString displayName = ''.obs;
final RxBool isAdmin = false.obs;
final RxString networkError = ''.obs;
bool get isLogin => userName.isNotEmpty;
String get displayNameOrUserName =>
displayName.value.trim().isEmpty ? userName.value : displayName.value;
String get accountLabelWithHandle {
final username = userName.value.trim();
if (username.isEmpty) {
return '';
}
final preferred = displayName.value.trim();
if (preferred.isEmpty || preferred == username) {
return username;
}
return '$preferred (@$username)';
}
WeakReference<FFI> parent;
UserModel(this.parent) {
@@ -98,7 +112,8 @@ class UserModel {
_updateLocalUserInfo() {
final userInfo = getLocalUserInfo();
if (userInfo != null) {
userName.value = userInfo['name'];
userName.value = (userInfo['name'] ?? '').toString();
displayName.value = (userInfo['display_name'] ?? '').toString();
}
}
@@ -110,10 +125,12 @@ class UserModel {
await gFFI.groupModel.reset();
}
userName.value = '';
displayName.value = '';
}
_parseAndUpdateUser(UserPayload user) {
userName.value = user.name;
displayName.value = user.displayName;
isAdmin.value = user.isAdmin;
bind.mainSetLocalOption(key: 'user_info', value: jsonEncode(user));
if (isWeb) {

View File

@@ -336,7 +336,9 @@ def gen_custom_ARPSYSTEMCOMPONENT_True(args, dist_dir):
f'{indent}<RegistryValue Type="integer" Name="Language" Value="[ProductLanguage]" />\n'
)
estimated_size = get_folder_size(dist_dir)
# EstimatedSize in uninstall registry must be in KB.
estimated_size_bytes = get_folder_size(dist_dir)
estimated_size = max(1, (estimated_size_bytes + 1023) // 1024)
lines_new.append(
f'{indent}<RegistryValue Type="integer" Name="EstimatedSize" Value="{estimated_size}" />\n'
)

View File

@@ -2630,10 +2630,13 @@ impl LoginConfigHandler {
display_name =
serde_json::from_str::<serde_json::Value>(&LocalConfig::get_option("user_info"))
.map(|x| {
x.get("name")
.map(|x| x.as_str().unwrap_or_default())
x.get("display_name")
.and_then(|x| x.as_str())
.map(|x| x.trim())
.filter(|x| !x.is_empty())
.or_else(|| x.get("name").and_then(|x| x.as_str()))
.map(|x| x.to_owned())
.unwrap_or_default()
.to_owned()
})
.unwrap_or_default();
}

View File

@@ -80,6 +80,8 @@ pub enum UserStatus {
pub struct UserPayload {
pub name: String,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub note: Option<String>,
@@ -268,7 +270,12 @@ impl OidcSession {
);
LocalConfig::set_option(
"user_info".to_owned(),
serde_json::json!({ "name": auth_body.user.name, "status": auth_body.user.status }).to_string(),
serde_json::json!({
"name": auth_body.user.name,
"display_name": auth_body.user.display_name,
"status": auth_body.user.status
})
.to_string(),
);
}
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "متابعة مع {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "Працягнуць з {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "Продължи с {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "Continua amb {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "使用 {} 登录"),
("Display Name", "显示名称"),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "Pokračovat s {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "Fortsæt med {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", "Bildschirm während ausgehender Sitzungen aktiv halten"),
("keep-awake-during-incoming-sessions-label", "Bildschirm während eingehender Sitzungen aktiv halten"),
("Continue with {}", "Fortfahren mit {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "Συνέχεια με {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", ""),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "Continuar con {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "Jätka koos {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "{} honekin jarraitu"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "ادامه با {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "Jatka käyttäen {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", "Maintenir lécran allumé lors des sessions sortantes"),
("keep-awake-during-incoming-sessions-label", "Maintenir lécran allumé lors des sessions entrantes"),
("Continue with {}", "Continuer avec {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "{}-ით გაგრძელება"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "המשך עם {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "Nastavi sa {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", "Képernyő aktív állapotban tartása a kimenő munkamenetek során"),
("keep-awake-during-incoming-sessions-label", "Képernyő aktív állapotban tartása a bejövő munkamenetek során"),
("Continue with {}", "Folytatás a következővel: {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "Lanjutkan dengan {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", "Mantieni lo schermo attivo durante le sessioni in uscita"),
("keep-awake-during-incoming-sessions-label", "Mantieni lo schermo attivo durante le sessioni in ingresso"),
("Continue with {}", "Continua con {}"),
("Display Name", "Visualizza nome"),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "{} で続行"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", "발신 세션 중 화면 켜짐 유지"),
("keep-awake-during-incoming-sessions-label", "수신 세션 중 화면 켜짐 유지"),
("Continue with {}", "{}(으)로 계속"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", ""),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "Tęsti su {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "Turpināt ar {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "Fortsett med {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", "Houd het scherm open tijdens de uitgaande sessies."),
("keep-awake-during-incoming-sessions-label", "Houd het scherm open tijdens de inkomende sessies."),
("Continue with {}", "Ga verder met {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", "Utrzymuj urządzenie w stanie aktywnym podczas sesji wychodzących"),
("keep-awake-during-incoming-sessions-label", "Utrzymuj urządzenie w stanie aktywnym podczas sesji przychodzących"),
("Continue with {}", "Kontynuuj z {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", ""),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", "Manter tela ativa durante sessões de saída"),
("keep-awake-during-incoming-sessions-label", "Manter tela ativa durante sessões de entrada"),
("Continue with {}", "Continuar com {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "Continuă cu {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", "Не отключать экран во время исходящих сеансов"),
("keep-awake-during-incoming-sessions-label", "Не отключать экран во время входящих сеансов"),
("Continue with {}", "Продолжить с {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "Sighi cun {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "Pokračovať s {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "Nadaljuj z {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "Vazhdo me {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "Nastavi sa {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "Fortsätt med {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "{} உடன் தொடர்"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", ""),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "ทำต่อด้วย {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", "Giden oturumlar süresince ekranıık tutun"),
("keep-awake-during-incoming-sessions-label", "Gelen oturumlar süresince ekranıık tutun"),
("Continue with {}", "{} ile devam et"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", "在連出工作階段期間保持螢幕喚醒"),
("keep-awake-during-incoming-sessions-label", "在連入工作階段期間保持螢幕喚醒"),
("Continue with {}", "使用 {} 登入"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "Продовжити з {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -739,5 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-outgoing-sessions-label", ""),
("keep-awake-during-incoming-sessions-label", ""),
("Continue with {}", "Tiếp tục với {}"),
("Display Name", ""),
].iter().cloned().collect();
}

View File

@@ -777,6 +777,32 @@ impl TerminalServiceProxy {
) -> Result<Option<TerminalResponse>> {
let mut response = TerminalResponse::new();
// When the client requests a terminal_id that doesn't exist but there are
// surviving persistent sessions, remap the lowest-ID session to the requested
// terminal_id. This handles the case where _nextTerminalId resets to 1 on
// reconnect but the server-side sessions have non-contiguous IDs (e.g. {2: htop}).
//
// The client's requested terminal_id may not match any surviving session ID
// (e.g. _nextTerminalId incremented beyond the surviving IDs). This remap is a
// one-time handle reassignment — only the first reconnect triggers it because
// needs_session_sync is cleared afterward. Remaining sessions are communicated
// back via `persistent_sessions` with their original server-side IDs.
if !service.sessions.contains_key(&open.terminal_id)
&& service.needs_session_sync
&& !service.sessions.is_empty()
{
if let Some(&lowest_id) = service.sessions.keys().min() {
log::info!(
"Remapping persistent session {} -> {} for reconnection",
lowest_id,
open.terminal_id
);
if let Some(session_arc) = service.sessions.remove(&lowest_id) {
service.sessions.insert(open.terminal_id, session_arc);
}
}
}
// Check if terminal already exists
if let Some(session_arc) = service.sessions.get(&open.terminal_id) {
// Reconnect to existing terminal
@@ -824,7 +850,7 @@ impl TerminalServiceProxy {
// Create new terminal session
log::info!(
"Creating new terminal {} for service: {}",
"Creating new terminal {} for service {}",
open.terminal_id,
service.service_id
);

View File

@@ -358,6 +358,22 @@ function getUserName() {
return '';
}
function getAccountLabelWithHandle() {
try {
var user = JSON.parse(handler.get_local_option("user_info"));
var username = (user.name || '').trim();
if (!username) {
return '';
}
var displayName = (user.display_name || '').trim();
if (!displayName || displayName == username) {
return username;
}
return displayName + " (@" + username + ")";
} catch(e) {}
return '';
}
// Shared dialog functions
function open_custom_server_dialog() {
var configOptions = handler.get_options();
@@ -493,7 +509,7 @@ class MyIdMenu: Reactor.Component {
}
function renderPop() {
var username = handler.get_local_option("access_token") ? getUserName() : '';
var accountLabel = handler.get_local_option("access_token") ? getAccountLabelWithHandle() : '';
return <popup>
<menu.context #config-options>
{!disable_settings && <li #enable-keyboard><span>{svg_checkmark}</span>{translate('Enable keyboard/mouse')}</li>}
@@ -521,8 +537,8 @@ class MyIdMenu: Reactor.Component {
{!disable_settings && <DirectServer />}
{!disable_settings && false && handler.using_public_server() && <li #allow-always-relay><span>{svg_checkmark}</span>{translate('Always connect via relay')}</li>}
{!disable_change_id && handler.is_ok_change_id() ? <div .separator /> : ""}
{!disable_account && (username ?
<li #logout>{translate('Logout')} ({username})</li> :
{!disable_account && (accountLabel ?
<li #logout>{translate('Logout')} ({accountLabel})</li> :
<li #login>{translate('Login')}</li>)}
{!disable_change_id && !disable_settings && handler.is_ok_change_id() && key_confirmed && connect_status > 0 ? <li #change-id>{translate('Change ID')}</li> : ""}
<div .separator />
@@ -1430,6 +1446,9 @@ checkConnectStatus();
function set_local_user_info(user) {
var user_info = {name: user.name};
if (user.display_name) {
user_info.display_name = user.display_name;
}
if (user.status) {
user_info.status = user.status;
}