feat(terminal): add opt-in OSC 52 clipboard writes (#16072)

* feat(terminal): add opt-in OSC 52 clipboard writes

* Remove dup tr

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
fufesou
2026-09-05 13:21:49 +08:00
committed by GitHub
parent c1a587cfa4
commit 618bf37deb
68 changed files with 1279 additions and 119 deletions

View File

@@ -2080,14 +2080,13 @@ class _DisplayState extends State<_Display> {
}
Widget otherRow(String label, String key) {
final value = bind.mainGetUserDefaultOption(key: key) == 'Y';
final isOptFixed = isOptionFixed(key);
final value = getOtherDefaultSettingOption(key) == 'Y';
final isOptFixed = isOtherDefaultSettingReadOnly(key);
onChanged(bool b) async {
await bind.mainSetUserDefaultOption(
key: key,
value: b
? 'Y'
: (key == kOptionEnableFileCopyPaste ? 'N' : defaultOptionNo));
await setOtherDefaultSettingOption(
key,
b ? 'Y' : (key == kOptionEnableFileCopyPaste ? 'N' : defaultOptionNo),
);
setState(() {});
}

View File

@@ -19,6 +19,8 @@ class TerminalPage extends StatefulWidget {
required this.tabKey,
this.forceRelay,
this.connToken,
this.onClipboardWriteBlocked,
this.onClipboardWriteSucceeded,
}) : super(key: key);
final String id;
final String? password;
@@ -26,6 +28,8 @@ class TerminalPage extends StatefulWidget {
final bool? forceRelay;
final bool? isSharedPassword;
final String? connToken;
final ValueChanged<String>? onClipboardWriteBlocked;
final ValueChanged<String>? onClipboardWriteSucceeded;
final int terminalId;
/// Tab key for focus management, passed from parent to avoid duplicate construction
@@ -71,6 +75,8 @@ class _TerminalPageState extends State<TerminalPage>
// Create terminal model with specific terminal ID
_terminalModel = TerminalModel(_ffi, widget.terminalId);
_terminalModel.onClipboardWriteBlocked = widget.onClipboardWriteBlocked;
_terminalModel.onClipboardWriteSucceeded = widget.onClipboardWriteSucceeded;
debugPrint(
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'package:desktop_multi_window/desktop_multi_window.dart';
@@ -10,6 +11,8 @@ 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:flutter_hbb/models/terminal_copy_shortcut.dart';
import 'package:flutter_hbb/models/terminal_model.dart';
import 'package:get/get.dart';
import '../../models/platform_model.dart';
@@ -19,6 +22,12 @@ import '../widgets/material_mod_popup_menu.dart' as mod_menu;
import '../widgets/popup_menu.dart';
import 'package:bot_toast/bot_toast.dart';
typedef _TerminalClipboardSource = ({
String peerId,
int terminalId,
String tabKey,
});
class TerminalTabPage extends StatefulWidget {
final Map<String, dynamic> params;
@@ -30,6 +39,18 @@ class TerminalTabPage extends StatefulWidget {
class _TerminalTabPageState extends State<TerminalTabPage> {
DesktopTabController get tabController => Get.find<DesktopTabController>();
bool get _canConfigureTerminalClipboardPermission =>
canConfigureTerminalClipboardPermission(
settingsDisabled: bind.isDisableSettings(),
optionFixed: isOptionFixed(kOptionAllowTerminalClipboardWrite),
);
bool get _canHandleTerminalClipboardWriteRequest =>
canHandleTerminalClipboardWriteRequest(
localOption: bind.mainGetLocalOption(
key: kOptionAllowTerminalClipboardWrite,
),
canConfigurePermission: _canConfigureTerminalClipboardPermission,
);
static const IconData selectedIcon = Icons.terminal;
static const IconData unselectedIcon = Icons.terminal_outlined;
@@ -38,6 +59,9 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
final Set<String> _closingTabs = {};
// When true, all session cleanup should persist (window-level close in progress)
bool _windowClosing = false;
CancelFunc? _terminalClipboardNoticeCancel;
final _terminalClipboardNotice =
TerminalClipboardNoticeCoordinator<_TerminalClipboardSource>();
_TerminalTabPageState(Map<String, dynamic> params) {
Get.put(DesktopTabController(tabType: DesktopTabType.terminal));
@@ -45,7 +69,10 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
WindowController.fromWindowId(windowId())
.setTitle(getWindowNameWithId(id));
};
tabController.onRemoved = (_, id) => onRemoveId(id);
tabController.onRemoved = (_, id) {
_closeTerminalClipboardNoticeForTab(id);
onRemoveId(id);
};
tabController.onCloseWindow = _closeWindowFromConnection;
final terminalId = params['terminalId'] ?? _nextTerminalId++;
tabController.add(_createTerminalTab(
@@ -70,6 +97,11 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
final alias = bind.mainGetPeerOptionSync(id: peerId, key: 'alias');
final tabLabel =
alias.isNotEmpty ? '$alias #$terminalId' : '$peerId #$terminalId';
final clipboardSource = (
peerId: peerId,
terminalId: terminalId,
tabKey: tabKey,
);
return TabInfo(
key: tabKey,
label: tabLabel,
@@ -86,10 +118,169 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
tabController: tabController,
forceRelay: forceRelay,
connToken: connToken,
onClipboardWriteBlocked: _canHandleTerminalClipboardWriteRequest
? (text) => _handleTerminalClipboardWriteBlocked(
clipboardSource,
text,
)
: null,
onClipboardWriteSucceeded: (_) {
_handleTerminalClipboardWriteSucceeded(clipboardSource);
},
),
);
}
void _handleTerminalClipboardWriteBlocked(
_TerminalClipboardSource source,
String clipboardText,
) {
if (!mounted) return;
final option = bind.mainGetLocalOption(
key: kOptionAllowTerminalClipboardWrite,
);
final request = _terminalClipboardNotice.recordBlocked(
source: source,
text: clipboardText,
option: option,
canWrite: _canWriteTerminalClipboard,
);
if (request != null) _showTerminalClipboardNotice(request);
}
void _showTerminalClipboardNotice(
TerminalClipboardNoticeRequest<_TerminalClipboardSource> request,
) {
_terminalClipboardNoticeCancel = BotToast.showCustomNotification(
duration: null,
enableSlideOff: false,
onlyOne: true,
onClose: _handleTerminalClipboardNoticeClosed,
toastBuilder: (_) => AnimatedBuilder(
animation: _terminalClipboardNotice,
builder: (_, __) => MaterialBanner(
leading: const Icon(Icons.content_copy_outlined),
content: Text(translate(kTerminalClipboardNoticeMessageKey)),
actions: [
TextButton(
onPressed: _terminalClipboardNotice.canClaimAction
? _handleTerminalClipboardNegativeAction
: null,
child: Text(translate(request.negativeActionKey)),
),
TextButton(
onPressed: _terminalClipboardNotice.canClaimAction
? _handleTerminalClipboardPositiveAction
: null,
child: Text(translate(request.actionKey)),
),
],
),
),
);
}
void _handleTerminalClipboardNegativeAction() {
final request = _terminalClipboardNotice.claimCurrentAction();
if (request == null) return;
if (request.persistAllowed) {
unawaited(_declineTerminalClipboardWrite());
} else {
_closeTerminalClipboardNotice();
}
}
void _handleTerminalClipboardPositiveAction() {
final request = _terminalClipboardNotice.claimCurrentAction();
if (request == null) return;
unawaited(_completeTerminalClipboardWrite(request));
}
void _handleTerminalClipboardNoticeClosed() {
_terminalClipboardNoticeCancel = null;
_terminalClipboardNotice.noticeClosed();
}
bool _canWriteTerminalClipboard(
_TerminalClipboardSource source,
) {
if (!_canHandleTerminalClipboardWriteRequest) return false;
final ffi = TerminalConnectionManager.getExistingConnection(source.peerId);
return ffi != null &&
!ffi.closed &&
ffi.ffiModel.permissions['clipboard'] != false &&
tabController.state.value.tabs.any((tab) => tab.key == source.tabKey) &&
ffi.terminalModels.containsKey(source.terminalId);
}
void _handleTerminalClipboardWriteSucceeded(
_TerminalClipboardSource source,
) {
final request = _terminalClipboardNotice.currentForSource(source);
if (request == null) return;
_closeTerminalClipboardNotice();
}
Future<void> _declineTerminalClipboardWrite() async {
try {
await bind.mainSetLocalOption(
key: kOptionAllowTerminalClipboardWrite,
value: kTerminalClipboardWriteDenied,
);
} catch (error) {
debugPrint(
'[TerminalTabPage] Failed to save terminal clipboard permission: $error');
return;
} finally {
_terminalClipboardNotice.releaseAction();
}
_closeTerminalClipboardNotice();
}
Future<void> _completeTerminalClipboardWrite(
TerminalClipboardNoticeRequest<_TerminalClipboardSource> request,
) async {
final source = request.source;
var completed = false;
try {
completed = await completeTerminalClipboardWrite(
clipboardText: request.text,
canWrite: () => _canWriteTerminalClipboard(source),
writeClipboard: writeTerminalClipboard,
persistAllowed: request.persistAllowed
? () => bind.mainSetLocalOption(
key: kOptionAllowTerminalClipboardWrite,
value: kTerminalClipboardWriteAllowed,
)
: null,
);
} catch (error) {
debugPrint(
'[TerminalTabPage] Failed to complete terminal clipboard write: $error');
} finally {
_terminalClipboardNotice.releaseAction();
}
if (!completed) return;
_closeTerminalClipboardNotice();
}
void _closeTerminalClipboardNoticeForTab(String tabKey) {
final current = _terminalClipboardNotice.current;
if (current?.source.tabKey != tabKey) return;
_closeTerminalClipboardNotice();
}
void _closeTerminalClipboardNotice() {
if (!_terminalClipboardNotice.beginClose()) return;
final cancel = _terminalClipboardNoticeCancel;
if (cancel == null) {
debugPrint('[TerminalTabPage] Clipboard notice controller is missing');
_terminalClipboardNotice.noticeClosed();
return;
}
cancel();
}
/// Unified tab close handler for all close paths (button, shortcut, programmatic).
/// Shows audit dialog, cleans up session if not persistent, then removes the UI tab.
Future<void> _closeTab(String tabKey) async {
@@ -147,6 +338,8 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
// Remove all UI tabs immediately (same instant behavior as the old tabController.clear())
// Keep the cleanup target lookup below synchronous before its first await:
// it relies on the current frame still retaining each TerminalPage's FFI/model.
_terminalClipboardNotice.clear();
_terminalClipboardNoticeCancel?.call();
tabController.clear();
// Run session cleanup in parallel with bounded timeout (closeTerminal() has internal 3s timeout).
// Skip tabs already being closed by a concurrent _closeTab() to avoid duplicate FFI calls.
@@ -357,6 +550,8 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
_terminalClipboardNotice.clear();
_terminalClipboardNoticeCancel?.call();
super.dispose();
}