From 2b2f1ddb60333186149e69d3329bb158c62111d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Thu, 15 May 2025 20:35:45 +0800 Subject: [PATCH] opt.: handle esc btn in ssh page (#761) --- lib/view/page/container.dart | 2 +- lib/view/page/iperf.dart | 2 +- lib/view/page/ssh/page/init.dart | 146 +++++++++++++ lib/view/page/ssh/page/keyboard.dart | 19 ++ lib/view/page/ssh/{ => page}/page.dart | 292 ++----------------------- lib/view/page/ssh/page/virt_key.dart | 130 +++++++++++ lib/view/page/ssh/tab.dart | 15 +- lib/view/page/storage/sftp.dart | 2 +- lib/view/page/systemd.dart | 2 +- lib/view/widget/server_func_btns.dart | 2 +- 10 files changed, 326 insertions(+), 286 deletions(-) create mode 100644 lib/view/page/ssh/page/init.dart create mode 100644 lib/view/page/ssh/page/keyboard.dart rename lib/view/page/ssh/{ => page}/page.dart (51%) create mode 100644 lib/view/page/ssh/page/virt_key.dart diff --git a/lib/view/page/container.dart b/lib/view/page/container.dart index eba13932..65bba8eb 100644 --- a/lib/view/page/container.dart +++ b/lib/view/page/container.dart @@ -14,7 +14,7 @@ import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/model/container/ps.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/provider/container.dart'; -import 'package:server_box/view/page/ssh/page.dart'; +import 'package:server_box/view/page/ssh/page/page.dart'; class ContainerPage extends StatefulWidget { final SpiRequiredArgs args; diff --git a/lib/view/page/iperf.dart b/lib/view/page/iperf.dart index b1e6bc1f..76116153 100644 --- a/lib/view/page/iperf.dart +++ b/lib/view/page/iperf.dart @@ -2,7 +2,7 @@ import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/core/route.dart'; -import 'package:server_box/view/page/ssh/page.dart'; +import 'package:server_box/view/page/ssh/page/page.dart'; class IPerfPage extends StatefulWidget { diff --git a/lib/view/page/ssh/page/init.dart b/lib/view/page/ssh/page/init.dart new file mode 100644 index 00000000..112f2e10 --- /dev/null +++ b/lib/view/page/ssh/page/init.dart @@ -0,0 +1,146 @@ +part of 'page.dart'; + +extension _Init on SSHPageState { + void _initStoredCfg() { + final fontFamilly = Stores.setting.fontPath.fetch().getFileName(); + final textSize = Stores.setting.termFontSize.fetch(); + final textStyle = TextStyle( + fontFamily: fontFamilly, + fontSize: textSize, + ); + + _terminalStyle = TerminalStyle.fromTextStyle(textStyle); + } + + Future _showHelp() async { + if (Stores.setting.sshTermHelpShown.fetch()) return; + + return await context.showRoundDialog( + title: libL10n.doc, + child: Text(l10n.sshTermHelp), + actions: [ + TextButton( + onPressed: () { + Stores.setting.sshTermHelpShown.put(true); + context.pop(); + }, + child: Text(l10n.noPromptAgain), + ), + ], + ); + } + + Future _initTerminal() async { + _writeLn(l10n.waitConnection); + _client ??= await genClient( + widget.args.spi, + onStatus: (p0) { + _writeLn(p0.toString()); + }, + onKeyboardInteractive: _onKeyboardInteractive, + ); + + _writeLn('${libL10n.execute}: Shell'); + final session = await _client?.shell( + pty: SSHPtyConfig( + width: _terminal.viewWidth, + height: _terminal.viewHeight, + ), + environment: widget.args.spi.envs, + ); + + if (session == null) { + _writeLn(libL10n.fail); + return; + } + + _terminal.buffer.clear(); + _terminal.buffer.setCursor(0, 0); + + _terminal.onOutput = (data) { + session.write(utf8.encode(data)); + }; + _terminal.onResize = (width, height, pixelWidth, pixelHeight) { + session.resizeTerminal(width, height); + }; + + _listen(session.stdout); + _listen(session.stderr); + + for (final snippet in SnippetProvider.snippets.value) { + if (snippet.autoRunOn?.contains(widget.args.spi.id) == true) { + snippet.runInTerm(_terminal, widget.args.spi); + } + } + + final initCmd = widget.args.initCmd; + if (initCmd != null) { + _terminal.textInput(initCmd); + _terminal.keyInput(TerminalKey.enter); + } + + final initSnippet = widget.args.initSnippet; + if (initSnippet != null) { + initSnippet.runInTerm(_terminal, widget.args.spi); + } + + widget.args.focusNode?.requestFocus(); + + await session.done; + if (mounted && widget.args.notFromTab) { + context.pop(); + } + widget.args.onSessionEnd?.call(); + } + + void _listen(Stream? stream) { + if (stream == null) { + return; + } + + stream.cast>().transform(const Utf8Decoder()).listen( + _terminal.write, + onError: (Object error, StackTrace stack) { + // _terminal.write('Stream error: $error\n'); + Loggers.root.warning('Error in SSH stream', error, stack); + }, + cancelOnError: false, + ); + } + + void _setupDiscontinuityTimer() { + _discontinuityTimer = Timer.periodic( + const Duration(seconds: 5), + (_) async { + var throwTimeout = true; + Future.delayed(const Duration(seconds: 3), () { + if (throwTimeout) { + _catchTimeout(); + } + }); + await _client?.ping(); + throwTimeout = false; + }, + ); + } + + void _catchTimeout() { + _discontinuityTimer?.cancel(); + if (!mounted) return; + _writeLn('\n\nConnection lost\r\n'); + context.showRoundDialog( + title: libL10n.attention, + child: Text('${l10n.disconnected}\n${l10n.goBackQ}'), + barrierDismiss: false, + actions: Btn.ok( + onTap: () { + contextSafe?.pop(); // Can't use tear-drop here + }, + ).toList, + ); + } + + void _writeLn(String p0) { + _terminal.write('$p0\r\n'); + } +} diff --git a/lib/view/page/ssh/page/keyboard.dart b/lib/view/page/ssh/page/keyboard.dart new file mode 100644 index 00000000..8afbfa1c --- /dev/null +++ b/lib/view/page/ssh/page/keyboard.dart @@ -0,0 +1,19 @@ +part of 'page.dart'; + +extension _Keyboard on SSHPageState { + void _handleEscKeyOrBackButton() { + _terminal.keyInput(TerminalKey.escape); + HapticFeedback.lightImpact(); + } + + bool _handleKeyEvent(KeyEvent event) { + if (event is KeyDownEvent) { + if (event.logicalKey == LogicalKeyboardKey.escape) { + // Prevent default behavior and send to terminal + _handleEscKeyOrBackButton(); + return true; // Mark as handled so it doesn't propagate + } + } + return false; // Let other handlers process this event + } +} diff --git a/lib/view/page/ssh/page.dart b/lib/view/page/ssh/page/page.dart similarity index 51% rename from lib/view/page/ssh/page.dart rename to lib/view/page/ssh/page/page.dart index d0af18fe..a80c2ca8 100644 --- a/lib/view/page/ssh/page.dart +++ b/lib/view/page/ssh/page/page.dart @@ -23,6 +23,10 @@ import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/model/ssh/virtual_key.dart'; import 'package:server_box/data/res/terminal.dart'; +part 'init.dart'; +part 'keyboard.dart'; +part 'virt_key.dart'; + const _echoPWD = 'echo \$PWD'; final class SshPageArgs { @@ -92,12 +96,15 @@ class SSHPageState extends State with AutomaticKeepAliveClientMixin, Af _terminalController.dispose(); _discontinuityTimer?.cancel(); + HardwareKeyboard.instance.removeHandler(_handleKeyEvent); + if (--_sshConnCount <= 0) { WakelockPlus.disable(); if (isAndroid) { MethodChans.stopService(); } } + super.dispose(); } @@ -143,11 +150,19 @@ class SSHPageState extends State with AutomaticKeepAliveClientMixin, Af @override Widget build(BuildContext context) { super.build(context); - Widget child = Scaffold( - backgroundColor: _terminalTheme.background, - body: _buildBody(), - bottomNavigationBar: isDesktop ? null : _buildBottom(), + Widget child = PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + _handleEscKeyOrBackButton(); + }, + child: Scaffold( + backgroundColor: _terminalTheme.background, + body: _buildBody(), + bottomNavigationBar: isDesktop ? null : _buildBottom(), + ), ); + if (isIOS) { child = AnnotatedRegion( value: _isDark ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark, @@ -276,278 +291,13 @@ class SSHPageState extends State with AutomaticKeepAliveClientMixin, Af @override bool get wantKeepAlive => true; - void _initStoredCfg() { - final fontFamilly = Stores.setting.fontPath.fetch().getFileName(); - final textSize = Stores.setting.termFontSize.fetch(); - final textStyle = TextStyle( - fontFamily: fontFamilly, - fontSize: textSize, - ); - - _terminalStyle = TerminalStyle.fromTextStyle(textStyle); - } - - Future _showHelp() async { - if (Stores.setting.sshTermHelpShown.fetch()) return; - - return await context.showRoundDialog( - title: libL10n.doc, - child: Text(l10n.sshTermHelp), - actions: [ - TextButton( - onPressed: () { - Stores.setting.sshTermHelpShown.put(true); - context.pop(); - }, - child: Text(l10n.noPromptAgain), - ), - ], - ); - } - @override FutureOr afterFirstLayout(BuildContext context) async { await _showHelp(); await _initTerminal(); if (Stores.setting.sshWakeLock.fetch()) WakelockPlus.enable(); - } -} - -extension _Init on SSHPageState { - Future _initTerminal() async { - _writeLn(l10n.waitConnection); - _client ??= await genClient( - widget.args.spi, - onStatus: (p0) { - _writeLn(p0.toString()); - }, - onKeyboardInteractive: _onKeyboardInteractive, - ); - - _writeLn('${libL10n.execute}: Shell'); - final session = await _client?.shell( - pty: SSHPtyConfig( - width: _terminal.viewWidth, - height: _terminal.viewHeight, - ), - environment: widget.args.spi.envs, - ); - - //_setupDiscontinuityTimer(); - - if (session == null) { - _writeLn(libL10n.fail); - return; - } - - _terminal.buffer.clear(); - _terminal.buffer.setCursor(0, 0); - - _terminal.onOutput = (data) { - session.write(utf8.encode(data)); - }; - _terminal.onResize = (width, height, pixelWidth, pixelHeight) { - session.resizeTerminal(width, height); - }; - - _listen(session.stdout); - _listen(session.stderr); - - for (final snippet in SnippetProvider.snippets.value) { - if (snippet.autoRunOn?.contains(widget.args.spi.id) == true) { - snippet.runInTerm(_terminal, widget.args.spi); - } - } - - if (widget.args.initCmd != null) { - _terminal.textInput(widget.args.initCmd!); - _terminal.keyInput(TerminalKey.enter); - } - - if (widget.args.initSnippet != null) { - widget.args.initSnippet!.runInTerm(_terminal, widget.args.spi); - } - - widget.args.focusNode?.requestFocus(); - - await session.done; - if (mounted && widget.args.notFromTab) { - context.pop(); - } - widget.args.onSessionEnd?.call(); - } - - void _listen(Stream? stream) { - if (stream == null) { - return; - } - stream.cast>().transform(const Utf8Decoder()).listen(_terminal.write); - } - - void _setupDiscontinuityTimer() { - _discontinuityTimer = Timer.periodic( - const Duration(seconds: 5), - (_) async { - var throwTimeout = true; - Future.delayed(const Duration(seconds: 3), () { - if (throwTimeout) { - _catchTimeout(); - } - }); - await _client?.ping(); - throwTimeout = false; - }, - ); - } - - void _catchTimeout() { - _discontinuityTimer?.cancel(); - if (!mounted) return; - _writeLn('\n\nConnection lost\r\n'); - context.showRoundDialog( - title: libL10n.attention, - child: Text('${l10n.disconnected}\n${l10n.goBackQ}'), - barrierDismiss: false, - actions: Btn.ok( - onTap: () { - if (mounted) { - context.pop(); - } - }, - ).toList, - ); - } - - void _writeLn(String p0) { - _terminal.write('$p0\r\n'); - } -} - -extension _VirtKey on SSHPageState { - void _doVirtualKey(VirtKey item) { - if (item.func != null) { - HapticFeedback.mediumImpact(); - _doVirtualKeyFunc(item.func!); - return; - } - if (item.key != null) { - HapticFeedback.mediumImpact(); - _doVirtualKeyInput(item.key!); - } - final inputRaw = item.inputRaw; - if (inputRaw != null) { - HapticFeedback.mediumImpact(); - _terminal.textInput(inputRaw); - } - } - - void _doVirtualKeyInput(TerminalKey key) { - switch (key) { - case TerminalKey.control: - _keyboard.ctrl = !_keyboard.ctrl; - break; - case TerminalKey.alt: - _keyboard.alt = !_keyboard.alt; - break; - default: - _terminal.keyInput(key); - break; - } - } - - Future _doVirtualKeyFunc(VirtualKeyFunc type) async { - switch (type) { - case VirtualKeyFunc.toggleIME: - _termKey.currentState?.toggleFocus(); - break; - case VirtualKeyFunc.backspace: - _terminal.keyInput(TerminalKey.backspace); - break; - case VirtualKeyFunc.clipboard: - final selected = terminalSelected; - if (selected != null) { - Pfs.copy(selected); - } else { - _paste(); - } - break; - case VirtualKeyFunc.snippet: - final snippets = await context.showPickWithTagDialog( - title: l10n.snippet, - tags: SnippetProvider.tags, - itemsBuilder: (e) { - if (e == TagSwitcher.kDefaultTag) { - return SnippetProvider.snippets.value; - } - return SnippetProvider.snippets.value - .where((element) => element.tags?.contains(e) ?? false) - .toList(); - }, - display: (e) => e.name, - ); - if (snippets == null || snippets.isEmpty) return; - - final snippet = snippets.firstOrNull; - if (snippet == null) return; - snippet.runInTerm(_terminal, widget.args.spi); - break; - case VirtualKeyFunc.file: - // get $PWD from SSH session - _terminal.textInput(_echoPWD); - _terminal.keyInput(TerminalKey.enter); - final cmds = _terminal.buffer.lines.toList(); - // wait for the command to finish - await Future.delayed(const Duration(milliseconds: 777)); - // the line below `echo $PWD` is the current path - final idx = cmds.lastIndexWhere((e) => e.toString().contains(_echoPWD)); - final initPath = cmds.elementAtOrNull(idx + 1)?.toString(); - if (initPath == null || !initPath.startsWith('/')) { - context.showRoundDialog( - title: libL10n.error, - child: Text('${l10n.remotePath}: $initPath'), - ); - return; - } - final args = SftpPageArgs(spi: widget.args.spi, initPath: initPath); - SftpPage.route.go(context, args); - } - } - - void _paste() { - Clipboard.getData(Clipboard.kTextPlain).then((value) { - final text = value?.text; - if (text != null) { - _terminal.textInput(text); - } else { - context.showRoundDialog( - title: libL10n.error, - child: Text(libL10n.empty), - ); - } - }); - } - - String? get terminalSelected { - final range = _terminalController.selection; - if (range == null) { - return null; - } - return _terminal.buffer.getText(range); - } - - void _initVirtKeys() { - final virtKeys = VirtKeyX.loadFromStore(); - for (int len = 0; len < virtKeys.length; len += 7) { - if (len + 7 > virtKeys.length) { - _virtKeysList.add(virtKeys.sublist(len)); - } else { - _virtKeysList.add(virtKeys.sublist(len, len + 7)); - } - } - } - - FutureOr?> _onKeyboardInteractive(SSHUserInfoRequest req) { - return KeybordInteractive.defaultHandle(widget.args.spi, ctx: context); + + HardwareKeyboard.instance.addHandler(_handleKeyEvent); } } diff --git a/lib/view/page/ssh/page/virt_key.dart b/lib/view/page/ssh/page/virt_key.dart new file mode 100644 index 00000000..062df402 --- /dev/null +++ b/lib/view/page/ssh/page/virt_key.dart @@ -0,0 +1,130 @@ +part of 'page.dart'; + +extension _VirtKey on SSHPageState { + void _doVirtualKey(VirtKey item) { + if (item.func != null) { + HapticFeedback.mediumImpact(); + _doVirtualKeyFunc(item.func!); + return; + } + if (item.key != null) { + HapticFeedback.mediumImpact(); + _doVirtualKeyInput(item.key!); + } + final inputRaw = item.inputRaw; + if (inputRaw != null) { + HapticFeedback.mediumImpact(); + _terminal.textInput(inputRaw); + } + } + + void _doVirtualKeyInput(TerminalKey key) { + switch (key) { + case TerminalKey.control: + _keyboard.ctrl = !_keyboard.ctrl; + break; + case TerminalKey.alt: + _keyboard.alt = !_keyboard.alt; + break; + default: + _terminal.keyInput(key); + break; + } + } + + Future _doVirtualKeyFunc(VirtualKeyFunc type) async { + switch (type) { + case VirtualKeyFunc.toggleIME: + _termKey.currentState?.toggleFocus(); + break; + case VirtualKeyFunc.backspace: + _terminal.keyInput(TerminalKey.backspace); + break; + case VirtualKeyFunc.clipboard: + final selected = terminalSelected; + if (selected != null) { + Pfs.copy(selected); + } else { + _paste(); + } + break; + case VirtualKeyFunc.snippet: + final snippets = await context.showPickWithTagDialog( + title: l10n.snippet, + tags: SnippetProvider.tags, + itemsBuilder: (e) { + if (e == TagSwitcher.kDefaultTag) { + return SnippetProvider.snippets.value; + } + return SnippetProvider.snippets.value + .where((element) => element.tags?.contains(e) ?? false) + .toList(); + }, + display: (e) => e.name, + ); + if (snippets == null || snippets.isEmpty) return; + + final snippet = snippets.firstOrNull; + if (snippet == null) return; + snippet.runInTerm(_terminal, widget.args.spi); + break; + case VirtualKeyFunc.file: + // get $PWD from SSH session + _terminal.textInput(_echoPWD); + _terminal.keyInput(TerminalKey.enter); + final cmds = _terminal.buffer.lines.toList(); + // wait for the command to finish + await Future.delayed(const Duration(milliseconds: 777)); + // the line below `echo $PWD` is the current path + final idx = cmds.lastIndexWhere((e) => e.toString().contains(_echoPWD)); + final initPath = cmds.elementAtOrNull(idx + 1)?.toString(); + if (initPath == null || !initPath.startsWith('/')) { + context.showRoundDialog( + title: libL10n.error, + child: Text('${l10n.remotePath}: $initPath'), + ); + return; + } + final args = SftpPageArgs(spi: widget.args.spi, initPath: initPath); + SftpPage.route.go(context, args); + break; + } + } + + void _paste() { + Clipboard.getData(Clipboard.kTextPlain).then((value) { + final text = value?.text; + if (text != null) { + _terminal.textInput(text); + } else { + context.showRoundDialog( + title: libL10n.error, + child: Text(libL10n.empty), + ); + } + }); + } + + String? get terminalSelected { + final range = _terminalController.selection; + if (range == null) { + return null; + } + return _terminal.buffer.getText(range); + } + + void _initVirtKeys() { + final virtKeys = VirtKeyX.loadFromStore(); + for (int len = 0; len < virtKeys.length; len += 7) { + if (len + 7 > virtKeys.length) { + _virtKeysList.add(virtKeys.sublist(len)); + } else { + _virtKeysList.add(virtKeys.sublist(len, len + 7)); + } + } + } + + FutureOr?> _onKeyboardInteractive(SSHUserInfoRequest req) { + return KeybordInteractive.defaultHandle(widget.args.spi, ctx: context); + } +} diff --git a/lib/view/page/ssh/tab.dart b/lib/view/page/ssh/tab.dart index 8aead183..0ee06119 100644 --- a/lib/view/page/ssh/tab.dart +++ b/lib/view/page/ssh/tab.dart @@ -8,7 +8,7 @@ import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/provider/server.dart'; import 'package:server_box/view/page/server/edit.dart'; -import 'package:server_box/view/page/ssh/page.dart'; +import 'package:server_box/view/page/ssh/page/page.dart'; class SSHTabPage extends StatefulWidget { const SSHTabPage({super.key}); @@ -144,15 +144,10 @@ extension on _SSHTabPageState { } void _onTapClose(String name) async { - final confirm = await showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: Text(libL10n.attention), - content: Text('${libL10n.close} SSH ${l10n.conn}($name) ?'), - actions: Btnx.okReds, - ); - }, + final confirm = await contextSafe?.showRoundDialog( + title: libL10n.attention, + child: Text('${libL10n.close} SSH ${l10n.conn}($name) ?'), + actions: Btnx.okReds, ); Future.delayed(Durations.short1, FocusScope.of(context).unfocus); if (confirm != true) return; diff --git a/lib/view/page/storage/sftp.dart b/lib/view/page/storage/sftp.dart index 437d9c50..1537b07b 100644 --- a/lib/view/page/storage/sftp.dart +++ b/lib/view/page/storage/sftp.dart @@ -14,7 +14,7 @@ import 'package:server_box/data/provider/sftp.dart'; import 'package:server_box/data/res/misc.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/store/setting.dart'; -import 'package:server_box/view/page/ssh/page.dart'; +import 'package:server_box/view/page/ssh/page/page.dart'; import 'package:server_box/view/page/storage/local.dart'; import 'package:server_box/view/page/storage/sftp_mission.dart'; import 'package:server_box/view/widget/omit_start_text.dart'; diff --git a/lib/view/page/systemd.dart b/lib/view/page/systemd.dart index 5f78e3e6..46138a2d 100644 --- a/lib/view/page/systemd.dart +++ b/lib/view/page/systemd.dart @@ -4,7 +4,7 @@ import 'package:server_box/core/route.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/model/server/systemd.dart'; import 'package:server_box/data/provider/systemd.dart'; -import 'package:server_box/view/page/ssh/page.dart'; +import 'package:server_box/view/page/ssh/page/page.dart'; final class SystemdPage extends StatefulWidget { final SpiRequiredArgs args; diff --git a/lib/view/widget/server_func_btns.dart b/lib/view/widget/server_func_btns.dart index ba9a39ff..b401ec3a 100644 --- a/lib/view/widget/server_func_btns.dart +++ b/lib/view/widget/server_func_btns.dart @@ -12,7 +12,7 @@ import 'package:server_box/data/res/store.dart'; import 'package:server_box/view/page/container.dart'; import 'package:server_box/view/page/iperf.dart'; import 'package:server_box/view/page/process.dart'; -import 'package:server_box/view/page/ssh/page.dart'; +import 'package:server_box/view/page/ssh/page/page.dart'; import 'package:server_box/view/page/storage/sftp.dart'; import 'package:server_box/view/page/systemd.dart';