Files
flutter_server_box/lib/view/page/ssh/page.dart
lollipopkit🏳️‍⚧️ 4d8268c614 fix: ssh tab focus mgmt (#525)
Fixes #522
2024-08-11 22:36:52 +08:00

551 lines
15 KiB
Dart

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<TerminalViewState>? 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<SSHPage> createState() => SSHPageState();
}
const _horizonPadding = 7.0;
class SSHPageState extends State<SSHPage>
with AutomaticKeepAliveClientMixin, AfterLayoutMixin {
final _keyboard = VirtKeyProvider();
late final _terminal = Terminal(inputHandler: _keyboard);
final TerminalController _terminalController = TerminalController();
final List<List<VirtKey>> _virtKeysList = [];
late final _termKey = widget.terminalKey ?? GlobalKey<TerminalViewState>();
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<VirtKeyProvider>(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<void> _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<Snippet>(
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<void> _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<Uint8List>? stream) {
if (stream == null) {
return;
}
stream
.cast<List<int>>()
.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<void> _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<void> _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<void> afterFirstLayout(BuildContext context) async {
await _showHelp();
await _initTerminal();
if (Stores.setting.sshWakeLock.fetch()) WakelockPlus.enable();
}
}
Future<void> _onStart(ServiceInstance service) async {}