feat(mobile): inline persistent terminal — multiple tabs beside the live screen

Add an embeddable, multi-tab persistent terminal to the mobile remote session:
open one or more terminal tabs in a resizable sheet over the live screen stream,
all on a single authenticated connection. This lets you watch the remote screen
and run shell commands at the same time — like an SSH+tmux split, built into
RustDesk with no external wrapper.

Client-only: no changes under src/ or libs/ — the server and protocol stay
stock. The feature reuses RustDesk's native terminal stack (TerminalModel + the
`terminal-persistent` option), so sessions survive disconnect/idle and reattach
on reconnect.

Highlights:
- Multiple tabs over ONE connection (TerminalConnectionManager keeps one FFI per
  peer, refcounted); each tab is a distinct terminal_id, one live TerminalView
  per tab in an IndexedStack.
- Persistent sessions: on (re)connect the server's surviving session ids are
  offered as tabs to reattach; `exit` closes the tab and reaps the session.
- Keyboard routed to the terminal while it's open (not the remote desktop), with
  a Termius-style accessory bar: sticky Ctrl/Alt that combine with the next key.
- One Dark theme; resizable/maximizable sheet that stays clear of the system
  bars and the soft keyboard.
- Hardening of the shared terminal stack it builds on: use-after-dispose guards,
  identity-checked event routing, an error-safe per-peer connection pool, and
  guarded padding math.

The desktop split-view reuses the same InlineTerminalPanel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Artur Puig 2026-06-21 16:15:52 +02:00
commit 3ce8e2cf1b
10 changed files with 1205 additions and 61 deletions

View file

@ -0,0 +1,733 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/terminal_model.dart';
import 'package:xterm/xterm.dart';
import 'terminal_connection_manager.dart';
/// An embeddable terminal panel that shows one or more persistent terminal
/// tabs over a single shared terminal connection to [peerId].
///
/// It reuses RustDesk's native terminal stack (TerminalModel + the
/// `terminal-persistent` option), so sessions survive disconnect/idle and
/// reattach on reconnect no tmux/SSH wrapper, no server/proto changes.
class InlineTerminalPanel extends StatefulWidget {
final String peerId;
final String? password;
final bool? isSharedPassword;
final bool? forceRelay;
// Called when the user closes the last tab the host closes the terminal UI
// (we don't silently spawn a replacement session).
final VoidCallback? onClose;
const InlineTerminalPanel({
Key? key,
required this.peerId,
this.password,
this.isSharedPassword,
this.forceRelay,
this.onClose,
}) : super(key: key);
@override
State<InlineTerminalPanel> createState() => _InlineTerminalPanelState();
}
class _TerminalTab {
final int id;
final TerminalModel model;
final FocusNode focusNode;
bool ready = false;
// True once the remote shell has exited (e.g. the user typed `exit`).
bool closed = false;
VoidCallback? listener;
_TerminalTab({
required this.id,
required this.model,
required this.focusNode,
});
}
class _InlineTerminalPanelState extends State<InlineTerminalPanel> {
// Offset terminal ids to avoid colliding with a standalone terminal window
// that may share the same per-peer connection.
static const int _baseTerminalId = 900;
late final FFI _ffi;
final List<_TerminalTab> _tabs = [];
int _selectedTabIndex = 0;
int _nextTabId = 0;
// True once the shared connection has come up (first terminal opened).
bool _connReady = false;
// Real terminal cell height (px), reported by the model on resize; used for
// vertical padding instead of a hardcoded guess.
double _cellHeight = 18.0;
// A calm, readable One Dark-inspired palette for a tidy, "Termius-like" look
// (intentionally no settings UI). Background matches the panel chrome so the
// split view stays cohesive.
static const TerminalTheme _theme = TerminalTheme(
cursor: Color(0xFF61AFEF),
selection: Color(0x553B4252),
foreground: Color(0xFFD7DAE0),
background: Color(0xFF1E1E1E),
black: Color(0xFF21252B),
red: Color(0xFFE06C75),
green: Color(0xFF98C379),
yellow: Color(0xFFE5C07B),
blue: Color(0xFF61AFEF),
magenta: Color(0xFFC678DD),
cyan: Color(0xFF56B6C2),
white: Color(0xFFABB2BF),
brightBlack: Color(0xFF5C6370),
brightRed: Color(0xFFE06C75),
brightGreen: Color(0xFF98C379),
brightYellow: Color(0xFFE5C07B),
brightBlue: Color(0xFF61AFEF),
brightMagenta: Color(0xFFC678DD),
brightCyan: Color(0xFF56B6C2),
brightWhite: Color(0xFFFFFFFF),
searchHitBackground: Color(0xFFFFFF2B),
searchHitBackgroundCurrent: Color(0xFF31FF26),
searchHitForeground: Color(0xFF000000),
);
// Slightly larger than xterm's default (13) with comfortable line height for
// legibility on a phone-sized split view.
static const TerminalStyle _textStyle =
TerminalStyle(fontSize: 14, height: 1.3);
// Show the on-screen special-keys bar (Esc/Ctrl/Alt/arrows/). Honours the
// same option as the stock mobile terminal (default on).
late final bool _showExtraKeys;
// Sticky modifiers (Termius-style): tap Ctrl/Alt to arm it, the next key
// from this bar OR the system keyboard combines with it, then it releases.
bool _ctrlActive = false;
bool _altActive = false;
@override
void initState() {
super.initState();
_showExtraKeys = !isWebDesktop &&
mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
// Establish the terminal connection exactly like the stock mobile terminal
// (peer_card -> connect(isTerminal:true) -> TerminalPage): a plain
// getConnection + registered TerminalModel. No connToken / persistence
// toggle / event-callback routing here those broke the connection.
_ffi = TerminalConnectionManager.getConnection(
peerId: widget.peerId,
password: widget.password,
isSharedPassword: widget.isSharedPassword,
forceRelay: widget.forceRelay,
);
// If we reused an already-connected FFI (e.g. a second panel on the same
// peer), peer_info won't fire again, so the FFI "ready" event that normally
// drives the first OpenTerminal never comes. Seed _connReady from the live
// connection so the first tab opens directly instead of hanging on
// "Connecting…".
_connReady = _ffi.ffiModel.pi.isSet.value;
_ensurePersistent();
_addTab();
}
/// Enable RustDesk's native persistent-terminal option so sessions survive
/// disconnect/idle and reattach on reconnect. This only flips a local config
/// bool + queues an option message; it does NOT restart the connection.
void _ensurePersistent() {
try {
final on = bind.sessionGetToggleOptionSync(
sessionId: _ffi.sessionId,
arg: kOptionTerminalPersistent,
);
if (!on) {
bind.sessionToggleOption(
sessionId: _ffi.sessionId,
value: kOptionTerminalPersistent,
);
}
} catch (e) {
debugPrint('[InlineTerminalPanel] Failed to enable persistence: $e');
}
}
@override
void dispose() {
for (final tab in _tabs) {
_disposeTab(tab);
}
_tabs.clear();
// Release this panel's single reference to the shared connection.
TerminalConnectionManager.releaseConnection(widget.peerId);
super.dispose();
}
void _disposeTab(_TerminalTab tab) {
if (tab.listener != null) {
tab.model.removeListener(tab.listener!);
}
_ffi.unregisterTerminalModel(tab.id, tab.model);
tab.model.dispose();
tab.focusNode.dispose();
}
void _addTab() {
_addTabWithId(_baseTerminalId + _nextTabId);
_nextTabId++;
}
/// Create a tab bound to a specific server-side terminal_id. Used for new
/// tabs and for restoring surviving persistent sessions after a reconnect.
/// All tabs share ONE authenticated connection (no per-tab re-login).
void _addTabWithId(int terminalId, {bool selectNew = true}) {
if (_tabs.any((t) => t.id == terminalId)) return; // already shown
final model = TerminalModel(_ffi, terminalId);
// Focusable from birth so the tab can always receive keyboard input.
final focusNode = FocusNode();
// Track the real cell height; only rebuild on an actual change (not every
// resize frame) so the padding stays correct without churn.
model.onResizeExternal = (w, h, pw, ph) {
if (ph > 0 && _cellHeight != ph) {
_cellHeight = ph.toDouble();
if (mounted) setState(() {});
}
};
// Surface other surviving sessions so we can restore them as tabs.
model.onPersistentSessions = _restorePersistentSessions;
// Apply sticky Ctrl/Alt to keystrokes coming from the system keyboard.
model.inputTransform = _applyModifiers;
final tab = _TerminalTab(
id: terminalId,
model: model,
focusNode: focusNode,
);
tab.listener = () {
if (!mounted) return;
if (model.terminalOpened) {
_connReady = true;
if (!tab.ready || tab.closed) {
setState(() {
tab.ready = true;
tab.closed = false;
});
// Grab the keyboard once the selected tab is actually up.
if (_tabs.isNotEmpty &&
_selectedTabIndex < _tabs.length &&
_tabs[_selectedTabIndex] == tab) {
_focusSelected();
}
}
} else if (tab.ready && !tab.closed) {
// The terminal reported closed. Show the "Session closed / Restart"
// banner but KEEP the tab a `closed` may be spurious (saturated
// output channel, transient reconnect), and we must never make a
// session vanish on its own. The tab goes away only via an explicit ×
// (with confirm); Restart reattaches/relaunches in place.
setState(() => tab.closed = true);
}
};
model.addListener(tab.listener!);
// Registering lets the FFI drive open()/reattach() on connect AND on
// reconnect (re-sends OpenTerminal(force) reattaches to the persistent
// session and replays output).
_ffi.registerTerminalModel(terminalId, model);
_tabs.add(tab);
if (selectNew) _selectedTabIndex = _tabs.length - 1;
// The FFI "ready" event only opens models registered before it fired. A tab
// added after the connection is already up must be opened directly (same
// connection, no re-login). Guard against the tab being removed meanwhile.
if (_connReady) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _tabs.contains(tab)) model.onReady();
});
}
if (mounted) setState(() {});
if (selectNew) _focusSelected();
}
/// On (re)connect the server reports surviving persistent session ids; show
/// each as a tab so you can switch to whichever you want. Cascades until all
/// survivors are restored; ids already shown are skipped.
void _restorePersistentSessions(List<int> ids) {
// We are inside a successful 'opened' callback the shared connection is
// up. Mark it ready so restored tabs are opened directly (they must send
// OpenTerminal to actually reattach; otherwise input is silently buffered).
_connReady = true;
for (final id in ids) {
if (_tabs.any((t) => t.id == id)) continue;
final offset = id - _baseTerminalId;
if (offset >= _nextTabId) _nextTabId = offset + 1; // avoid id collisions
_addTabWithId(id, selectNew: false);
}
}
Future<void> _closeTab(int index) async {
if (index < 0 || index >= _tabs.length) return;
final tab = _tabs[index];
// Confirm only when killing a session that is actually alive.
if (tab.ready && !tab.closed) {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(translate('Close')),
content: Text('${translate('Close')} "Tab ${index + 1}"?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(translate('Cancel'))),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(translate('OK'))),
],
),
);
if (confirmed != true || !mounted) return;
}
// An explicit × means "end this session for good", so always reap it
// server-side (force covers a closed/hung tab whose model isn't "opened").
await _removeTab(tab, reap: true);
}
/// Dispose a tab and drop it from the bar, optionally reaping its server-side
/// session first. Removing the last tab calls onClose (the host closes the
/// terminal UI) rather than spawning a replacement. closeTerminal is awaited
/// before dispose so its post-RPC notifyListeners() can't hit a disposed
/// ChangeNotifier.
Future<void> _removeTab(_TerminalTab tab, {bool reap = false}) async {
if (reap) {
await tab.model.closeTerminal(force: true);
if (!mounted) return;
}
// Re-find by identity _tabs may have changed during the await.
final i = _tabs.indexOf(tab);
if (i < 0) return;
_disposeTab(tab);
_tabs.removeAt(i);
if (_selectedTabIndex >= _tabs.length) {
_selectedTabIndex = _tabs.isEmpty ? 0 : _tabs.length - 1;
}
if (mounted) setState(() {});
if (_tabs.isEmpty) {
// No silent replacement session let the host close the terminal UI.
widget.onClose?.call();
} else {
_focusSelected();
}
}
/// Move keyboard focus to the selected tab's terminal (after the next frame,
/// once its view is laid out). Keeps typing going to the visible terminal.
void _focusSelected() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _tabs.isEmpty) return;
final i = _selectedTabIndex.clamp(0, _tabs.length - 1);
_tabs[i].focusNode.requestFocus();
});
}
EdgeInsets _calculatePadding(double heightPx) {
const defaultPadding = EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
final cell = _cellHeight > 0 ? _cellHeight : 18.0;
final rows = (heightPx / cell).floor();
if (rows <= 0) return defaultPadding;
final extraSpace = heightPx - rows * cell;
if (!extraSpace.isFinite || extraSpace < 0) return defaultPadding;
return EdgeInsets.symmetric(
horizontal: defaultPadding.horizontal / 2,
vertical: extraSpace / 2.0,
);
}
@override
Widget build(BuildContext context) {
final hasTabs = _tabs.isNotEmpty;
final selIndex =
hasTabs ? _selectedTabIndex.clamp(0, _tabs.length - 1) : 0;
return Container(
color: const Color(0xFF1E1E1E),
child: Column(
children: [
_buildTabBar(),
if (hasTabs)
Expanded(
// One TerminalView per tab, all kept alive. Switching tabs only
// changes which is shown we never swap a terminal underneath a
// single view (that broke repaint/focus and dropped keystrokes).
child: IndexedStack(
index: selIndex,
sizing: StackFit.expand,
children: [
for (final tab in _tabs)
KeyedSubtree(
key: ValueKey(tab.id),
child: _buildTerminalView(tab),
),
],
),
),
if (_showExtraKeys && hasTabs) _buildExtraKeys(_tabs[selIndex]),
],
),
);
}
// A single tab's terminal view (kept alive inside the IndexedStack). Focus is
// managed explicitly via _focusSelected, so autofocus stays off here (else the
// offstage tabs would fight over focus).
Widget _buildTerminalView(_TerminalTab tab) {
return LayoutBuilder(
builder: (context, constraints) {
final view = TerminalView(
tab.model.terminal,
controller: tab.model.terminalController,
focusNode: tab.focusNode,
autofocus: false,
theme: _theme,
textStyle: _textStyle,
backgroundOpacity: 0.7,
padding: _calculatePadding(constraints.maxHeight),
onSecondaryTapDown: (details, offset) async {
final selection = tab.model.terminalController.selection;
if (selection != null) {
final text = tab.model.terminal.buffer.getText(selection);
tab.model.terminalController.clearSelection();
await Clipboard.setData(ClipboardData(text: text));
} else {
final data = await Clipboard.getData('text/plain');
final text = data?.text;
if (text != null) {
tab.model.terminal.paste(text);
}
}
},
);
return RepaintBoundary(
child: Stack(
children: [
view,
if (!tab.ready && !tab.closed)
Positioned.fill(child: _connectingView()),
if (tab.closed)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _closedBanner(tab),
),
],
),
);
},
);
}
// Escape sequences for the bar's non-printable keys.
static const Map<String, String> _keySequences = {
'Esc': '\x1B',
'Tab': '\t',
'': '\x1B[A',
'': '\x1B[B',
'': '\x1B[C',
'': '\x1B[D',
'Home': '\x1B[H',
'End': '\x1B[F',
'PgUp': '\x1B[5~',
'PgDn': '\x1B[6~',
};
// A Termius-style accessory bar: one scrollable row docked above the system
// keyboard, with sticky Ctrl/Alt that highlight while armed and combine with
// the next key (this bar's or the system keyboard's).
Widget _buildExtraKeys(_TerminalTab tab) {
return Container(
decoration: const BoxDecoration(
color: Color(0xFF161618),
border: Border(top: BorderSide(color: Color(0xFF333336), width: 1)),
),
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 6),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
child: Row(
children: [
_keyCap(tab, label: 'esc', onTap: () => _sendKey(tab, 'Esc')),
_keyCap(tab, label: 'ctrl', active: _ctrlActive, onTap: () => _toggleMod(ctrl: true)),
_keyCap(tab, label: 'alt', active: _altActive, onTap: () => _toggleMod(ctrl: false)),
_keyCap(tab, label: 'tab', onTap: () => _sendKey(tab, 'Tab')),
_barSeparator(),
_keyCap(tab, icon: Icons.west, onTap: () => _sendKey(tab, '')),
_keyCap(tab, icon: Icons.north, onTap: () => _sendKey(tab, '')),
_keyCap(tab, icon: Icons.south, onTap: () => _sendKey(tab, '')),
_keyCap(tab, icon: Icons.east, onTap: () => _sendKey(tab, '')),
_barSeparator(),
for (final s in const ['-', '/', '|', '~', '`'])
_keyCap(tab, label: s, onTap: () => _sendKey(tab, s)),
_barSeparator(),
for (final k in const ['Home', 'End', 'PgUp', 'PgDn'])
_keyCap(tab, label: k, onTap: () => _sendKey(tab, k)),
],
),
),
);
}
Widget _barSeparator() => Container(
width: 1,
height: 18,
margin: const EdgeInsets.symmetric(horizontal: 7),
color: const Color(0xFF38383B),
);
Widget _keyCap(
_TerminalTab tab, {
String? label,
IconData? icon,
bool active = false,
required VoidCallback onTap,
}) {
final fg = active ? Colors.white : const Color(0xFFCED0D4);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: Material(
color: active ? const Color(0xFF3B6FE0) : const Color(0xFF2B2B2E),
borderRadius: BorderRadius.circular(8),
child: InkWell(
borderRadius: BorderRadius.circular(8),
// Never take focus from the terminal otherwise the soft keyboard
// closes and the armed modifier can't combine with the next key.
canRequestFocus: false,
onTap: () {
onTap();
// Keep the terminal focused so the keyboard stays up and the next
// system-keyboard key reaches it (with the modifier applied).
if (!tab.focusNode.hasFocus) tab.focusNode.requestFocus();
},
child: Container(
height: 34,
constraints: const BoxConstraints(minWidth: 42),
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 11),
child: icon != null
? Icon(icon, size: 17, color: fg)
: Text(
label!,
style: TextStyle(
color: fg,
fontSize: 13,
height: 1.0,
fontWeight: active ? FontWeight.w700 : FontWeight.w500,
),
),
),
),
),
);
}
void _toggleMod({required bool ctrl}) {
setState(() {
if (ctrl) {
_ctrlActive = !_ctrlActive;
} else {
_altActive = !_altActive;
}
});
}
void _sendKey(_TerminalTab tab, String label) {
final raw = _keySequences[label] ?? label;
tab.model.sendVirtualKey(_applyModifiers(raw));
}
/// Apply any armed sticky modifier to [data], then release it (one-shot).
/// Set as each model's inputTransform, so it also catches the system keyboard.
String _applyModifiers(String data) {
if (!_ctrlActive && !_altActive) return data;
var out = data;
if (_ctrlActive) out = _ctrlTransform(out);
if (_altActive) out = '\x1B$out'; // Alt = ESC prefix
_ctrlActive = false;
_altActive = false;
// We may be inside an input event; update the highlight next frame.
if (mounted) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() {});
});
}
return out;
}
// Map a single character to its Ctrl- control code (Ctrl+C -> 0x03, etc.).
// Multi-char input (e.g. arrow sequences) is left unchanged.
static String _ctrlTransform(String data) {
if (data.length != 1) return data;
final c = data.codeUnitAt(0);
if (c >= 0x61 && c <= 0x7A) return String.fromCharCode(c - 0x60); // a-z
if (c >= 0x41 && c <= 0x5A) return String.fromCharCode(c - 0x40); // A-Z
if (c >= 0x5B && c <= 0x5F) return String.fromCharCode(c - 0x40); // [ \ ] ^ _
if (c == 0x20) return '\x00'; // Ctrl+Space -> NUL
return data;
}
Widget _closedBanner(_TerminalTab tab) {
return Container(
color: Colors.black.withOpacity(0.65),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.cancel, size: 14, color: Colors.grey.shade400),
const SizedBox(width: 8),
Text(translate('Session closed'),
style: TextStyle(color: Colors.grey.shade300, fontSize: 12)),
const SizedBox(width: 8),
TextButton(
onPressed: () => tab.model.openTerminal(force: true),
child: Text(translate('Restart')),
),
],
),
);
}
Widget _connectingView() {
return Container(
color: const Color(0xFF1E1E1E),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.grey.shade500,
),
),
const SizedBox(height: 12),
Text(
'${translate('Connecting')}...',
style: TextStyle(color: Colors.grey.shade400, fontSize: 12),
),
],
),
),
);
}
Widget _buildTabBar() {
return Container(
height: 36,
decoration: BoxDecoration(
color: const Color(0xFF2D2D2D),
border: Border(
bottom: BorderSide(color: Colors.grey.shade800, width: 1),
),
),
child: Row(
children: [
const SizedBox(width: 8),
Icon(Icons.terminal, size: 16, color: Colors.grey.shade400),
// Persistent-session indicator: sessions survive reconnect/idle.
const SizedBox(width: 6),
Tooltip(
message: translate('Keep terminal sessions on disconnect'),
child: Icon(Icons.push_pin, size: 12, color: Colors.green.shade400),
),
const SizedBox(width: 8),
Expanded(
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: _tabs.length,
itemBuilder: (context, index) => _buildTab(index),
),
),
IconButton(
icon: Icon(Icons.add, size: 16, color: Colors.grey.shade400),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: _addTab,
tooltip: translate('New tab'),
),
const SizedBox(width: 8),
],
),
);
}
Widget _buildTab(int index) {
final tab = _tabs[index];
final isSelected = index == _selectedTabIndex;
return GestureDetector(
onTap: () {
setState(() {
_selectedTabIndex = index;
// Don't carry an armed modifier across tabs.
_ctrlActive = false;
_altActive = false;
});
// Move the keyboard to the newly selected tab.
_focusSelected();
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: isSelected ? const Color(0xFF3D3D3D) : Colors.transparent,
borderRadius: BorderRadius.circular(4),
border: isSelected
? Border.all(color: Colors.blue.shade400, width: 1)
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (tab.closed)
Icon(Icons.cancel, size: 10, color: Colors.grey.shade500)
else if (!tab.ready)
SizedBox(
width: 8,
height: 8,
child: CircularProgressIndicator(
strokeWidth: 1,
color: Colors.grey.shade500,
),
)
else
Icon(Icons.check_circle, size: 10, color: Colors.green.shade400),
const SizedBox(width: 4),
Text(
// Positional label so it stays consistent after close/restore.
'Tab ${index + 1}',
style: TextStyle(
color: isSelected ? Colors.white : Colors.grey.shade400,
fontSize: 11,
fontFamily: 'monospace',
),
),
const SizedBox(width: 4),
// Always offer a close affordance even the last/only tab (a hung
// session must be closable); closing the last tab closes the
// terminal UI via onClose.
GestureDetector(
onTap: () => _closeTab(index),
child: Icon(Icons.close, size: 12, color: Colors.grey.shade500),
),
],
),
),
);
}
}

View file

@ -22,6 +22,7 @@ import '../../utils/image.dart';
import '../widgets/remote_toolbar.dart';
import '../widgets/kb_layout_type_chooser.dart';
import '../widgets/tabbar_widget.dart';
import 'inline_terminal_panel.dart';
import 'package:flutter_hbb/native/custom_cursor.dart'
if (dart.library.html) 'package:flutter_hbb/web/custom_cursor.dart';
@ -105,6 +106,9 @@ class _RemotePageState extends State<RemotePage>
bool _waylandKeyboardModeNormalized = false;
bool _waylandKeyboardModeNormalizing = false;
// Inline terminal panel state
bool _showInlineTerminal = false;
SessionID get sessionId => _ffi.sessionId;
_RemotePageState(String id) {
@ -392,6 +396,43 @@ class _RemotePageState extends State<RemotePage>
removeSharedStates(widget.id);
}
/// Toggle the inline terminal panel visibility
void toggleInlineTerminal() {
setState(() {
_showInlineTerminal = !_showInlineTerminal;
});
}
/// Build split view with remote desktop on top and terminal on bottom
Widget _buildSplitView(BuildContext context, Widget remoteBody) {
return Column(
children: [
// Remote desktop view (takes remaining space)
Expanded(
flex: 3,
child: remoteBody,
),
// Terminal panel (bottom 40% of screen)
Expanded(
flex: 2,
child: InlineTerminalPanel(
peerId: widget.id,
password: widget.password,
isSharedPassword: widget.isSharedPassword,
forceRelay: widget.forceRelay,
// Closing the last tab collapses the split view instead of leaving
// an empty panel.
onClose: () {
if (_showInlineTerminal) {
setState(() => _showInlineTerminal = false);
}
},
),
),
],
);
}
Widget emptyOverlay() => BlockableOverlay(
/// the Overlay key will be set with _blockableOverlayState in BlockableOverlay
/// see override build() in [BlockableOverlay]
@ -419,6 +460,7 @@ class _RemotePageState extends State<RemotePage>
}
},
setRemoteState: setState,
onToggleInlineTerminal: toggleInlineTerminal,
);
bodyWidget() {
@ -506,7 +548,9 @@ class _RemotePageState extends State<RemotePage>
}
// Block the whole `bodyWidget()` when dialog shows.
return BlockableOverlay(
underlying: bodyWidget(),
underlying: _showInlineTerminal
? _buildSplitView(context, bodyWidget())
: bodyWidget(),
state: _blockableOverlayState,
);
} else {

View file

@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import 'package:uuid/uuid.dart';
import '../../models/model.dart';
/// Manages terminal connections to ensure one FFI instance per peer
@ -16,7 +17,7 @@ class TerminalConnectionManager {
required String? password,
required bool? isSharedPassword,
required bool? forceRelay,
required String? connToken,
String? connToken,
}) {
final existingFfi = _connections[peerId];
if (existingFfi != null && !existingFfi.closed) {
@ -26,24 +27,38 @@ class TerminalConnectionManager {
return existingFfi;
}
// Create new FFI instance for first terminal
// Create new FFI instance for first terminal.
// IMPORTANT: pass a fresh SessionID. On mobile FFI(null) reuses a shared
// constant SessionID, which would collide with the active video session's
// FFI the native side then injects a "close" into the video stream and
// never spawns the terminal's io_loop, so the terminal hangs on
// "Connecting…". A unique id makes the terminal a distinct session
// (desktop already does this).
debugPrint('[TerminalConnectionManager] Creating new terminal connection for peer $peerId');
final ffi = FFI(null);
ffi.start(
peerId,
password: password,
isSharedPassword: isSharedPassword,
forceRelay: forceRelay,
connToken: connToken,
isTerminal: true,
);
final ffi = FFI(const Uuid().v4obj());
// Track the connection BEFORE start() so a throw can't leave an orphaned,
// half-started native session that nothing ever closes.
_connections[peerId] = ffi;
_connectionRefCount[peerId] = 1;
// Register the FFI instance with Get for dependency injection
Get.put<FFI>(ffi, tag: 'terminal_$peerId');
try {
ffi.start(
peerId,
password: password,
isSharedPassword: isSharedPassword,
forceRelay: forceRelay,
connToken: connToken,
isTerminal: true,
);
} catch (e) {
debugPrint('[TerminalConnectionManager] start failed for $peerId: $e');
_connections.remove(peerId);
_connectionRefCount.remove(peerId);
Get.delete<FFI>(tag: 'terminal_$peerId', force: true);
ffi.close();
rethrow;
}
debugPrint('[TerminalConnectionManager] New connection created. Total connections: ${_connections.length}');
return ffi;
}
@ -54,15 +69,14 @@ class TerminalConnectionManager {
debugPrint('[TerminalConnectionManager] Releasing connection for peer $peerId. Current ref count: $refCount');
if (refCount <= 1) {
// Last reference, close the connection
final ffi = _connections[peerId];
if (ffi != null) {
debugPrint('[TerminalConnectionManager] Closing connection for peer $peerId (last reference)');
ffi.close();
_connections.remove(peerId);
_connectionRefCount.remove(peerId);
Get.delete<FFI>(tag: 'terminal_$peerId');
}
// Last reference: tear everything down. Clear all bookkeeping even if the
// FFI is already gone, so a desync can't leave a stale refcount/service id
// behind that poisons later getTerminalCount()/hasConnection() checks.
debugPrint('[TerminalConnectionManager] Closing connection for peer $peerId (last reference)');
_connections.remove(peerId)?.close();
_connectionRefCount.remove(peerId);
_serviceIds.remove(peerId);
Get.delete<FFI>(tag: 'terminal_$peerId', force: true);
} else {
// Decrement reference count
_connectionRefCount[peerId] = refCount - 1;

View file

@ -75,6 +75,9 @@ class _TerminalPageState extends State<TerminalPage>
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
_terminalModel.onResizeExternal = (w, h, pw, ph) {
// A resize can still fire while the page is tearing down; bail before
// touching the (possibly disposed) focus node.
if (!mounted) return;
_cellHeight = ph * 1.0;
// Enable focus once terminal has valid dimensions (first valid resize)
@ -121,8 +124,10 @@ class _TerminalPageState extends State<TerminalPage>
void dispose() {
// Cancel tab state subscription to prevent memory leak
_tabStateSubscription?.cancel();
// Drop the resize callback before disposing the focus node it touches.
_terminalModel.onResizeExternal = null;
// Unregister terminal model from FFI
_ffi.unregisterTerminalModel(widget.terminalId);
_ffi.unregisterTerminalModel(widget.terminalId, _terminalModel);
_terminalModel.dispose();
_terminalFocusNode.dispose();
// Release connection reference instead of closing directly

View file

@ -437,18 +437,16 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
HardwareKeyboard.instance.isMetaPressed &&
!HardwareKeyboard.instance.isShiftPressed) {
// macOS: Cmd+W (standard for close tab)
final currentTab = tabController.state.value.selectedTabInfo;
if (tabController.state.value.tabs.length > 1) {
_closeTab(currentTab.key);
_closeTab(tabController.state.value.selectedTabInfo.key);
return true;
}
} else if (!isMacOS &&
HardwareKeyboard.instance.isControlPressed &&
HardwareKeyboard.instance.isShiftPressed) {
// 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) {
_closeTab(currentTab.key);
_closeTab(tabController.state.value.selectedTabInfo.key);
return true;
}
}
@ -501,13 +499,18 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
}
void _addNewTerminal(String peerId, {int? terminalId}) {
// Find first tab for this peer to get connection parameters
final firstTab = tabController.state.value.tabs.firstWhere(
(tab) {
final last = tab.key.lastIndexOf('_');
return last > 0 && tab.key.substring(0, last) == peerId;
},
);
// Find first tab for this peer to get connection parameters. Use a nullable
// lookup (not firstWhere, which throws StateError) since the seed tab may
// have been closed while a restore loop was awaiting between iterations.
TabInfo? firstTab;
for (final tab in tabController.state.value.tabs) {
final last = tab.key.lastIndexOf('_');
if (last > 0 && tab.key.substring(0, last) == peerId) {
firstTab = tab;
break;
}
}
if (firstTab == null) return;
if (firstTab.page is TerminalPage) {
final page = firstTab.page as TerminalPage;
final newTerminalId = terminalId ?? _nextTerminalId++;
@ -526,6 +529,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
}
void _addNewTerminalForCurrentPeer({int? terminalId}) {
if (tabController.state.value.tabs.isEmpty) return;
final currentTab = tabController.state.value.selectedTabInfo;
final parsed = _parseTabKey(currentTab.key);
if (parsed == null) return;

View file

@ -446,6 +446,7 @@ class RemoteToolbar extends StatefulWidget {
final Function(int, Function(bool)) onEnterOrLeaveImageSetter;
final Function(int) onEnterOrLeaveImageCleaner;
final Function(VoidCallback) setRemoteState;
final VoidCallback? onToggleInlineTerminal;
RemoteToolbar({
Key? key,
@ -455,6 +456,7 @@ class RemoteToolbar extends StatefulWidget {
required this.onEnterOrLeaveImageSetter,
required this.onEnterOrLeaveImageCleaner,
required this.setRemoteState,
this.onToggleInlineTerminal,
}) : super(key: key);
@override
@ -838,6 +840,11 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
toolbarItems
.add(_ControlMenu(id: widget.id, ffi: widget.ffi, state: widget.state));
toolbarItems.add(_InlineTerminalButton(
id: widget.id,
ffi: widget.ffi,
onToggle: widget.onToggleInlineTerminal,
));
toolbarItems.add(_DisplayMenu(
id: widget.id,
ffi: widget.ffi,
@ -1331,6 +1338,38 @@ class _ControlMenu extends StatelessWidget {
}
}
class _InlineTerminalButton extends StatelessWidget {
final String id;
final FFI ffi;
final VoidCallback? onToggle;
const _InlineTerminalButton({
Key? key,
required this.id,
required this.ffi,
this.onToggle,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return _IconSubmenuButton(
tooltip: '${translate('Terminal')} (inline)',
svg: "assets/terminal.svg",
color: _ToolbarTheme.blueColor,
hoverColor: _ToolbarTheme.hoverBlueColor,
ffi: ffi,
menuChildrenGetter: (_) => [
MenuButton(
child: Text('${translate('Terminal')} (inline)'),
onPressed: () {
onToggle?.call();
},
ffi: ffi,
),
]);
}
}
class ScreenAdjustor {
final String id;
final FFI ffi;

View file

@ -24,6 +24,7 @@ import '../../models/platform_model.dart';
import '../../utils/image.dart';
import '../widgets/dialog.dart';
import '../widgets/custom_scale_widget.dart';
import '../../desktop/pages/inline_terminal_panel.dart';
final initText = '1' * 1024;
@ -74,6 +75,13 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
final FocusNode _mobileFocusNode = FocusNode();
final FocusNode _physicalFocusNode = FocusNode();
var _showEdit = false; // use soft keyboard
bool _showInlineTerminal = false;
// Lazily mount the terminal panel on first open, then keep it alive so
// terminal sessions/tabs survive while the sheet is toggled.
bool _terminalMounted = false;
// Terminal sheet height as a fraction of the screen (drag handle / maximize).
double _terminalSheetFraction = 0.62;
bool _draggingSheet = false;
Worker? _waylandKeyboardGateWorker;
bool _waylandKeyboardGateInitialized = false;
@ -242,7 +250,11 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
if (!visible) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []);
// [pi.version.isNotEmpty] -> check ready or not, avoid login without soft-keyboard
if (gFFI.chatModel.chatWindowOverlayEntry == null &&
// Don't re-suppress the keyboard while the inline terminal is open — it
// needs the soft keyboard, and re-arming FLAG_ALT_FOCUSABLE_IM here would
// stop the terminal from receiving input after the keyboard is dismissed.
if (!_showInlineTerminal &&
gFFI.chatModel.chatWindowOverlayEntry == null &&
gFFI.ffiModel.pi.version.isNotEmpty) {
gFFI.invokeMethod("enable_soft_keyboard", false);
}
@ -433,6 +445,189 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
});
}
void _toggleTerminal(bool show) {
setState(() {
_showInlineTerminal = show;
if (show) _terminalMounted = true;
});
// Hand the keyboard to whichever surface is in front. While the terminal is
// open, ALLOW the soft keyboard (the desktop default blocks it via
// FLAG_ALT_FOCUSABLE_IM) and release the remote-desktop input focus (hidden
// text field + physical-key scope) so keystrokes go to the terminal. On
// close, restore the desktop default (keyboard suppressed) and its focus.
if (show) {
_showEdit = false;
gFFI.invokeMethod("enable_soft_keyboard", true);
_mobileFocusNode.unfocus();
_physicalFocusNode.unfocus();
} else {
_showEdit = false;
gFFI.invokeMethod("enable_soft_keyboard", false);
_physicalFocusNode.requestFocus();
}
_refitStream();
}
/// Refit the live stream to the area ABOVE the terminal sheet: tell the
/// canvas how much the sheet covers, then recompute the view style so the
/// stream fits and top-aligns to the visible region (not centered behind it).
void _refitStream() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
try {
final h = MediaQuery.of(context).size.height;
final obstruction =
_showInlineTerminal ? h * _terminalSheetFraction : 0.0;
// Skip the FFI roundtrip when nothing changed (e.g. a no-op drag-end).
if (gFFI.canvasModel.bottomObstructionPx == obstruction) return;
gFFI.canvasModel.bottomObstructionPx = obstruction;
gFFI.canvasModel.updateViewStyle();
} catch (_) {}
});
}
/// A clean slide-up terminal sheet over the live stream. Drag the handle to
/// resize, tap maximize to expand to nearly full screen, and the scrim / /
/// drag-down-to-dismiss to close. Kept alive once mounted so sessions survive.
// Sheet height capped to the space actually on screen: when the keyboard is
// up the Scaffold body shrinks, so a full-height sheet would push its tab bar
// and top rows off the top. Clamping keeps everything visible above the
// keyboard (matters most when maximized).
double _effectiveSheetHeight(BuildContext context) {
final mq = MediaQuery.of(context);
final available = mq.size.height - mq.viewInsets.bottom;
return (mq.size.height * _terminalSheetFraction)
.clamp(0.0, available)
.toDouble();
}
Widget _buildTerminalSheet(BuildContext context) {
final size = MediaQuery.of(context).size;
final sheetHeight = _effectiveSheetHeight(context);
final shown = _showInlineTerminal;
return Stack(
children: [
if (shown)
Positioned.fill(
child: GestureDetector(
onTap: () => _toggleTerminal(false),
child: Container(color: Colors.black38),
),
),
AnimatedPositioned(
duration: _draggingSheet
? Duration.zero
: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
left: 0,
right: 0,
height: sheetHeight,
bottom: shown ? 0 : -(sheetHeight + 48),
child: Material(
color: const Color(0xFF1E1E1E),
elevation: 12,
// Keep the header and terminal clear of the status bar / gesture
// nav bar so every edge stays tappable. The top inset only matters
// when maximized (sheet reaches the status bar); a partial sheet
// sits mid-screen and needs no top padding.
child: SafeArea(
top: _terminalSheetFraction >= 0.88,
bottom: true,
child: Column(
children: [
_buildTerminalSheetHeader(size.height),
Expanded(
// Pause the panel's tickers (cursor blink) while the sheet
// is hidden sessions stay alive, just no wasted repaints.
child: TickerMode(
enabled: _showInlineTerminal || _draggingSheet,
child: InlineTerminalPanel(
peerId: widget.id,
password: widget.password,
isSharedPassword: widget.isSharedPassword,
forceRelay: widget.forceRelay,
// Closing the last tab closes the terminal UI and
// unmounts the panel, so reopening starts a fresh
// session instead of a silent replacement tab.
onClose: () {
_toggleTerminal(false);
setState(() => _terminalMounted = false);
},
),
),
),
],
),
),
),
),
],
);
}
Widget _buildTerminalSheetHeader(double screenH) {
final isMax = _terminalSheetFraction >= 0.88;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onVerticalDragStart: (_) => setState(() => _draggingSheet = true),
onVerticalDragUpdate: (d) {
setState(() {
_terminalSheetFraction =
(_terminalSheetFraction - d.delta.dy / screenH).clamp(0.12, 0.95);
});
},
onVerticalDragEnd: (DragEndDetails d) {
setState(() => _draggingSheet = false);
// Close ONLY on a clear downward fling; a slow drag just resizes the
// sheet (use the to close explicitly).
if ((d.primaryVelocity ?? 0) > 700 && _terminalSheetFraction <= 0.3) {
_terminalSheetFraction = 0.62;
_toggleTerminal(false);
}
_refitStream();
},
child: Container(
height: 36,
color: const Color(0xFF2D2D2D),
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
IconButton(
icon: Icon(isMax ? Icons.fullscreen_exit : Icons.fullscreen,
size: 18, color: Colors.grey.shade300),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
tooltip: isMax ? translate('Restore') : translate('Maximize'),
onPressed: () {
setState(() => _terminalSheetFraction = isMax ? 0.62 : 0.95);
_refitStream();
},
),
Expanded(
child: Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade600,
borderRadius: BorderRadius.circular(2),
),
),
),
),
IconButton(
icon: Icon(Icons.close, size: 18, color: Colors.grey.shade300),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
tooltip: translate('Close'),
onPressed: () => _toggleTerminal(false),
),
],
),
),
);
}
Widget _bottomWidget() => _showGestureHelp
? getGestureHelp()
: (_showBar && gFFI.ffiModel.pi.displays.isNotEmpty
@ -500,7 +695,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
() => getRawPointerAndKeyBody(Overlay(
initialEntries: [
OverlayEntry(builder: (context) {
return Container(
final remoteView = Container(
color: kColorCanvas,
child: isWebDesktop
? getBodyForDesktopWithListener()
@ -527,6 +722,28 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
}),
),
);
// Keep the original full-screen render path when the
// terminal isn't mounted (no regression to the live stream).
if (!_terminalMounted) return remoteView;
// When the terminal is up, shrink the stream into the visible
// area ABOVE the sheet so it fits there instead of being
// covered (the canvas refits via updateViewStyle on toggle).
return Stack(
children: [
Positioned(
top: 0,
left: 0,
right: 0,
// Keep the stream's bottom aligned with the (keyboard-
// capped) sheet top so they don't diverge.
bottom: _showInlineTerminal
? _effectiveSheetHeight(context)
: 0,
child: remoteView,
),
_buildTerminalSheet(context),
],
);
})
],
)),
@ -541,7 +758,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
inputModel: inputModel,
// Disable RawKeyFocusScope before the connecting is established.
// The "Delete" key on the soft keyboard may be grabbed when inputting the password dialog.
child: gFFI.ffiModel.pi.isSet.isTrue
// Also disable it while the inline terminal is open so physical keys reach
// the terminal instead of being routed to the remote desktop.
child: gFFI.ffiModel.pi.isSet.isTrue && !_showInlineTerminal
? RawKeyFocusScope(
focusNode: _physicalFocusNode,
inputModel: inputModel,
@ -554,7 +773,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
final ffiModel = Provider.of<FfiModel>(context);
return BottomAppBar(
elevation: 10,
color: MyTheme.accent,
color: _showInlineTerminal ? const Color(0xFF2D2D2D) : MyTheme.accent,
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@ -575,7 +794,28 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
setState(() => _showEdit = false);
showOptions(context, widget.id, gFFI.dialogManager);
},
)
),
// Terminal toggle - visually distinct when active
Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: _showInlineTerminal
? MyTheme.accent.withOpacity(0.3)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: IconButton(
color: _showInlineTerminal
? MyTheme.accent
: Colors.white,
icon: const Icon(Icons.terminal),
tooltip: _showInlineTerminal
? translate('Close')
: translate('Terminal'),
onPressed: () =>
_toggleTerminal(!_showInlineTerminal),
),
),
] +
(isWebDesktop || ffiModel.viewOnly || !ffiModel.keyboard
? []

View file

@ -102,7 +102,7 @@ class _TerminalPageState extends State<TerminalPage>
@override
void dispose() {
// Unregister terminal model from FFI
_ffi.unregisterTerminalModel(widget.terminalId);
_ffi.unregisterTerminalModel(widget.terminalId, _terminalModel);
_terminalModel.dispose();
_keyboardDebounce?.cancel();
WidgetsBinding.instance.removeObserver(this);
@ -116,6 +116,7 @@ class _TerminalPageState extends State<TerminalPage>
_keyboardDebounce?.cancel();
_keyboardDebounce = Timer(const Duration(milliseconds: 20), () {
if (!mounted) return;
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
setState(() {
_sysKeyboardHeight = bottomInset;
@ -131,14 +132,28 @@ class _TerminalPageState extends State<TerminalPage>
}
EdgeInsets _calculatePadding(double heightPx) {
if (_cellHeight == null) {
return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
const defaultPadding = EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
final cellHeight = _cellHeight;
// Guard against NaN/zero/negative inputs (e.g. a tall keyboard in landscape
// makes realHeight <= 0); mirrors the hardened desktop version.
if (!heightPx.isFinite ||
heightPx <= 0 ||
cellHeight == null ||
!cellHeight.isFinite ||
cellHeight <= 0) {
return defaultPadding;
}
final realHeight = heightPx - _sysKeyboardHeight - _keyboardHeight;
final rows = (realHeight / _cellHeight!).floor();
final extraSpace = realHeight - rows * _cellHeight!;
final topBottom = max(0.0, extraSpace / 2.0);
return EdgeInsets.only(left: 5.0, right: 5.0, top: topBottom, bottom: topBottom + _sysKeyboardHeight + _keyboardHeight);
if (!realHeight.isFinite) return defaultPadding;
final rows = (realHeight / cellHeight).floor();
if (rows <= 0) return defaultPadding;
final extraSpace = realHeight - rows * cellHeight;
final topBottom = extraSpace.isFinite ? max(0.0, extraSpace / 2.0) : 0.0;
return EdgeInsets.only(
left: 5.0,
right: 5.0,
top: topBottom,
bottom: topBottom + _sysKeyboardHeight + _keyboardHeight);
}
@override

View file

@ -2254,6 +2254,11 @@ class CanvasModel with ChangeNotifier {
static double get bottomToEdge =>
isDesktop ? windowBorderWidth + kDragToResizeAreaPadding.bottom : 0;
// Height (px) of a bottom overlay that covers part of the canvas on mobile
// (e.g. the inline terminal sheet). Subtracted from the usable height so the
// stream fits and top-aligns to the visible area above it.
double bottomObstructionPx = 0;
Size getSize() {
final mediaData = MediaQueryData.fromView(ui.window);
final size = mediaData.size;
@ -2267,6 +2272,7 @@ class CanvasModel with ChangeNotifier {
// bottom overlay (e.g. key-help tools) so the canvas is not covered.
h = h -
mediaData.viewInsets.bottom -
bottomObstructionPx -
(parent.target?.cursorModel.keyHelpToolsRectToAdjustCanvas?.bottom ??
0);
// Orientation-specific handling:
@ -2366,7 +2372,11 @@ class CanvasModel with ChangeNotifier {
_resetCanvasOffset(int displayWidth, int displayHeight) {
_x = (size.width - displayWidth * _scale) / 2;
_y = (size.height - displayHeight * _scale) / 2;
// Top-align when a bottom overlay (the inline terminal sheet) shrinks the
// canvas, so the stream sits at the top of the visible area, not centered.
_y = bottomObstructionPx > 0
? 0
: (size.height - displayHeight * _scale) / 2;
if (isMobile) {
_moveToCenterCursor();
}
@ -2683,6 +2693,8 @@ class CanvasModel with ChangeNotifier {
_x = 0;
_y = 0;
_scale = 1.0;
// Reset the terminal-sheet obstruction so it can't shrink the next session.
bottomObstructionPx = 0;
_lastViewStyle = ViewStyle.defaultViewStyle();
_timerMobileFocusCanvasCursor?.cancel();
_timerMobileRestoreCanvasOffset?.cancel();
@ -3981,15 +3993,26 @@ class FFI {
return await platformFFI.invokeMethod(method, arguments);
}
// Terminal model management
// Terminal model management. Models are keyed by terminal id over a single
// per-peer connection, so two embedders (e.g. the tabbed terminal and the
// inline panel) must not reuse an id; warn if a live model would be clobbered.
void registerTerminalModel(int terminalId, TerminalModel model) {
debugPrint('[FFI] Registering terminal model for terminal $terminalId');
final existing = _terminalModels[terminalId];
if (existing != null && !identical(existing, model)) {
debugPrint(
'[FFI] WARNING: terminal id $terminalId already registered to a different model; overwriting');
}
_terminalModels[terminalId] = model;
}
void unregisterTerminalModel(int terminalId) {
// Identity-checked so a late-disposing page can't remove a replacement model
// that re-registered under the same id (which would silently drop its events).
void unregisterTerminalModel(int terminalId, TerminalModel model) {
debugPrint('[FFI] Unregistering terminal model for terminal $terminalId');
_terminalModels.remove(terminalId);
if (identical(_terminalModels[terminalId], model)) {
_terminalModels.remove(terminalId);
}
}
void routeTerminalResponse(Map<String, dynamic> evt) {
@ -3999,6 +4022,8 @@ class FFI {
final model = _terminalModels[terminalId];
if (model != null) {
model.handleTerminalResponse(evt);
} else {
debugPrint('[FFI] No terminal model registered for terminal $terminalId; dropping event');
}
}
}

View file

@ -37,6 +37,13 @@ class TerminalModel with ChangeNotifier {
bool _suppressNextTerminalDataOutput = false;
void Function(int w, int h, int pw, int ph)? onResizeExternal;
// Fired with the server's surviving persistent terminal session ids (those
// not already open locally), so an embedder can offer to reattach to them.
void Function(List<int> persistentSessions)? onPersistentSessions;
// Optional transform applied to keyboard input from the terminal view before
// it is sent lets an embedder implement sticky modifiers (e.g. a Ctrl/Alt
// accessory bar) that combine with the next system-keyboard key.
String Function(String data)? inputTransform;
Future<void> _handleInput(String data) async {
// Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a
@ -77,7 +84,8 @@ class TerminalModel with ChangeNotifier {
// Setup terminal callbacks
terminal.onOutput = (data) {
if (_suppressTerminalOutput) return;
_handleInput(data);
final t = inputTransform;
_handleInput(t != null ? t(data) : data);
};
terminal.onResize = (w, h, pw, ph) async {
@ -169,12 +177,17 @@ class TerminalModel with ChangeNotifier {
}
}
// NOTE: this bypasses terminal.onOutput, so [inputTransform] is NOT applied
// here callers (e.g. an accessory key bar) must apply their own modifiers
// before calling, otherwise a sticky modifier would be applied twice.
Future<void> sendVirtualKey(String data) async {
return _handleInput(data);
}
Future<void> closeTerminal() async {
if (_terminalOpened) {
Future<void> closeTerminal({bool force = false}) async {
// force lets us reap a session that never finished opening (e.g. a hung
// corpse restored from a previous run), which would otherwise be unclosable.
if (_terminalOpened || force) {
try {
await bind
.sessionCloseTerminal(
@ -194,7 +207,8 @@ class TerminalModel with ChangeNotifier {
// Continue with cleanup even if close fails
}
_terminalOpened = false;
notifyListeners();
// The widget may have been disposed during the await above.
if (!_disposed) notifyListeners();
}
}
@ -248,6 +262,10 @@ class TerminalModel with ChangeNotifier {
}
void handleTerminalResponse(Map<String, dynamic> evt) {
// A terminal_response can still arrive (and be routed here) between this
// model being disposed and its widget tearing down; ignore it so we never
// notifyListeners() on a disposed ChangeNotifier.
if (_disposed) return;
final String? type = evt['type'];
final int evtTerminalId = getTerminalIdFromEvt(evt);
@ -301,12 +319,13 @@ class TerminalModel with ChangeNotifier {
_scheduleMarkViewReady();
}
// Process any buffered input
// Process any buffered input. These callbacks resolve on a later
// microtask, by which point the model may have been disposed.
_processBufferedInputAsync().then((_) {
notifyListeners();
if (!_disposed) notifyListeners();
}).catchError((e) {
debugPrint('[TerminalModel] Error processing buffered input: $e');
notifyListeners();
if (!_disposed) notifyListeners();
});
final persistentSessions =
@ -314,6 +333,7 @@ class TerminalModel with ChangeNotifier {
.whereType<int>()
.where((id) => !parent.terminalModels.containsKey(id))
.toList();
onPersistentSessions?.call(persistentSessions);
if (kWindowId != null && persistentSessions.isNotEmpty) {
DesktopMultiWindow.invokeMethod(
kWindowId!,
@ -472,7 +492,12 @@ class TerminalModel with ChangeNotifier {
final int exitCode = evt['exit_code'] ?? 0;
_writeToTerminal('\r\nTerminal closed with exit code: $exitCode\r\n');
_terminalOpened = false;
notifyListeners();
// Surface the close to the UI (it shows a "Session closed / Restart"
// banner), but do NOT reap the persistent session here: a `closed` can be
// spurious/stale (e.g. a saturated output channel or a transient reconnect),
// and reaping it would permanently destroy a session the user was still
// using. The session is reaped only by an explicit tab close.
if (!_disposed) notifyListeners();
}
void _handleTerminalError(Map<String, dynamic> evt) {