import 'dart:async'; import 'dart:convert'; import 'package:dartssh2/dartssh2.dart'; import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_background_service/flutter_background_service.dart'; import 'package:provider/provider.dart'; import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/core/utils/ssh_auth.dart'; import 'package:server_box/core/utils/server.dart'; import 'package:server_box/data/model/server/snippet.dart'; import 'package:server_box/data/provider/virtual_keyboard.dart'; import 'package:server_box/data/res/provider.dart'; import 'package:server_box/data/res/store.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:xterm/core.dart'; import 'package:xterm/ui.dart' hide TerminalThemes; import '../../../core/route.dart'; import '../../../data/model/server/server_private_info.dart'; import '../../../data/model/ssh/virtual_key.dart'; import '../../../data/res/terminal.dart'; const _echoPWD = 'echo \$PWD'; class SSHPage extends StatefulWidget { final ServerPrivateInfo spi; final String? initCmd; final Snippet? initSnippet; final bool notFromTab; final Function()? onSessionEnd; final GlobalKey? terminalKey; final FocusNode? focusNode; const SSHPage({ super.key, required this.spi, this.initCmd, this.initSnippet, this.notFromTab = true, this.onSessionEnd, this.terminalKey, this.focusNode, }); @override State createState() => SSHPageState(); } const _horizonPadding = 7.0; class SSHPageState extends State with AutomaticKeepAliveClientMixin, AfterLayoutMixin { final _keyboard = VirtKeyProvider(); late final _terminal = Terminal(inputHandler: _keyboard); final TerminalController _terminalController = TerminalController(); final List> _virtKeysList = []; late final _termKey = widget.terminalKey ?? GlobalKey(); late MediaQueryData _media; late TerminalStyle _terminalStyle; late TerminalTheme _terminalTheme; double _virtKeyWidth = 0; double _virtKeysHeight = 0; late final _horizonVirtKeys = Stores.setting.horizonVirtKey.fetch(); bool _isDark = false; Timer? _virtKeyLongPressTimer; late SSHClient? _client = widget.spi.server?.client; Timer? _discontinuityTimer; @override void initState() { super.initState(); _initStoredCfg(); _initVirtKeys(); } @override void dispose() { super.dispose(); _virtKeyLongPressTimer?.cancel(); _terminalController.dispose(); _discontinuityTimer?.cancel(); if (!Stores.setting.generalWakeLock.fetch()) WakelockPlus.disable(); } @override void didChangeDependencies() { super.didChangeDependencies(); _isDark = switch (Stores.setting.termTheme.fetch()) { 1 => false, 2 => true, _ => context.isDark, }; _media = MediaQuery.of(context); _terminalTheme = _isDark ? TerminalThemes.dark : TerminalThemes.light; _terminalTheme = _terminalTheme.copyWith(selectionCursor: UIs.primaryColor); // Because the virtual keyboard only displayed on mobile devices if (isMobile) { _virtKeyWidth = _media.size.width / 7; if (_horizonVirtKeys) { _virtKeysHeight = _media.size.height * 0.043; } else { _virtKeysHeight = _media.size.height * 0.043 * _virtKeysList.length; } } } @override Widget build(BuildContext context) { super.build(context); Widget child = Scaffold( backgroundColor: _terminalTheme.background, body: _buildBody(), bottomNavigationBar: isDesktop ? null : _buildBottom(), ); if (isIOS) { child = AnnotatedRegion( value: _isDark ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark, child: child, ); } return child; } Widget _buildBody() { final letterCache = Stores.setting.letterCache.fetch(); return SizedBox( height: _media.size.height - _virtKeysHeight - _media.padding.bottom - _media.padding.top, child: Padding( padding: EdgeInsets.only( top: widget.notFromTab ? CustomAppBar.barHeight ?? 0 : 0, left: _horizonPadding, right: _horizonPadding, ), child: TerminalView( _terminal, key: _termKey, controller: _terminalController, keyboardType: TextInputType.text, enableSuggestions: letterCache, textStyle: _terminalStyle, theme: _terminalTheme, deleteDetection: isMobile, autofocus: false, keyboardAppearance: _isDark ? Brightness.dark : Brightness.light, showToolbar: isMobile, viewOffset: Offset( 2 * _horizonPadding, CustomAppBar.barHeight ?? _media.padding.top, ), hideScrollBar: false, focusNode: widget.focusNode, ), ), ); } Widget _buildBottom() { return SafeArea( child: AnimatedPadding( padding: _media.viewInsets, duration: const Duration(milliseconds: 23), curve: Curves.fastOutSlowIn, child: Container( color: _terminalTheme.background, height: _virtKeysHeight, child: ChangeNotifierProvider( create: (_) => _keyboard, builder: (_, __) => Consumer(builder: (_, __, ___) { return _buildVirtualKey(); }), ), ), ), ); } Widget _buildVirtualKey() { if (_horizonVirtKeys) { return SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: _virtKeysList.expand((e) => e).map(_buildVirtKeyItem).toList(), ), ); } final rows = _virtKeysList .map((e) => Row(children: e.map((f) => _buildVirtKeyItem(f)).toList())) .toList(); return Column( mainAxisSize: MainAxisSize.min, children: rows, ); } Widget _buildVirtKeyItem(VirtKey item) { var selected = false; switch (item.key) { case TerminalKey.control: selected = _keyboard.ctrl; break; case TerminalKey.alt: selected = _keyboard.alt; break; default: break; } final child = item.icon != null ? Icon( item.icon, size: 17, color: _isDark ? Colors.white : Colors.black, ) : Text( item.text, style: TextStyle( color: selected ? UIs.primaryColor : (_isDark ? Colors.white : Colors.black), fontSize: 15, ), ); return InkWell( onTap: () => _doVirtualKey(item), onTapDown: (details) { if (item.canLongPress) { _virtKeyLongPressTimer = Timer.periodic( const Duration(milliseconds: 137), (_) => _doVirtualKey(item), ); } }, onTapCancel: () => _virtKeyLongPressTimer?.cancel(), onTapUp: (_) => _virtKeyLongPressTimer?.cancel(), child: SizedBox( width: _virtKeyWidth, height: _virtKeysHeight / _virtKeysList.length, child: Center(child: child), ), ); } void _doVirtualKey(VirtKey item) { if (item.func != null) { HapticFeedback.mediumImpact(); _doVirtualKeyFunc(item.func!); return; } if (item.key != null) { HapticFeedback.mediumImpact(); _doVirtualKeyInput(item.key!); } } 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: Pros.snippet.tags, itemsBuilder: (e) { if (e == null) return Pros.snippet.snippets; return Pros.snippet.snippets .where((element) => element.tags?.contains(e) ?? false) .toList(); }, name: (e) => e.name, ); if (snippets == null || snippets.isEmpty) return; final snippet = snippets.firstOrNull; if (snippet == null) return; snippet.runInTerm(_terminal, widget.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[idx + 1].toString(); if (initPath.isEmpty || !initPath.startsWith('/')) { context.showRoundDialog( title: libL10n.error, child: const Text('Failed to get current path'), ); return; } AppRoutes.sftp(spi: widget.spi, initPath: initPath).go(context); } } void _paste() { Clipboard.getData(Clipboard.kTextPlain).then((value) { if (value != null) { _terminal.textInput(value.text!); } }); } String? get terminalSelected { final range = _terminalController.selection; if (range == null) { return null; } return _terminal.buffer.getText(range); } void _writeLn(String p0) { _terminal.write('$p0\r\n'); } void _initVirtKeys() { final virtKeys = () { try { return Stores.setting.sshVirtKeys .fetch() .map((e) => VirtKey.values[e]) .toList(); } catch (_) { return VirtKey.values; } }(); 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)); } } } Future _initTerminal() async { _writeLn('Connecting...\r\n'); _client ??= await genClient( widget.spi, onStatus: (p0) { _writeLn(p0.toString()); }, onKeyboardInteractive: (_) => KeybordInteractive.defaultHandle(widget.spi), ); _writeLn('Starting shell...\r\n'); final session = await _client?.shell( pty: SSHPtyConfig( width: _terminal.viewWidth, height: _terminal.viewHeight, ), environment: widget.spi.envs, ); //_setupDiscontinuityTimer(); if (session == null) { _writeLn('Null session, please back and retry\r\n'); 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); _initService(); for (final snippet in Pros.snippet.snippets) { if (snippet.autoRunOn?.contains(widget.spi.id) == true) { snippet.runInTerm(_terminal, widget.spi); } } if (widget.initCmd != null) { _terminal.textInput(widget.initCmd!); _terminal.keyInput(TerminalKey.enter); } if (widget.initSnippet != null) { widget.initSnippet!.runInTerm(_terminal, widget.spi); } widget.focusNode?.requestFocus(); await session.done; if (mounted && widget.notFromTab) { context.pop(); } widget.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: Text(l10n.attention), // child: Text('${l10n.disconnected}\n${l10n.goBackQ}'), // barrierDismiss: false, // actions: [ // TextButton( // onPressed: () { // if (mounted) { // context.pop(); // if (widget.pop) { // context.pop(); // } // } // }, // child: Text(l10n.ok), // ), // ], // ); // } @override bool get wantKeepAlive => true; Future _initService() async { if (!isAndroid) return; await FlutterBackgroundService().configure( androidConfiguration: AndroidConfiguration( onStart: _onStart, autoStart: true, isForegroundMode: true, initialNotificationTitle: 'SSH', initialNotificationContent: l10n.bgRun, ), iosConfiguration: IosConfiguration(), ); } 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(); } } Future _onStart(ServiceInstance service) async {}