diff --git a/lib/core/chan.dart b/lib/core/chan.dart index e81ce7605..d704f2557 100644 --- a/lib/core/chan.dart +++ b/lib/core/chan.dart @@ -18,10 +18,14 @@ abstract final class MethodChans { _channel.invokeMethod('stopService'); } - static void updateHomeWidget() async { + static Future updateHomeWidget() async { if (!isIOS && !isAndroid) return; if (!Stores.setting.autoUpdateHomeWidget.fetch()) return; - await _channel.invokeMethod('updateHomeWidget'); + try { + await _channel.invokeMethod('updateHomeWidget'); + } catch (e, s) { + Loggers.app.warning('Failed to update home widget', e, s); + } } /// Update Android foreground service notifications for SSH sessions diff --git a/lib/core/utils/server.dart b/lib/core/utils/server.dart index 340bb0141..63b34d48c 100644 --- a/lib/core/utils/server.dart +++ b/lib/core/utils/server.dart @@ -17,12 +17,17 @@ import 'package:server_box/data/res/store.dart'; /// Because of this function is called by [compute]. /// /// https://stackoverflow.com/questions/51998995/invalid-arguments-illegal-argument-in-isolate-message-object-is-a-closure -List loadIndentity(String key) { +List loadIdentity(String key) { return SSHKeyPair.fromPem(key); } +/// Decrypts an encrypted PEM private key. +/// +/// Must also be a top-level function because it is called via [Computer] +/// (isolate) — see comment on [loadIdentity]. +/// /// [args] : [key, pwd] -String decyptPem(List args) { +String decryptPem(List args) { /// skip when the key is not encrypted, or will throw exception if (!SSHKeyPair.isEncryptedPem(args[0])) return args[0]; final sshKey = SSHKeyPair.fromPem(args[0], args[1]); @@ -214,7 +219,7 @@ Future genClient( socket, username: spi.user, // Must use [compute] here, instead of [Computer.shared.start] - identities: await compute(loadIndentity, privateKey), + identities: await compute(loadIdentity, privateKey), onUserInfoRequest: onKeyboardInteractive, onVerifyHostKey: hostKeyVerifier.call, ); @@ -249,10 +254,11 @@ bool _isJumpFailoverError(Object error) { errStr.contains('connection reset') || errStr.contains('connection closed') || errStr.contains('no route to host') || - errStr.contains('network') || - errStr.contains('socket') || + errStr.contains('network unreachable') || + errStr.contains('network is unreachable') || + errStr.contains('socketexception') || errStr.contains('failed host lookup') || - errStr.contains('forward') || + errStr.contains('forwardlocal') || errStr.contains('proxycommand exited') || errStr.contains('proxycommand timed out'); } diff --git a/lib/core/utils/server_dedup.dart b/lib/core/utils/server_dedup.dart index 3b86302d8..dba0fff38 100644 --- a/lib/core/utils/server_dedup.dart +++ b/lib/core/utils/server_dedup.dart @@ -8,12 +8,15 @@ import 'package:server_box/data/store/server.dart'; class ServerDeduplication { /// Remove duplicate servers from the import list based on existing servers /// Returns the deduplicated list - static List deduplicateServers(List importedServers) { - final existingServers = ServerStore.instance.fetch(); + static List deduplicateServers( + List importedServers, { + List? existingServers, + }) { + final existing = existingServers ?? ServerStore.instance.fetch(); final deduplicated = []; for (final imported in importedServers) { - if (!_isDuplicate(imported, existingServers)) { + if (!_isDuplicate(imported, existing)) { deduplicated.add(imported); } } @@ -33,9 +36,12 @@ class ServerDeduplication { } /// Resolve name conflicts by appending suffixes - static List resolveNameConflicts(List importedServers) { - final existingServers = ServerStore.instance.fetch(); - final existingNames = existingServers.map((s) => s.name).toSet(); + static List resolveNameConflicts( + List importedServers, { + List? existingServers, + }) { + final existing = existingServers ?? ServerStore.instance.fetch(); + final existingNames = existing.map((s) => s.name).toSet(); final processedNames = {}; final result = []; @@ -111,8 +117,12 @@ class ServerDeduplication { } static List _resolveServers(List servers) { - final deduplicated = deduplicateServers(servers); - final resolved = resolveNameConflicts(deduplicated); + final existing = ServerStore.instance.fetch(); + final deduplicated = deduplicateServers(servers, existingServers: existing); + final resolved = resolveNameConflicts( + deduplicated, + existingServers: existing, + ); return resolved; } } diff --git a/lib/core/utils/sftp_sudo.dart b/lib/core/utils/sftp_sudo.dart index 52721b8a0..0e2e087b9 100644 --- a/lib/core/utils/sftp_sudo.dart +++ b/lib/core/utils/sftp_sudo.dart @@ -237,10 +237,13 @@ final class SftpSudoHelper { } static String _buildSudoCommand(String command, String password) { - final pwdBase64 = base64Encode(utf8.encode(password)); final wrapped = '($command) 2>&1'; final escapedWrapped = wrapped.replaceAll("'", "'\\''"); - return 'echo "$pwdBase64" | base64 -d | sudo -S -- sh -c \'$escapedWrapped\''; + // Use shell builtin printf to pipe password to sudo -S. + // printf is a shell builtin so the password does not appear in + // the process argument list (unlike external `echo`). + final escapedPwd = password.replaceAll("'", "'\\''"); + return "printf '%s\\n' '$escapedPwd' | sudo -S -- sh -c '$escapedWrapped'"; } static SftpFileMode _buildMode(String typeChar, int permOct) { diff --git a/lib/data/model/container/ps.dart b/lib/data/model/container/ps.dart index 989000062..6746e29d2 100644 --- a/lib/data/model/container/ps.dart +++ b/lib/data/model/container/ps.dart @@ -149,7 +149,7 @@ final class DockerPs implements ContainerPs { /// CONTAINER ID NAMES IMAGE STATUS /// a049d689e7a1 aria2-pro p3terx/aria2-pro Up 3 weeks factory DockerPs.parse(String raw) { - final parts = raw.split(Miscs.multiBlankreg); + final parts = raw.split(Miscs.multiBlankReg); return DockerPs( id: parts[0], state: parts[1], diff --git a/lib/data/model/server/server.dart b/lib/data/model/server/server.dart index 0bf3b7e9e..80b7ce8d9 100644 --- a/lib/data/model/server/server.dart +++ b/lib/data/model/server/server.dart @@ -64,5 +64,8 @@ enum ServerConn { /// Status parsing finished finished; + /// Orders by declaration index: failed < disconnected < connecting < + /// connected < loading < finished. Do NOT reorder the enum values + /// above without auditing all call sites that rely on this ordering. bool operator <(ServerConn other) => index < other.index; } diff --git a/lib/data/model/server/server_private_info.dart b/lib/data/model/server/server_private_info.dart index daba1db59..d60c7aa07 100644 --- a/lib/data/model/server/server_private_info.dart +++ b/lib/data/model/server/server_private_info.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:fl_lib/fl_lib.dart'; +import 'package:flutter/foundation.dart' show listEquals; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:server_box/data/model/app/error.dart'; import 'package:server_box/data/model/server/custom.dart'; @@ -148,7 +149,7 @@ extension Spix on Spi { port == other.port && pwd == other.pwd && keyId == other.keyId && - _sameStringList(resolvedJumpIds, other.resolvedJumpIds) && + listEquals(resolvedJumpIds, other.resolvedJumpIds) && proxyCommand == other.proxyCommand; } @@ -208,11 +209,3 @@ extension Spix on Spi { /// Returns true if the user is 'root'. bool get isRoot => user == 'root'; } - -bool _sameStringList(List a, List b) { - if (a.length != b.length) return false; - for (var i = 0; i < a.length; i++) { - if (a[i] != b[i]) return false; - } - return true; -} diff --git a/lib/data/provider/server/all.dart b/lib/data/provider/server/all.dart index 1a2674ce1..123e54bdb 100644 --- a/lib/data/provider/server/all.dart +++ b/lib/data/provider/server/all.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:fl_lib/fl_lib.dart'; +import 'package:flutter/foundation.dart' show listEquals; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:server_box/core/sync.dart'; @@ -335,18 +336,8 @@ class ServersNotifier extends _$ServersNotifier { } bool _isSameOrder(List a, List b) { - if (identical(a, b)) { - return true; - } - if (a.length != b.length) { - return false; - } - for (var i = 0; i < a.length; i++) { - if (a[i] != b[i]) { - return false; - } - } - return true; + if (identical(a, b)) return true; + return listEquals(a, b); } Future updateServer(Spi old, Spi newSpi) async { diff --git a/lib/data/res/misc.dart b/lib/data/res/misc.dart index b759e7be7..0e363c155 100644 --- a/lib/data/res/misc.dart +++ b/lib/data/res/misc.dart @@ -2,7 +2,7 @@ import 'dart:convert'; abstract final class Miscs { static final blankReg = RegExp(r'\s+'); - static final multiBlankreg = RegExp(r'\s{2,}'); + static final multiBlankReg = RegExp(r'\s{2,}'); /// RegExp for password request static final pwdRequestWithUserReg = RegExp(r'\[sudo\] password for (.+):'); diff --git a/lib/data/ssh/persistent_shell.dart b/lib/data/ssh/persistent_shell.dart index 8b19e5b75..ccb880728 100644 --- a/lib/data/ssh/persistent_shell.dart +++ b/lib/data/ssh/persistent_shell.dart @@ -287,16 +287,6 @@ printf '\\n$_donePrefix$commandId:%s\\n' "\$__server_box_exit" } } - static PersistentShellCommandResult? tryParseCompletedOutput( - String raw, { - required String expectedCommandId, - }) { - return _parseCompletedOutput( - raw, - expectedCommandId: expectedCommandId, - )?.result; - } - static _ParsedCompletedOutput? _parseCompletedOutput( String raw, { required String expectedCommandId, diff --git a/lib/data/store/setting.dart b/lib/data/store/setting.dart index 04a393eca..8f2c7f9cf 100644 --- a/lib/data/store/setting.dart +++ b/lib/data/store/setting.dart @@ -291,7 +291,7 @@ class SettingStore extends HiveStore { late final noNotiPerm = propertyDefault('noNotiPerm', false); /// The backup password - late final backupasswd = SecureProp('bakPasswd'); + late final backupPassword = SecureProp('bakPasswd'); /// Whether to read SSH config from ~/.ssh/config on first time late final firstTimeReadSSHCfg = propertyDefault('firstTimeReadSSHCfg', true); diff --git a/lib/view/page/home.dart b/lib/view/page/home.dart index d6b7e8283..322f24195 100644 --- a/lib/view/page/home.dart +++ b/lib/view/page/home.dart @@ -126,7 +126,7 @@ class _HomePageState extends ConsumerState final serverNotifier = _notifier; unawaited(serverNotifier.startAutoRefresh()); unawaited(serverNotifier.refresh()); - MethodChans.updateHomeWidget(); + unawaited(MethodChans.updateHomeWidget()); _syncFullscreenSystemUi(); break; case AppLifecycleState.paused: @@ -274,7 +274,7 @@ class _HomePageState extends ConsumerState context: context, ); } - MethodChans.updateHomeWidget(); + unawaited(MethodChans.updateHomeWidget()); await _notifier.refresh(); bakSync.sync(milliDelay: 1000); diff --git a/lib/view/page/private_key/edit.dart b/lib/view/page/private_key/edit.dart index f7221bdc5..824f1536f 100644 --- a/lib/view/page/private_key/edit.dart +++ b/lib/view/page/private_key/edit.dart @@ -279,7 +279,7 @@ class _PrivateKeyEditPageState extends ConsumerState { FocusScope.of(context).unfocus(); _loading.value = SizedLoading.medium; try { - final decrypted = await Computer.shared.start(decyptPem, [key, pwd]); + final decrypted = await Computer.shared.start(decryptPem, [key, pwd]); final pki = PrivateKeyInfo(id: name, key: decrypted); final originPki = this.pki; if (originPki != null) { diff --git a/lib/view/page/setting/entries/app.dart b/lib/view/page/setting/entries/app.dart index 9a261aa52..8c6dcb459 100644 --- a/lib/view/page/setting/entries/app.dart +++ b/lib/view/page/setting/entries/app.dart @@ -394,7 +394,7 @@ extension _App on _AppSettingsPageState { String? passwordUsed; Future resolvePassword() async { - final saved = await _setting.backupasswd.read(); + final saved = await _setting.backupPassword.read(); if (saved?.isNotEmpty == true) return saved; final backupPwd = await SecureStoreProps.bakPwd.read(); if (backupPwd?.isNotEmpty == true) return backupPwd; diff --git a/lib/view/page/setting/entries/ssh.dart b/lib/view/page/setting/entries/ssh.dart index f86a5cd1e..880e82b1c 100644 --- a/lib/view/page/setting/entries/ssh.dart +++ b/lib/view/page/setting/entries/ssh.dart @@ -165,8 +165,15 @@ extension _SSH on _AppSettingsPageState { } Future _processSSHServers(List servers) async { - final deduplicated = ServerDeduplication.deduplicateServers(servers); - final resolved = ServerDeduplication.resolveNameConflicts(deduplicated); + final existing = Stores.server.fetch(); + final deduplicated = ServerDeduplication.deduplicateServers( + servers, + existingServers: existing, + ); + final resolved = ServerDeduplication.resolveNameConflicts( + deduplicated, + existingServers: existing, + ); final summary = ServerDeduplication.getImportSummary(servers, resolved); if (!summary.hasItemsToImport) { diff --git a/lib/view/widget/server_func_btns.dart b/lib/view/widget/server_func_btns.dart index de4e5c2ff..3b51d0ab0 100644 --- a/lib/view/widget/server_func_btns.dart +++ b/lib/view/widget/server_func_btns.dart @@ -206,20 +206,16 @@ void _gotoSSH(Spi spi, BuildContext context) async { File? tempKeyFile; final shouldGenKey = spi.keyId != null; + var sshLaunched = false; try { if (shouldGenKey) { - final path = await () async { - final tempKeyFileName = 'srvbox_pk_${spi.keyId}'; - - /// For security reason, save the private key file to app doc path - return Paths.doc.joinPath(tempKeyFileName); - }(); + final tempDir = await Directory.systemTemp.createTemp( + 'srvbox_pk_${spi.keyId}_', + ); + final path = tempDir.path.joinPath('id_key'); final file = File(path); tempKeyFile = file; - if (await file.exists()) { - await file.delete(); - } final keyContent = getPrivateKey(spi.keyId!); final keyContentWithNewline = keyContent.endsWith('\n') ? keyContent @@ -252,6 +248,7 @@ void _gotoSSH(Spi spi, BuildContext context) async { switch (system) { case Pfs.windows: await Process.start('cmd', ['/c', 'start'] + sshCommand); + sshLaunched = true; break; case Pfs.macos: try { @@ -262,6 +259,7 @@ void _gotoSSH(Spi spi, BuildContext context) async { '-e', 'tell application "Terminal" to do script ${_appleScriptString(command)}', ]); + sshLaunched = true; } catch (e, s) { context.showErrDialog(e, s, libL10n.emulator); } @@ -281,6 +279,7 @@ void _gotoSSH(Spi spi, BuildContext context) async { if (terminal.isEmpty) terminal = 'x-terminal-emulator'; await Process.start(scriptFile.path, [terminal, ...sshCommand]); + sshLaunched = true; } catch (e, s) { if (context.mounted) { context.showErrDialog(e, s, libL10n.emulator); @@ -296,22 +295,40 @@ void _gotoSSH(Spi spi, BuildContext context) async { } } finally { final file = tempKeyFile; - if (file != null && await file.exists()) { - unawaited( - Future.delayed(const Duration(seconds: 2), () async { - try { - if (await file.exists()) { - await file.delete(); + if (file != null) { + if (sshLaunched) { + // Keep the key file alive while SSH is establishing the connection. + unawaited( + Future.delayed(const Duration(seconds: 30), () async { + try { + final parent = file.parent; + if (await parent.exists()) { + await parent.delete(recursive: true); + } + } catch (e, s) { + Loggers.app.warning( + 'Failed to delete temporary SSH key directory', + e, + s, + ); } - } catch (e, s) { - Loggers.app.warning( - 'Failed to delete temporary SSH key file', - e, - s, - ); + }), + ); + } else { + // SSH never launched — clean up immediately. + try { + final parent = file.parent; + if (await parent.exists()) { + await parent.delete(recursive: true); } - }), - ); + } catch (e, s) { + Loggers.app.warning( + 'Failed to delete temporary SSH key directory', + e, + s, + ); + } + } } } } @@ -397,8 +414,11 @@ Future _copyDesktopSshPasswordIfNeeded( Future.delayed(const Duration(seconds: 25), () async { try { final current = await Clipboard.getData(Clipboard.kTextPlain); - if (current?.text != pwd) return; - await Clipboard.setData(const ClipboardData(text: '')); + // Only clear if the clipboard still holds the password. + // If the user copied something else in the meantime, preserve it. + if (current?.text == pwd) { + await Clipboard.setData(const ClipboardData(text: '')); + } } catch (e, s) { Loggers.app.warning( 'Failed to clear copied SSH password from clipboard', diff --git a/test/persistent_shell_test.dart b/test/persistent_shell_test.dart index 67d63a6d6..d0dd856bb 100644 --- a/test/persistent_shell_test.dart +++ b/test/persistent_shell_test.dart @@ -6,41 +6,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:server_box/data/ssh/persistent_shell.dart'; void main() { - test('PersistentShell parses completed output marker', () { - const raw = 'cpu:10%\nmem:20%\n__SERVER_BOX_DONE__7:0\n'; - - final result = PersistentShell.tryParseCompletedOutput( - raw, - expectedCommandId: '7', - ); - - expect(result, isNotNull); - expect(result!.output, 'cpu:10%\nmem:20%'); - expect(result.exitCode, 0); - }); - - test('PersistentShell waits for full completion marker', () { - const raw = 'cpu:10%\n__SERVER_BOX_DONE__7:'; - - final result = PersistentShell.tryParseCompletedOutput( - raw, - expectedCommandId: '7', - ); - - expect(result, isNull); - }); - - test('PersistentShell ignores markers for another command id', () { - const raw = 'cpu:10%\n__SERVER_BOX_DONE__999:0\n'; - - final result = PersistentShell.tryParseCompletedOutput( - raw, - expectedCommandId: '7', - ); - - expect(result, isNull); - }); - test('PersistentShell reuses one session across multiple commands', () async { final factory = _FakeSessionFactory(); final shell = PersistentShell(null, sessionFactory: factory.call);