* fix(linux): a session logout should hand the peer to the login screen
Logging out closes every window in the session, the connection manager's
included, and its close handler kicks every peer with the reason a person
gets when they disconnect one by hand. That reason is the one thing the
client never retries on, so the remote session dies on a frozen frame
instead of reconnecting to the greeter that is already there.
The close carries nothing to tell the two apart: measured on KDE, the CM
receives no signal and logind still reports the session active at that
instant, and the server is killed within a few hundred ms either way, so
neither a state check nor a grace period can decide it. What is
distinguishable is the ACTION: disconnecting a peer is not the same event
as this window going away. So the window-close path now says so, and the
server ends the session without poisoning the retry; the Disconnect
button and the app's own close control keep kicking exactly as before.
Linux only, since that is where a logout closes the window.
Verified on plasma/sddm with a client attached: a logout now reconnects
to the greeter with no dialog, while closing the manager window still
shows Closed manually by the peer.
* fix(linux): close the tunnel too, and keep the web build compiling
Three seams the first pass missed. The web bridge is hand written, not
generated, so the new call needs its stub there or flutter build web
stops compiling - and that job is disabled in CI, so it would have gone
green. try_port_forward_loop is a second consumer of the same channel
and only knew Close, so a forwarded tunnel outlived the window it was
supposed to die with. And the variant had landed inside the DRM section,
whose comment says everything below it is drm-gated.
* fix(windows): restore keyboard focus when the cursor re-enters the remote image
On Windows the raw key focus node is unfocused on window blur and nothing
requests it back, so returning to an already connected session left the
keyboard dead until the remote image was clicked.
Request focus from enterView(), gated on the window being active, the tab
being selected and no blocking overlay, so a background window cannot grab
system keys. enterOrLeave(true) is still driven by RawKeyFocusScope's
onFocusChange, so it is not called twice.
* fix(windows): refocus on window focus when the cursor already hovers the image
Alt+Tab or a taskbar click returns focus without a PointerEnter, so
enterView() cannot restore the keyboard. Reuse _cursorOverImage, gated
on the selected tab and no blocking overlay.
* refactor(windows): share one focus predicate for every requestFocus path
The relative-mouse-mode restore on window focus could hand remote input
to this page while a blocking dialog was up or the tab was not selected.
* fix(flutter): make Adjust Window reliable across desktop platforms
- Fix incorrect sizing on scaled displays by calculating the target from the
rendered canvas scale and platform-specific window coordinate units.
- Fix adjustments using the wrong monitor by querying the current remote
window's screen, with the main window as fallback.
- Fix stale geometry after fullscreen or maximized transitions by refreshing
metrics before calculating and applying the target frame.
- Fix fullscreen availability checks on Windows and macOS by predicting the
restored window borders and caching each macOS window's pre-fullscreen work area.
- Fix incorrect Linux work areas by handling GNOME Wayland fractional scaling
and caching compositor/X11 work-area measurements when visibleFrame is wrong.
- Prevent unsafe adjustments by rejecting invalid, oversized, or implausibly
small target frames.
- Avoid failures during window teardown by skipping adjustment when the view,
screen, or native window frame is unavailable.
Signed-off-by: 21pages <sunboeasy@gmail.com>
* fix(flutter): harden Adjust Window handling
- Use the dynamic Linux resize edge when predicting restored window bounds.
- Treat GNOME fractional-scaling lookup failures as unknown without repeating
the lookup for the remote window.
- Stop adjustment safely when native window calls fail during window teardown.
Signed-off-by: 21pages <sunboeasy@gmail.com>
* fix(flutter): correct Linux monitor selection
Update window_size to use monitor height for vertical bounds, preventing incorrect screen selection with vertically stacked displays.
Signed-off-by: 21pages <sunboeasy@gmail.com>
* docs(flutter): simplify Linux screen handling comments
Keep the source rationale concise and move platform measurements and investigation details out of the implementation.
Signed-off-by: 21pages <sunboeasy@gmail.com>
* fix(flutter): align Adjust Window resize padding
Use the shared drag-to-resize padding for Linux restored-window predictions so menu validation matches the applied frame dimensions.
Signed-off-by: 21pages <sunboeasy@gmail.com>
* fix(flutter): remove Adjust Window screen fallback
Return null when the current window screen is unavailable instead of using the main window's scale factor and work area.
Signed-off-by: 21pages <sunboeasy@gmail.com>
* fix(linux): query Mutter monitor layout mode
Use DisplayConfig.GetCurrentState instead of inferring scaling from
experimental features, and handle Ubuntu's UI-scaled logical mode.
Signed-off-by: 21pages <sunboeasy@gmail.com>
* fix(flutter): use native maximized state for Wayland cache
Signed-off-by: 21pages <sunboeasy@gmail.com>
* fix(flutter): allow Adjust Window to fill work area
Signed-off-by: 21pages <sunboeasy@gmail.com>
* fix(flutter): avoid racing screen info updates
Signed-off-by: 21pages <sunboeasy@gmail.com>
* refactor(flutter): remove dead Adjust Window web plumbing
Signed-off-by: 21pages <sunboeasy@gmail.com>
* fix(flutter): tolerate near-unity Wayland scale factors
Signed-off-by: 21pages <sunboeasy@gmail.com>
* fix(flutter): harden window screen detection
Signed-off-by: 21pages <sunboeasy@gmail.com>
* fix(linux): drop deprecated GNOME session detection
Signed-off-by: 21pages <sunboeasy@gmail.com>
* fix(flutter): remove GNOME monitor layout mode flutter cache
Signed-off-by: 21pages <sunboeasy@gmail.com>
---------
Signed-off-by: 21pages <sunboeasy@gmail.com>
* fix: hide the printer settings tab when settings are disabled
The Security and Network tabs already honour `disable-settings`, but the
Printer tab was gated only on `hide-remote-printer-settings`, so custom
clients built with settings disabled still exposed it.
https://github.com/rustdesk/rustdesk-server-pro/issues/1001
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: add hide-general-settings builtin option
Hides the General tab of the settings page. Unlike the other
hide-*-settings options this one is still useful when settings are
disabled, since `disable-settings` does not cover the General tab.
https://github.com/rustdesk/rustdesk-server-pro/issues/1001
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(flutter): dispose the settings PageController and order dispose() correctly
`dispose()` began with `super.dispose()`, so the mixin chain marked the State
defunct before the WidgetsBindingObserver registration and the periodic timer
were released. The `PageController` was never disposed at all: `Get.delete`
only runs `onDelete()` for a `GetLifeCycleBase`, and a plain `ChangeNotifier`
is not one, so every open/close of the Settings tab leaked one controller with
its listener still attached.
Also guard `switch2page` on the `Rx<SettingsTabKey>` registration it actually
reads rather than only the `PageController` — now that both are really
deleted, a partial teardown would throw into the catch and silently open the
wrong tab — and re-check `mounted` after the await in the `_videoConnTimer`
tick, which `Timer::cancel` cannot stop once the body has started.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refact: finish the plugin-framework removal sweep
#15854 removed the feature but stopped short of its leftovers:
- `Uninstall`, `Enable`, `Disable`, `Options` and `Please install plugins`
were consumed only by the deleted `flutter/lib/plugin/**`; drop them from
template.rs and the 50 locale files (250 dead entries). `Update` and
`Install` stay, still used by desktop_home_page.dart.
- The server no longer sends `PrvOnFailedPlugin`, and the client no longer
offers to install plugins when privacy mode fails to turn on.
- Drop the MSI `F_Client_Plugins` / `F_Server_Plugins` localization strings;
no `.wxs` references them.
- `_DisplayMenu`'s constructor became a pure pass-through once `pluginItem`
was removed, and the cfg inside `handle_input` repeats the one on the
function itself.
- Normalize `src/lang/sl.rs` to 0644, the only executable file under src/.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): handle legacy privacy mode plugin failures
Signed-off-by: fufesou <linlong1266@gmail.com>
---------
Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
* feat(recording): add visibility and service storage options
- support hide-recording-button in Flutter and Sciter
- allow a custom save directory for Windows service recordings
- sanitize peer IDs used in recording filenames
Tested:
- with hide-recording-button=Y and allow-auto-record-outgoing=Y,
outgoing sessions are recorded automatically while the recording button
remains hidden and cannot be stopped from the UI; verified on Flutter
desktop, Sciter, and Android
- windows-service-video-save-directory takes effect when the Windows client
runs as an installed service
- the Windows controlling side can save recordings for direct IP:port
connections
Signed-off-by: 21pages <sunboeasy@gmail.com>
* update hbb_common
Signed-off-by: 21pages <sunboeasy@gmail.com>
* fix(recording): validate configured save directories
- trim configured recording directory paths
- reject non-absolute paths and fall back to defaults
- warn when a non-empty path is invalid
Signed-off-by: 21pages <sunboeasy@gmail.com>
* fix(recording): validate configured save directories
Signed-off-by: 21pages <sunboeasy@gmail.com>
---------
Signed-off-by: 21pages <sunboeasy@gmail.com>
* Fix Adjust Window sizing across DPI and fullscreen transitions
Signed-off-by: 21pages <sunboeasy@gmail.com>
* Use visible screen frame for Adjust Window
Signed-off-by: 21pages <sunboeasy@gmail.com>
* Tolerate floating-point errors in Adjust Window sizing
Signed-off-by: 21pages <sunboeasy@gmail.com>
* Fix review, fix Adjust Window async metric guards
Capture the current screen before awaiting window geometry so one target-frame calculation uses consistent screen metrics.
Return early when adjusting without a context and the Flutter view list is empty, instead of calling views.first after the window or engine may have been torn
down.
Clarify the platform coordinate units used for Adjust Window scaling.
* Fix Adjust Window for maximized Linux windows
Unmaximize Linux remote windows before applying Adjust Window because native setFrame may be ignored while the window is maximized.
* Fix Adjust Window screen refresh guards
Refresh screen metrics before checking Adjust Window availability and again after exiting fullscreen so target-frame calculation uses current window geometry.
Hide Adjust Window on web because resizing relies on desktop window APIs.
* Fix review, handle missing window frame in Adjust Window
Return null when WindowController.getFrame fails so Adjust Window availability checks and resize attempts skip cleanly if the window is hidden or disposed.
---------
Signed-off-by: 21pages <sunboeasy@gmail.com>
* Feature: add monitor-switch buttons to remote toolbars
Add buttons to cycle through the remote displays from the toolbars:
- A main-toolbar button and a minimized-handle button, both using a shared SVG icon with the current monitor number overlaid.
- Two opt-in settings under Settings/Other. The minimized-toolbar option is nested under the main-toolbar option.
- The minimized button only appears once the toolbar is collapsed.
- Cycling does not move the remote cursor, matching the existing in-toolbar monitor buttons.
Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com>
* fix: respect individual-window display mode when cycling
In "Show displays as individual windows" mode, route the cycle button through openMonitorInNewTabOrWindow like the monitor selector, so each display keeps its own window instead of repurposing the current one.
Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com>
---------
Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com>
* Feature: add monitor-switch buttons to remote toolbars
Add a one-click "switch to next monitor" control to both desktop toolbars:
- Main toolbar: always shown when the remote has more than one monitor,
styled to match the existing blue icon buttons (white screen, black number).
- Minimized (draggable show/hide) toolbar: off by default, toggled via a new
"Show monitor switch on minimized toolbar" checkbox in the Display menu and
persisted as a local option.
Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com>
* Update remote_toolbar.dart
* refact: unify monitor-switch button icons, share tooltip
Addressing the review feedback on the monitor-switch toolbar buttons:
- Add assets/display_switcher.svg and use it for both the main and minimized buttons. This replaces the hand-drawn glyph (Containers + magic numbers) on the main toolbar. The icon scales with DPI/theme and the two toolbars stay visually consistent.
- Flip the minimized button's label to white for contrast, since the new icon has a solid screen.
- Move the tooltip string into a shared _MonitorCycle.tooltip getter so both buttons use one source of truth.
- Use const Offstage() for consistency with the surrounding returns.
Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com>
* Improve monitor-switch settings and toolbar behavior
- Nest the minimized-toolbar option under the main one in settings only show when the main option is enabled.
- Only show the minimized switch button on the collapsed toolbar handle, so it no longer duplicates the main switch while the toolbar is expanded.
Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com>
---------
Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com>
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
* Drag whole toolbar; snap to all four edges
Today the drag handle on the remote-session toolbar repositions only
the handle row -- the icons themselves stay centered at the top. This
change applies the position to the entire toolbar wrapper so dragging
the handle moves the whole thing, and extends snapping from top-only
to any of the four window edges.
When docked left/right the toolbar reflows vertically. A live ghost
preview shows where the toolbar will land while you drag, with a small
hysteresis bias to keep the preview from flickering near corners.
The legacy 'remote-menubar-drag-x' session option is read as a fallback
on first load so existing users keep their saved horizontal position;
new option keys are 'remote-menubar-edge' and 'remote-menubar-frac'.
Tested locally on Windows. macOS / Linux / web desktop use the same
shared widget with no platform-specific calls, but I did not verify
them.
* Load edge independently and clamp loaded fraction
Addresses CodeRabbit review on #15051: parse the saved edge regardless
of whether the new fraction option is present so a partial write of
frac doesn't reset the toolbar back to top, and clamp the loaded
fraction to the kOptionRemoteMenubarDragLeft/Right contract so a
corrupted or out-of-range saved value can't bypass the bounds until
the user drags again.
* Require edge activation zone to switch dock; preserve horizontal slide
Per review feedback on #15051: nearest-edge-wins made a low-intent
horizontal slide too easy to escalate into a high-impact orientation
change (vertical reflow on left/right dock). The default drag now
keeps the toolbar on its current dock edge and just updates the
fraction along that edge -- the prior horizontal-slide behavior.
An alternate edge is only previewed/committed when the cursor enters
its 32 px activation zone; once previewed, the cursor has to move
back 64 px before reverting (hysteresis at the zone boundary).
* Gate multi-edge docking behind a settings toggle; default = horizontal slide
Replaces the activation-zone approach with an explicit opt-in setting
in Settings -> Other ("Allow docking remote toolbar to any window
edge"). This addresses the concern that a low-intent horizontal drag
shouldn't be able to trigger a high-impact orientation change, while
still letting users who want multi-edge docking opt in cleanly.
Default (toggle off):
- The original horizontal slide is preserved.
- The bug fix from the first commit still applies: dragging the
handle moves the whole toolbar, and the position persists across
collapse/expand (no more re-center on re-open).
- Draggable is axis-locked to horizontal so the feedback widget
stays on the top line during drag.
Opt-in (toggle on):
- Full nearest-edge wins with the live preview ghost and corner
hysteresis; toolbar reflows vertically on left/right docks.
- Draggable is unlocked for 2D drag.
Reads the option via mainGetLocalBoolOptionSync so the toolbar's
default state matches what the settings checkbox shows; the option
key uses the allow- prefix so unset defaults to off.
Takes effect on next session (setting is read at session init).
The setting key (allow-multi-edge-toolbar-dock) is read by the
existing local-options machinery and persists per-install without
needing to be registered in libs/hbb_common's KEYS_LOCAL_SETTINGS.
Can add that registration in a parallel hbb_common PR if preferred.
* Fix remote toolbar drag positioning & persistence
Align drag fraction calculation with the toolbar's actual travel range,
keep preview sizing stable during drag, and preserve legacy horizontal
position storage when multi-edge docking is disabled.
Signed-off-by: fufesou <linlong1266@gmail.com>
* Remote toolbar snap edges
1. Translations
2. Apply option to remote windows on changed
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: avoid remote toolbar docking jumps on setting reload
Signed-off-by: fufesou <linlong1266@gmail.com>
* Fix remote toolbar docking updates and drag sync
Signed-off-by: fufesou <linlong1266@gmail.com>
* refact: translation key
Signed-off-by: fufesou <linlong1266@gmail.com>
* feat(toolbar-snap-edges): test web
Signed-off-by: fufesou <linlong1266@gmail.com>
* Fix remote toolbar docking sync and vertical layout
Signed-off-by: fufesou <linlong1266@gmail.com>
* Fix remote toolbar monitor controls on side docks
Signed-off-by: fufesou <linlong1266@gmail.com>
---------
Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
* add option to hide stop-service when service is running
Signed-off-by: 21pages <sunboeasy@gmail.com>
* update hbb_common to upstream
Signed-off-by: 21pages <sunboeasy@gmail.com>
---------
Signed-off-by: 21pages <sunboeasy@gmail.com>
* avatar
* refactor avatar display: unify rendering and resolve at use time
- Extract buildAvatarWidget() in common.dart to share avatar rendering
logic across desktop settings, desktop CM and mobile CM
- Add resolve_avatar_url() in Rust, exposed via FFI (SyncReturn),
to resolve relative avatar paths (e.g. "/avatar/xxx") to absolute URLs
- Store avatar as-is in local config, only resolve when displaying
(settings page) or sending (LoginRequest)
- Resolve avatar in LoginRequest before sending to remote peer
- Add error handling for network image load failures
- Guard against empty client.name[0] crash
- Show avatar in mobile settings page account tile
Signed-off-by: 21pages <sunboeasy@gmail.com>
* web: implement mainResolveAvatarUrl via js getByName
Signed-off-by: 21pages <sunboeasy@gmail.com>
* increase ipc Data enum size limit to 120 bytes
Signed-off-by: 21pages <sunboeasy@gmail.com>
---------
Signed-off-by: 21pages <sunboeasy@gmail.com>
Co-authored-by: 21pages <sunboeasy@gmail.com>
* feat(terminal): add reconnection buffer support for persistent sessions
Fix two related issues:
1. Reconnecting to persistent sessions shows blank screen - server now
automatically sends historical buffer on reconnection via SessionState
machine with pending_buffer, eliminating the need for client-initiated
buffer requests.
2. Terminal output before view ready causes NaN errors - buffer output
chunks on client side until terminal view has valid dimensions, then
flush in order on first valid resize.
Rust side:
- Introduce SessionState enum (Closed/Active) replacing bool is_opened
- Auto-attach pending buffer on reconnection in handle_open()
- Always drain output channel in read_outputs() to prevent overflow
- Increase channel buffer from 100 to 500
- Optimize get_recent() to collect whole chunks (avoids ANSI truncation)
- Extract create_terminal_data_response() helper (DRY)
- Add reconnected flag to TerminalOpened protobuf message
Flutter side:
- Buffer output chunks until terminal view has valid dimensions
- Flush buffered output on first valid resize via _markViewReady()
- Clear terminal on reconnection to avoid duplicate output from buffer replay
- Fix max_bytes type (u32) to match protobuf definition
- Pass reconnected field through FlutterHandler event
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(terminal): add two-phase SIGWINCH for TUI app redraw and session remap on reconnection
Fix TUI apps (top, htop) not redrawing after reconnection. A single
resize-then-restore is too fast for ncurses to detect a size change,
so split across two read_outputs() polling cycles (~30ms apart) to
force a full redraw.
Also fix reconnection failure when client terminal_id doesn't match
any surviving server-side session ID by remapping the lowest surviving
session to the requested ID.
Rust side:
- Add two-phase SIGWINCH state machine (SigwinchPhase: TempResize →
Restore → Idle) with retry logic (max 3 attempts per phase)
- Add do_sigwinch_resize() for cross-platform PTY resize (direct PTY
and Windows helper mode)
- Add session remap logic for non-contiguous terminal_id reconnection
- Extract try_send_output() helper with rate-limited drop logging (DRY)
- Add 3-byte limit to UTF-8 continuation byte skipping in get_recent()
to prevent runaway on non-UTF-8 binary data
- Remove reconnected flag from flutter.rs (unused on client side)
Flutter side:
- Add reconnection screen clear and deferred flush logic
- Filter self from persistent_sessions restore list
- Add comments for web-related changes
Signed-off-by: fufesou <linlong1266@gmail.com>
---------
Signed-off-by: fufesou <linlong1266@gmail.com>
* - 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>
* 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>
- 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>
Terminal tab keys use the format "peerId_terminalId". The previous code
used split('_')[0] or startsWith('$peerId_') to extract the peerId,
which breaks when the peerId itself contains underscores.
This can happen in two scenarios:
- Hostname-based ID: when OPTION_ALLOW_HOSTNAME_AS_ID is enabled, the
peerId is derived from the system hostname, which commonly contains
underscores (e.g. "my_dev_machine").
- Custom ID: the validation regex ^[a-zA-Z][\w-]{5,15}$ allows
underscores since \w matches [a-zA-Z0-9_], so IDs like "my_dev_01"
are valid.
Fix all three parsing sites in terminal_tab_page.dart to use
lastIndexOf('_'), which is safe because terminalId is always a plain
integer with no underscores.