From 3bfd2b17af3b7d3459ced13b5a2bf7f53f4bcef6 Mon Sep 17 00:00:00 2001 From: GT610 Date: Thu, 2 Jul 2026 10:47:26 +0800 Subject: [PATCH 01/11] Update dartssh2 package --- packages/dartssh2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dartssh2 b/packages/dartssh2 index aad680bf7..39573deee 160000 --- a/packages/dartssh2 +++ b/packages/dartssh2 @@ -1 +1 @@ -Subproject commit aad680bf79437230cd5973d1c0d6b775a30c2208 +Subproject commit 39573deee0481ba6b6c526ca9661c7f9e035970f From dcebc9b7ba3b883c4e3491f97949a13d6a18139d Mon Sep 17 00:00:00 2001 From: GT610 Date: Thu, 2 Jul 2026 10:56:30 +0800 Subject: [PATCH 02/11] Remove dead code: DecyptPem and tryParseCompletedOutput - Remove unused DecyptPem function (zero call sites) - Remove unused tryParseCompletedOutput static method (only used in tests) - Fix typo: loadIndentity -> loadIdentity - Update persistent_shell_test.dart to drop tests for removed method --- lib/core/utils/server.dart | 12 ++-------- lib/data/ssh/persistent_shell.dart | 10 --------- test/persistent_shell_test.dart | 35 ------------------------------ 3 files changed, 2 insertions(+), 55 deletions(-) diff --git a/lib/core/utils/server.dart b/lib/core/utils/server.dart index 340bb0141..1ea65e852 100644 --- a/lib/core/utils/server.dart +++ b/lib/core/utils/server.dart @@ -17,18 +17,10 @@ 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); } -/// [args] : [key, pwd] -String decyptPem(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]); - return sshKey.first.toPem(); -} - enum GenSSHClientStatus { socket, key, pwd } String getPrivateKey(String id) { @@ -214,7 +206,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, ); 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/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); From f90e594cd5a3d4bafb82f32ace5c324f4d455257 Mon Sep 17 00:00:00 2001 From: GT610 Date: Thu, 2 Jul 2026 11:03:47 +0800 Subject: [PATCH 03/11] Harden security: avoid password in process args, use system temp for keys - sftp_sudo: use shell builtin printf instead of echo to pipe sudo password, so the password does not appear in the process argument list visible via ps - server_func_btns: write temp private key to system temp directory instead of the predictable app doc path, and extend retention to 5s for SSH connection establishment - server_func_btns: unconditionally clear clipboard after password copy timeout instead of skipping when content changed --- lib/core/utils/sftp_sudo.dart | 7 +++++-- lib/view/widget/server_func_btns.dart | 9 ++++----- 2 files changed, 9 insertions(+), 7 deletions(-) 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/view/widget/server_func_btns.dart b/lib/view/widget/server_func_btns.dart index de4e5c2ff..d8dbd5a11 100644 --- a/lib/view/widget/server_func_btns.dart +++ b/lib/view/widget/server_func_btns.dart @@ -212,8 +212,9 @@ void _gotoSSH(Spi spi, BuildContext context) async { 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); + /// Use system temp directory so the key file has a short-lived + /// random path and is cleaned up by the OS on reboot. + return Directory.systemTemp.path.joinPath(tempKeyFileName); }(); final file = File(path); tempKeyFile = file; @@ -298,7 +299,7 @@ void _gotoSSH(Spi spi, BuildContext context) async { final file = tempKeyFile; if (file != null && await file.exists()) { unawaited( - Future.delayed(const Duration(seconds: 2), () async { + Future.delayed(const Duration(seconds: 5), () async { try { if (await file.exists()) { await file.delete(); @@ -396,8 +397,6 @@ Future _copyDesktopSshPasswordIfNeeded( unawaited( 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: '')); } catch (e, s) { Loggers.app.warning( From 1185ec39548a653b23531a71bff237cc8e19a908 Mon Sep 17 00:00:00 2001 From: GT610 Date: Thu, 2 Jul 2026 11:07:46 +0800 Subject: [PATCH 04/11] Reduce redundant store fetches and reuse listEquals - server_dedup: accept optional existingServers param to avoid duplicate ServerStore.fetch() calls in _resolveServers - all.dart: replace manual _isSameOrder with listEquals - server_private_info: replace _sameStringList with listEquals --- lib/core/utils/server_dedup.dart | 26 +++++++++++++------ .../model/server/server_private_info.dart | 11 ++------ lib/data/provider/server/all.dart | 15 +++-------- 3 files changed, 23 insertions(+), 29 deletions(-) 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/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 { From 53e2a1191df1cf030a04ef5231f36656364e1e52 Mon Sep 17 00:00:00 2001 From: GT610 Date: Thu, 2 Jul 2026 11:12:09 +0800 Subject: [PATCH 05/11] Improve robustness: fix async void, skip empty workers, narrow failover - chan: change updateHomeWidget from void async to Future with try-catch so callers can use unawaited properly - home: mark updateHomeWidget calls with unawaited - main: skip Computer.shared.turnOn when no servers configured - server: narrow _isJumpFailoverError keyword matching to avoid false positives on unrelated errors containing 'network' or 'socket' --- lib/core/chan.dart | 8 ++++++-- lib/core/utils/server.dart | 6 +++--- lib/main.dart | 8 +++++--- lib/view/page/home.dart | 4 ++-- 4 files changed, 16 insertions(+), 10 deletions(-) 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 1ea65e852..c41020266 100644 --- a/lib/core/utils/server.dart +++ b/lib/core/utils/server.dart @@ -241,10 +241,10 @@ 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('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/main.dart b/lib/main.dart index a775a152f..51f5ecd43 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -86,9 +86,11 @@ Future _doPlatformRelated() async { } final serversCount = Stores.server.keys().length; - await Computer.shared.turnOn( - workersCount: (serversCount / 3).round() + 1, - ); // Plus 1 to avoid 0. + if (serversCount > 0) { + await Computer.shared.turnOn( + workersCount: (serversCount / 3).round() + 1, + ); // Plus 1 to avoid 0. + } } // It may contains some async heavy funcs. 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); From 3e0cee049efc37cae1a41e1dc588b46e52968633 Mon Sep 17 00:00:00 2001 From: GT610 Date: Thu, 2 Jul 2026 11:17:07 +0800 Subject: [PATCH 06/11] Fix naming inconsistencies and document enum ordering - setting: rename backupasswd -> backupPassword - misc: rename multiBlankreg -> multiBlankReg for consistency - server: add ordering invariant comment for ServerConn operator< --- lib/data/model/container/ps.dart | 2 +- lib/data/model/server/server.dart | 3 +++ lib/data/res/misc.dart | 2 +- lib/data/store/setting.dart | 2 +- lib/view/page/setting/entries/app.dart | 2 +- 5 files changed, 7 insertions(+), 4 deletions(-) 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/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/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/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; From d8ce270aa7f7c2dad773d8ad86bdf8278858b4f4 Mon Sep 17 00:00:00 2001 From: GT610 Date: Thu, 2 Jul 2026 11:20:52 +0800 Subject: [PATCH 07/11] Restore decryptPem and fix typo in caller - Restore decryptPem (was wrongly removed as dead code, actually called in private_key/edit.dart via Computer.shared.start) - Fix typo: decyptPem -> decryptPem in both definition and call site --- lib/core/utils/server.dart | 13 +++++++++++++ lib/view/page/private_key/edit.dart | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/core/utils/server.dart b/lib/core/utils/server.dart index c41020266..58302715e 100644 --- a/lib/core/utils/server.dart +++ b/lib/core/utils/server.dart @@ -21,6 +21,19 @@ 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 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]); + return sshKey.first.toPem(); +} + enum GenSSHClientStatus { socket, key, pwd } String getPrivateKey(String id) { 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) { From 8814dd726f519aa87e6881e22af05dda6599012e Mon Sep 17 00:00:00 2001 From: GT610 Date: Thu, 2 Jul 2026 11:26:09 +0800 Subject: [PATCH 08/11] Preserve user clipboard content when clearing SSH password Revert to checking clipboard content before clearing: only wipe the clipboard if it still holds the password. If the user copied something else during the 25s window, preserve their content. --- lib/view/widget/server_func_btns.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/view/widget/server_func_btns.dart b/lib/view/widget/server_func_btns.dart index d8dbd5a11..b187dc6a0 100644 --- a/lib/view/widget/server_func_btns.dart +++ b/lib/view/widget/server_func_btns.dart @@ -397,7 +397,12 @@ Future _copyDesktopSshPasswordIfNeeded( unawaited( Future.delayed(const Duration(seconds: 25), () async { try { - await Clipboard.setData(const ClipboardData(text: '')); + final current = await Clipboard.getData(Clipboard.kTextPlain); + // 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', From 395e48ec25f0807a43806f2a3418ce21db0154c1 Mon Sep 17 00:00:00 2001 From: GT610 Date: Thu, 2 Jul 2026 12:40:04 +0800 Subject: [PATCH 09/11] Revert dartssh2 package --- packages/dartssh2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dartssh2 b/packages/dartssh2 index 39573deee..aad680bf7 160000 --- a/packages/dartssh2 +++ b/packages/dartssh2 @@ -1 +1 @@ -Subproject commit 39573deee0481ba6b6c526ca9661c7f9e035970f +Subproject commit aad680bf79437230cd5973d1c0d6b775a30c2208 From 9d9b7a7a4bdc66a7d0c31e1b462b8b7e37f5af0c Mon Sep 17 00:00:00 2001 From: GT610 Date: Thu, 2 Jul 2026 23:37:08 +0800 Subject: [PATCH 10/11] Address review findings: failover match, Computer init, temp key, dedup - server.dart: add 'network is unreachable' to failover error matching - main.dart: restore unconditional Computer.shared.turnOn to avoid hangs when backup/private-key flows call Computer.shared.start with zero servers configured - server_func_btns: use unique temp directory per launch instead of stable filename, and extend key cleanup delay to 30s, delete the whole temp directory on cleanup - ssh.dart: reuse a single Stores.server.fetch() call in _processSSHServers for both deduplicateServers and resolveNameConflicts --- lib/core/utils/server.dart | 1 + lib/main.dart | 8 +++----- lib/view/page/setting/entries/ssh.dart | 11 +++++++++-- lib/view/widget/server_func_btns.dart | 25 ++++++++++--------------- 4 files changed, 23 insertions(+), 22 deletions(-) diff --git a/lib/core/utils/server.dart b/lib/core/utils/server.dart index 58302715e..63b34d48c 100644 --- a/lib/core/utils/server.dart +++ b/lib/core/utils/server.dart @@ -255,6 +255,7 @@ bool _isJumpFailoverError(Object error) { errStr.contains('connection closed') || errStr.contains('no route to host') || errStr.contains('network unreachable') || + errStr.contains('network is unreachable') || errStr.contains('socketexception') || errStr.contains('failed host lookup') || errStr.contains('forwardlocal') || diff --git a/lib/main.dart b/lib/main.dart index 51f5ecd43..a775a152f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -86,11 +86,9 @@ Future _doPlatformRelated() async { } final serversCount = Stores.server.keys().length; - if (serversCount > 0) { - await Computer.shared.turnOn( - workersCount: (serversCount / 3).round() + 1, - ); // Plus 1 to avoid 0. - } + await Computer.shared.turnOn( + workersCount: (serversCount / 3).round() + 1, + ); // Plus 1 to avoid 0. } // It may contains some async heavy funcs. 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 b187dc6a0..1b24a425f 100644 --- a/lib/view/widget/server_func_btns.dart +++ b/lib/view/widget/server_func_btns.dart @@ -209,18 +209,12 @@ void _gotoSSH(Spi spi, BuildContext context) async { try { if (shouldGenKey) { - final path = await () async { - final tempKeyFileName = 'srvbox_pk_${spi.keyId}'; - - /// Use system temp directory so the key file has a short-lived - /// random path and is cleaned up by the OS on reboot. - return Directory.systemTemp.path.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 @@ -297,16 +291,17 @@ void _gotoSSH(Spi spi, BuildContext context) async { } } finally { final file = tempKeyFile; - if (file != null && await file.exists()) { + if (file != null) { unawaited( - Future.delayed(const Duration(seconds: 5), () async { + Future.delayed(const Duration(seconds: 30), () async { try { - if (await file.exists()) { - await file.delete(); + 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 file', + 'Failed to delete temporary SSH key directory', e, s, ); From 6d6d67b55593d7bd3de812752e2072a9911b46c5 Mon Sep 17 00:00:00 2001 From: GT610 Date: Thu, 2 Jul 2026 23:47:58 +0800 Subject: [PATCH 11/11] Delete temp SSH key dir immediately when launch fails Track whether SSH actually started via sshLaunched flag set after each successful Process.start. On failure paths (exception, unsupported platform, or key prep error) the temp key directory is deleted right away in the finally block instead of waiting 30 seconds. --- lib/view/widget/server_func_btns.dart | 49 +++++++++++++++++++-------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/lib/view/widget/server_func_btns.dart b/lib/view/widget/server_func_btns.dart index 1b24a425f..3b51d0ab0 100644 --- a/lib/view/widget/server_func_btns.dart +++ b/lib/view/widget/server_func_btns.dart @@ -206,6 +206,7 @@ void _gotoSSH(Spi spi, BuildContext context) async { File? tempKeyFile; final shouldGenKey = spi.keyId != null; + var sshLaunched = false; try { if (shouldGenKey) { @@ -247,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 { @@ -257,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); } @@ -276,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); @@ -292,22 +296,39 @@ void _gotoSSH(Spi spi, BuildContext context) async { } finally { final file = tempKeyFile; if (file != null) { - unawaited( - Future.delayed(const Duration(seconds: 30), () async { - try { - final parent = file.parent; - if (await parent.exists()) { - await parent.delete(recursive: true); + 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 directory', - 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, + ); + } + } } } }