terminal works basically. (#12189)

* terminal works basically.
todo:
- persistent
- sessions restore
- web
- mobile

* missed terminal persistent option change

* android sdk 34 -> 35

* +#![cfg_attr(lt_1_77, feature(c_str_literals))]

* fixing ci

* fix ci

* fix ci for android

* try "Fix Android SDK Platform 35"

* fix android 34

* revert flutter_plugin_android_lifecycle to 2.0.17 which used in rustdesk 1.4.0

* refactor, but break something of desktop terminal (new tab showing loading)

* fix connecting...
This commit is contained in:
RustDesk
2025-07-01 13:12:55 +08:00
committed by GitHub
parent ee5cdc3155
commit 5faf0ad3cf
130 changed files with 4064 additions and 4247 deletions

View File

@@ -327,10 +327,15 @@ class _ConnectionPageState extends State<ConnectionPage>
/// Callback for the connect button.
/// Connects to the selected peer.
void onConnect({bool isFileTransfer = false, bool isViewCamera = false}) {
void onConnect(
{bool isFileTransfer = false,
bool isViewCamera = false,
bool isTerminal = false}) {
var id = _idController.id;
connect(context, id,
isFileTransfer: isFileTransfer, isViewCamera: isViewCamera);
isFileTransfer: isFileTransfer,
isViewCamera: isViewCamera,
isTerminal: isTerminal);
}
/// UI for the remote ID TextField.
@@ -527,22 +532,23 @@ class _ConnectionPageState extends State<ConnectionPage>
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Obx(() {
var offset = Offset(0, 0);
return InkWell(
child: _menuOpen.value
? Transform.rotate(
angle: pi,
child: Icon(IconFont.more, size: 14),
)
: Icon(IconFont.more, size: 14),
onTapDown: (e) {
offset = e.globalPosition;
},
onTap: () async {
_menuOpen.value = true;
final x = offset.dx;
final y = offset.dy;
child: StatefulBuilder(
builder: (context, setState) {
var offset = Offset(0, 0);
return Obx(() => InkWell(
child: _menuOpen.value
? Transform.rotate(
angle: pi,
child: Icon(IconFont.more, size: 14),
)
: Icon(IconFont.more, size: 14),
onTapDown: (e) {
offset = e.globalPosition;
},
onTap: () async {
_menuOpen.value = true;
final x = offset.dx;
final y = offset.dy;
await mod_menu
.showMenu(
context: context,
@@ -556,6 +562,10 @@ class _ConnectionPageState extends State<ConnectionPage>
'View camera',
() => onConnect(isViewCamera: true)
),
(
'Terminal',
() => onConnect(isTerminal: true)
),
]
.map((e) => MenuEntryButton<String>(
childBuilder: (TextStyle? style) => Text(
@@ -583,8 +593,9 @@ class _ConnectionPageState extends State<ConnectionPage>
_menuOpen.value = false;
});
},
);
}),
));
},
),
),
),
]),

View File

@@ -786,6 +786,7 @@ class _DesktopHomePageState extends State<DesktopHomePage>
call.arguments['id'],
isFileTransfer: call.arguments['isFileTransfer'],
isViewCamera: call.arguments['isViewCamera'],
isTerminal: call.arguments['isTerminal'],
isTcpTunneling: call.arguments['isTcpTunneling'],
isRDP: call.arguments['isRDP'],
password: call.arguments['password'],

View File

@@ -1011,6 +1011,8 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
enabled: enabled, fakeValue: fakeValue),
_OptionCheckBox(context, 'Enable camera', kOptionEnableCamera,
enabled: enabled, fakeValue: fakeValue),
_OptionCheckBox(context, 'Enable terminal', kOptionEnableTerminal,
enabled: enabled, fakeValue: fakeValue),
_OptionCheckBox(
context, 'Enable TCP tunneling', kOptionEnableTunnel,
enabled: enabled, fakeValue: fakeValue),

View File

@@ -355,6 +355,7 @@ Widget buildConnectionCard(Client client) {
_CmHeader(client: client),
client.type_() == ClientType.file ||
client.type_() == ClientType.portForward ||
client.type_() == ClientType.terminal ||
client.disconnected
? Offstage()
: _PrivilegeBoard(client: client),
@@ -499,7 +500,36 @@ class _CmHeaderState extends State<_CmHeader>
"(${client.peerId})",
style: TextStyle(color: Colors.white, fontSize: 14),
),
).marginOnly(bottom: 10.0),
),
if (client.type_() == ClientType.terminal)
FittedBox(
child: Text(
translate("Terminal"),
style: TextStyle(color: Colors.white70, fontSize: 12),
),
),
if (client.type_() == ClientType.file)
FittedBox(
child: Text(
translate("File Transfer"),
style: TextStyle(color: Colors.white70, fontSize: 12),
),
),
if (client.type_() == ClientType.camera)
FittedBox(
child: Text(
translate("View Camera"),
style: TextStyle(color: Colors.white70, fontSize: 12),
),
),
if (client.portForward.isNotEmpty)
FittedBox(
child: Text(
"Port Forward: ${client.portForward}",
style: TextStyle(color: Colors.white70, fontSize: 12),
),
),
SizedBox(height: 10.0),
FittedBox(
child: Row(
children: [

View File

@@ -0,0 +1,98 @@
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import '../../models/model.dart';
/// Manages terminal connections to ensure one FFI instance per peer
class TerminalConnectionManager {
static final Map<String, FFI> _connections = {};
static final Map<String, int> _connectionRefCount = {};
// Track service IDs per peer
static final Map<String, String> _serviceIds = {};
/// Get or create an FFI instance for a peer
static FFI getConnection({
required String peerId,
required String? password,
required bool? isSharedPassword,
required bool? forceRelay,
required String? connToken,
}) {
final existingFfi = _connections[peerId];
if (existingFfi != null && !existingFfi.closed) {
// Increment reference count
_connectionRefCount[peerId] = (_connectionRefCount[peerId] ?? 0) + 1;
debugPrint('[TerminalConnectionManager] Reusing existing connection for peer $peerId. Reference count: ${_connectionRefCount[peerId]}');
return existingFfi;
}
// Create new FFI instance for first terminal
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,
);
_connections[peerId] = ffi;
_connectionRefCount[peerId] = 1;
// Register the FFI instance with Get for dependency injection
Get.put<FFI>(ffi, tag: 'terminal_$peerId');
debugPrint('[TerminalConnectionManager] New connection created. Total connections: ${_connections.length}');
return ffi;
}
/// Release a connection reference
static void releaseConnection(String peerId) {
final refCount = _connectionRefCount[peerId] ?? 0;
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');
}
} else {
// Decrement reference count
_connectionRefCount[peerId] = refCount - 1;
debugPrint('[TerminalConnectionManager] Connection still in use. New ref count: ${_connectionRefCount[peerId]}');
}
}
/// Check if a connection exists for a peer
static bool hasConnection(String peerId) {
final ffi = _connections[peerId];
return ffi != null && !ffi.closed;
}
/// Get existing connection without creating new one
static FFI? getExistingConnection(String peerId) {
return _connections[peerId];
}
/// Get connection count for debugging
static int getConnectionCount() => _connections.length;
/// Get terminal count for a peer
static int getTerminalCount(String peerId) => _connectionRefCount[peerId] ?? 0;
/// Get service ID for a peer
static String? getServiceId(String peerId) => _serviceIds[peerId];
/// Set service ID for a peer
static void setServiceId(String peerId, String serviceId) {
_serviceIds[peerId] = serviceId;
debugPrint('[TerminalConnectionManager] Service ID for $peerId: $serviceId');
}
}

View File

@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/terminal_model.dart';
import 'package:xterm/xterm.dart';
import 'terminal_connection_manager.dart';
class TerminalPage extends StatefulWidget {
const TerminalPage({
Key? key,
required this.id,
required this.password,
required this.tabController,
required this.isSharedPassword,
required this.terminalId,
this.forceRelay,
this.connToken,
}) : super(key: key);
final String id;
final String? password;
final DesktopTabController tabController;
final bool? forceRelay;
final bool? isSharedPassword;
final String? connToken;
final int terminalId;
@override
State<TerminalPage> createState() => _TerminalPageState();
}
class _TerminalPageState extends State<TerminalPage>
with AutomaticKeepAliveClientMixin {
late FFI _ffi;
late TerminalModel _terminalModel;
@override
void initState() {
super.initState();
// Use shared FFI instance from connection manager
_ffi = TerminalConnectionManager.getConnection(
peerId: widget.id,
password: widget.password,
isSharedPassword: widget.isSharedPassword,
forceRelay: widget.forceRelay,
connToken: widget.connToken,
);
// Create terminal model with specific terminal ID
_terminalModel = TerminalModel(_ffi, widget.terminalId);
debugPrint(
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
// Register this terminal model with FFI for event routing
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
// Initialize terminal connection
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.tabController.onSelected?.call(widget.id);
// Check if this is a new connection or additional terminal
// Note: When a connection exists, the ref count will be > 1 after this terminal is added
final isExistingConnection = TerminalConnectionManager.hasConnection(widget.id) &&
TerminalConnectionManager.getTerminalCount(widget.id) > 1;
if (!isExistingConnection) {
// First terminal - show loading dialog, wait for onReady
_ffi.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection);
} else {
// Additional terminal - connection already established
// Open the terminal directly
_terminalModel.openTerminal();
}
});
}
@override
void dispose() {
// Unregister terminal model from FFI
_ffi.unregisterTerminalModel(widget.terminalId);
_terminalModel.dispose();
// Release connection reference instead of closing directly
TerminalConnectionManager.releaseConnection(widget.id);
super.dispose();
}
@override
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: TerminalView(
_terminalModel.terminal,
controller: _terminalModel.terminalController,
autofocus: true,
backgroundOpacity: 0.7,
padding: const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0),
onSecondaryTapDown: (details, offset) async {
final selection = _terminalModel.terminalController.selection;
if (selection != null) {
final text = _terminalModel.terminal.buffer.getText(selection);
_terminalModel.terminalController.clearSelection();
await Clipboard.setData(ClipboardData(text: text));
} else {
final data = await Clipboard.getData('text/plain');
final text = data?.text;
if (text != null) {
_terminalModel.terminal.paste(text);
}
}
},
),
);
}
@override
bool get wantKeepAlive => true;
}

View File

@@ -0,0 +1,384 @@
import 'dart:convert';
import 'package:desktop_multi_window/desktop_multi_window.dart';
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/state_model.dart';
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:get/get.dart';
import '../../models/platform_model.dart';
import 'terminal_page.dart';
import 'terminal_connection_manager.dart';
import '../widgets/material_mod_popup_menu.dart' as mod_menu;
import '../widgets/popup_menu.dart';
import 'package:bot_toast/bot_toast.dart';
class TerminalTabPage extends StatefulWidget {
final Map<String, dynamic> params;
const TerminalTabPage({Key? key, required this.params}) : super(key: key);
@override
State<TerminalTabPage> createState() => _TerminalTabPageState(params);
}
class _TerminalTabPageState extends State<TerminalTabPage> {
DesktopTabController get tabController => Get.find<DesktopTabController>();
static const IconData selectedIcon = Icons.terminal;
static const IconData unselectedIcon = Icons.terminal_outlined;
int _nextTerminalId = 1;
_TerminalTabPageState(Map<String, dynamic> params) {
Get.put(DesktopTabController(tabType: DesktopTabType.terminal));
tabController.onSelected = (id) {
WindowController.fromWindowId(windowId())
.setTitle(getWindowNameWithId(id));
};
tabController.onRemoved = (_, id) => onRemoveId(id);
final terminalId = params['terminalId'] ?? _nextTerminalId++;
tabController.add(_createTerminalTab(
peerId: params['id'],
terminalId: terminalId,
password: params['password'],
isSharedPassword: params['isSharedPassword'],
forceRelay: params['forceRelay'],
connToken: params['connToken'],
));
}
TabInfo _createTerminalTab({
required String peerId,
required int terminalId,
String? password,
bool? isSharedPassword,
bool? forceRelay,
String? connToken,
}) {
final tabKey = '${peerId}_$terminalId';
return TabInfo(
key: tabKey,
label: '$peerId #$terminalId',
selectedIcon: selectedIcon,
unselectedIcon: unselectedIcon,
onTabCloseButton: () async {
// 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);
},
page: TerminalPage(
key: ValueKey(tabKey),
id: peerId,
terminalId: terminalId,
password: password,
isSharedPassword: isSharedPassword,
tabController: tabController,
forceRelay: forceRelay,
connToken: connToken,
),
);
}
Widget _tabMenuBuilder(String peerId, CancelFunc cancelFunc) {
final List<MenuEntryBase<String>> menu = [];
const EdgeInsets padding = EdgeInsets.only(left: 8.0, right: 5.0);
// New tab menu item
menu.add(MenuEntryButton<String>(
childBuilder: (TextStyle? style) => Text(
translate('New tab'),
style: style,
),
proc: () {
_addNewTerminal(peerId);
cancelFunc();
// Also try to close any BotToast overlays
BotToast.cleanAll();
},
padding: padding,
));
menu.add(MenuEntryDivider());
menu.add(MenuEntrySwitch<String>(
switchType: SwitchType.scheckbox,
text: translate('Keep terminal sessions on disconnect'),
getter: () async {
final ffi = Get.find<FFI>(tag: 'terminal_$peerId');
return bind.sessionGetToggleOptionSync(
sessionId: ffi.sessionId,
arg: kOptionTerminalPersistent,
);
},
setter: (bool v) async {
final ffi = Get.find<FFI>(tag: 'terminal_$peerId');
bind.sessionToggleOption(
sessionId: ffi.sessionId,
value: kOptionTerminalPersistent,
);
},
padding: padding,
));
return mod_menu.PopupMenu<String>(
items: menu
.map((e) => e.build(
context,
const MenuConfig(
commonColor: CustomPopupMenuTheme.commonColor,
height: CustomPopupMenuTheme.height,
dividerHeight: CustomPopupMenuTheme.dividerHeight,
),
))
.expand((i) => i)
.toList(),
);
}
@override
void initState() {
super.initState();
// Add keyboard shortcut handler
HardwareKeyboard.instance.addHandler(_handleKeyEvent);
rustDeskWinManager.setMethodHandler((call, fromWindowId) async {
print(
"[Remote Terminal] call ${call.method} with args ${call.arguments} from window $fromWindowId");
if (call.method == kWindowEventNewTerminal) {
final args = jsonDecode(call.arguments);
final id = args['id'];
windowOnTop(windowId());
// Allow multiple terminals for the same connection
final terminalId = args['terminalId'] ?? _nextTerminalId++;
tabController.add(_createTerminalTab(
peerId: id,
terminalId: terminalId,
password: args['password'],
isSharedPassword: args['isSharedPassword'],
forceRelay: args['forceRelay'],
connToken: args['connToken'],
));
} else if (call.method == "onDestroy") {
tabController.clear();
} else if (call.method == kWindowActionRebuild) {
reloadCurrentWindow();
}
});
Future.delayed(Duration.zero, () {
restoreWindowPosition(WindowType.Terminal, windowId: windowId());
});
}
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
super.dispose();
}
bool _handleKeyEvent(KeyEvent event) {
if (event is KeyDownEvent) {
// Use Cmd+T on macOS, Ctrl+Shift+T on other platforms
if (event.logicalKey == LogicalKeyboardKey.keyT) {
if (isMacOS &&
HardwareKeyboard.instance.isMetaPressed &&
!HardwareKeyboard.instance.isShiftPressed) {
// macOS: Cmd+T (standard for new tab)
_addNewTerminalForCurrentPeer();
return true;
} else if (!isMacOS &&
HardwareKeyboard.instance.isControlPressed &&
HardwareKeyboard.instance.isShiftPressed) {
// Other platforms: Ctrl+Shift+T (to avoid conflict with Ctrl+T in terminal)
_addNewTerminalForCurrentPeer();
return true;
}
}
// Use Cmd+W on macOS, Ctrl+Shift+W on other platforms
if (event.logicalKey == LogicalKeyboardKey.keyW) {
if (isMacOS &&
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) {
tabController.closeBy(currentTab.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) {
tabController.closeBy(currentTab.key);
return true;
}
}
}
// Use Alt+Left/Right for tab navigation (avoids conflicts)
if (HardwareKeyboard.instance.isAltPressed) {
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
// Previous tab
final currentIndex = tabController.state.value.selected;
if (currentIndex > 0) {
tabController.jumpTo(currentIndex - 1);
}
return true;
} else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
// Next tab
final currentIndex = tabController.state.value.selected;
if (currentIndex < tabController.length - 1) {
tabController.jumpTo(currentIndex + 1);
}
return true;
}
}
// Check for Cmd/Ctrl + Number (switch to specific tab)
final numberKeys = [
LogicalKeyboardKey.digit1,
LogicalKeyboardKey.digit2,
LogicalKeyboardKey.digit3,
LogicalKeyboardKey.digit4,
LogicalKeyboardKey.digit5,
LogicalKeyboardKey.digit6,
LogicalKeyboardKey.digit7,
LogicalKeyboardKey.digit8,
LogicalKeyboardKey.digit9,
];
for (int i = 0; i < numberKeys.length; i++) {
if (event.logicalKey == numberKeys[i] &&
((isMacOS && HardwareKeyboard.instance.isMetaPressed) ||
(!isMacOS && HardwareKeyboard.instance.isControlPressed))) {
if (i < tabController.length) {
tabController.jumpTo(i);
return true;
}
}
}
}
return false;
}
void _addNewTerminal(String peerId) {
// Find first tab for this peer to get connection parameters
final firstTab = tabController.state.value.tabs.firstWhere(
(tab) => tab.key.startsWith('$peerId\_'),
);
if (firstTab.page is TerminalPage) {
final page = firstTab.page as TerminalPage;
final terminalId = _nextTerminalId++;
tabController.add(_createTerminalTab(
peerId: peerId,
terminalId: terminalId,
password: page.password,
isSharedPassword: page.isSharedPassword,
forceRelay: page.forceRelay,
connToken: page.connToken,
));
}
}
void _addNewTerminalForCurrentPeer() {
final currentTab = tabController.state.value.selectedTabInfo;
final parts = currentTab.key.split('_');
if (parts.isNotEmpty) {
final peerId = parts[0];
_addNewTerminal(peerId);
}
}
@override
Widget build(BuildContext context) {
final child = Scaffold(
backgroundColor: Theme.of(context).cardColor,
body: DesktopTab(
controller: tabController,
onWindowCloseButton: handleWindowCloseButton,
tail: _buildAddButton(),
selectedBorderColor: MyTheme.accent,
labelGetter: DesktopTab.tablabelGetter,
tabMenuBuilder: (key) {
// Extract peerId from tab key (format: "peerId_terminalId")
final parts = key.split('_');
if (parts.isEmpty) return Container();
final peerId = parts[0];
return _tabMenuBuilder(peerId, () {});
},
));
final tabWidget = isLinux
? buildVirtualWindowFrame(context, child)
: workaroundWindowBorder(
context,
Container(
decoration: BoxDecoration(
border: Border.all(color: MyTheme.color(context).border!)),
child: child,
));
return isMacOS || kUseCompatibleUiMode
? tabWidget
: SubWindowDragToResizeArea(
child: tabWidget,
resizeEdgeSize: stateGlobal.resizeEdgeSize.value,
enableResizeEdges: subWindowManagerEnableResizeEdges,
windowId: stateGlobal.windowId,
);
}
void onRemoveId(String id) {
if (tabController.state.value.tabs.isEmpty) {
WindowController.fromWindowId(windowId()).close();
}
}
int windowId() {
return widget.params["windowId"];
}
Widget _buildAddButton() {
return ActionIcon(
message: 'New tab',
icon: IconFont.add,
onTap: () {
_addNewTerminalForCurrentPeer();
},
isClose: false,
);
}
Future<bool> handleWindowCloseButton() async {
final connLength = tabController.state.value.tabs.length;
if (connLength <= 1) {
tabController.clear();
return true;
} else {
final bool res;
if (!option2bool(kOptionEnableConfirmClosingTabs,
bind.mainGetLocalOption(key: kOptionEnableConfirmClosingTabs))) {
res = true;
} else {
res = await closeConfirmDialog();
}
if (res) {
tabController.clear();
}
return res;
}
}
}

View File

@@ -515,8 +515,6 @@ class ImagePaint extends StatefulWidget {
}
class _ImagePaintState extends State<ImagePaint> {
bool _lastRemoteCursorMoved = false;
String get id => widget.id;
RxBool get cursorOverImage => widget.cursorOverImage;
Widget Function(Widget)? get listenerBuilder => widget.listenerBuilder;

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:provider/provider.dart';
import 'package:flutter_hbb/desktop/pages/terminal_tab_page.dart';
class DesktopTerminalScreen extends StatelessWidget {
final Map<String, dynamic> params;
const DesktopTerminalScreen({Key? key, required this.params})
: super(key: key);
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: gFFI.ffiModel),
],
child: Scaffold(
backgroundColor: isLinux ? Colors.transparent : null,
body: TerminalTabPage(
params: params,
),
),
);
}
}

View File

@@ -54,6 +54,7 @@ enum DesktopTabType {
fileTransfer,
viewCamera,
portForward,
terminal,
install,
}