From 5272324be6e3c3ac8906d839b9acc4c63c7e772d 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: Mon, 20 Oct 2025 09:31:20 +0800 Subject: [PATCH] feat: prompt user on host key verification (#943) --- .github/workflows/analysis.yml | 13 - lib/app.dart | 2 + lib/core/app_navigator.dart | 8 + lib/core/utils/host_key_helper.dart | 26 ++ lib/core/utils/server.dart | 249 ++++++++++++++++++- lib/data/store/setting.dart | 12 + lib/generated/l10n/l10n.dart | 42 ++++ lib/generated/l10n/l10n_de.dart | 33 +++ lib/generated/l10n/l10n_en.dart | 33 +++ lib/generated/l10n/l10n_es.dart | 33 +++ lib/generated/l10n/l10n_fr.dart | 33 +++ lib/generated/l10n/l10n_id.dart | 33 +++ lib/generated/l10n/l10n_ja.dart | 33 +++ lib/generated/l10n/l10n_nl.dart | 33 +++ lib/generated/l10n/l10n_pt.dart | 33 +++ lib/generated/l10n/l10n_ru.dart | 33 +++ lib/generated/l10n/l10n_tr.dart | 33 +++ lib/generated/l10n/l10n_uk.dart | 33 +++ lib/generated/l10n/l10n_zh.dart | 66 +++++ lib/l10n/app_de.arb | 9 +- lib/l10n/app_en.arb | 12 +- lib/l10n/app_es.arb | 9 +- lib/l10n/app_fr.arb | 9 +- lib/l10n/app_id.arb | 9 +- lib/l10n/app_ja.arb | 9 +- lib/l10n/app_nl.arb | 9 +- lib/l10n/app_pt.arb | 9 +- lib/l10n/app_ru.arb | 9 +- lib/l10n/app_tr.arb | 9 +- lib/l10n/app_uk.arb | 9 +- lib/l10n/app_zh.arb | 9 +- lib/l10n/app_zh_tw.arb | 9 +- lib/view/page/server/connection_stats.dart | 199 +++++---------- lib/view/page/setting/entries/app.dart | 118 +++++++++ lib/view/page/setting/entries/home_tabs.dart | 6 +- lib/view/page/setting/entries/server.dart | 47 ---- lib/view/page/storage/local.dart | 5 + lib/view/page/storage/sftp.dart | 19 +- 38 files changed, 1076 insertions(+), 219 deletions(-) create mode 100644 lib/core/app_navigator.dart create mode 100644 lib/core/utils/host_key_helper.dart diff --git a/.github/workflows/analysis.yml b/.github/workflows/analysis.yml index 1d72e1b0..b4cac067 100644 --- a/.github/workflows/analysis.yml +++ b/.github/workflows/analysis.yml @@ -23,19 +23,6 @@ jobs: - uses: subosito/flutter-action@v2 with: channel: 'stable' - cache: true - cache-key: 'flutter-:os:-:channel:-:version:-:arch:-:hash:' - - - name: Cache pub dependencies - uses: actions/cache@v4 - with: - path: | - ${{ env.PUB_CACHE }} - ~/.pub-cache - key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}-${{ hashFiles('**/pubspec.yaml') }} - restore-keys: | - ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}- - ${{ runner.os }}-pub- - name: Install dependencies run: flutter pub get diff --git a/lib/app.dart b/lib/app.dart index 40ba5c6d..771b7161 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -3,6 +3,7 @@ import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/generated/l10n/lib_l10n.dart'; import 'package:flutter/material.dart'; import 'package:icons_plus/icons_plus.dart'; +import 'package:server_box/core/app_navigator.dart'; import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/data/res/build_data.dart'; import 'package:server_box/data/res/store.dart'; @@ -87,6 +88,7 @@ class _MyAppState extends State { return MaterialApp( key: ValueKey(locale), + navigatorKey: AppNavigator.key, builder: ResponsivePoints.builder, locale: locale, localizationsDelegates: const [LibLocalizations.delegate, ...AppLocalizations.localizationsDelegates], diff --git a/lib/core/app_navigator.dart b/lib/core/app_navigator.dart new file mode 100644 index 00000000..422738ca --- /dev/null +++ b/lib/core/app_navigator.dart @@ -0,0 +1,8 @@ +import 'package:flutter/widgets.dart'; + +/// Global navigator access used for cross-cutting flows (e.g. dialogs). +abstract final class AppNavigator { + static final key = GlobalKey(); + + static BuildContext? get context => key.currentContext; +} diff --git a/lib/core/utils/host_key_helper.dart b/lib/core/utils/host_key_helper.dart new file mode 100644 index 00000000..420e61fc --- /dev/null +++ b/lib/core/utils/host_key_helper.dart @@ -0,0 +1,26 @@ +import 'package:fl_lib/fl_lib.dart'; +import 'package:flutter/material.dart'; +import 'package:server_box/core/utils/server.dart'; +import 'package:server_box/core/utils/ssh_auth.dart'; +import 'package:server_box/data/model/server/server_private_info.dart'; +import 'package:server_box/data/res/store.dart'; + +Future ensureHostKeyAcceptedForSftp(BuildContext context, Spi spi) async { + final known = Stores.setting.sshKnownHostFingerprints.get(); + final hostId = spi.id.isNotEmpty ? spi.id : spi.oldId; + final prefix = '$hostId::'; + if (known.keys.any((key) => key.startsWith(prefix))) { + return true; + } + + final (result, error) = await context.showLoadingDialog( + fn: () async { + await ensureKnownHostKey( + spi, + onKeyboardInteractive: (_) => KeybordInteractive.defaultHandle(spi, ctx: context), + ); + return true; + }, + ); + return error == null && result == true; +} diff --git a/lib/core/utils/server.dart b/lib/core/utils/server.dart index 25b0f535..7ee67a38 100644 --- a/lib/core/utils/server.dart +++ b/lib/core/utils/server.dart @@ -1,8 +1,12 @@ import 'dart:async'; +import 'dart:convert'; import 'package:dartssh2/dartssh2.dart'; import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:server_box/core/app_navigator.dart'; +import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/data/model/app/error.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/res/store.dart'; @@ -29,7 +33,7 @@ enum GenSSHClientStatus { socket, key, pwd } String getPrivateKey(String id) { final pki = Stores.key.fetchOne(id); if (pki == null) { - throw SSHErr(type: SSHErrType.noPrivateKey, message: 'key [$id] not found'); + throw SSHErr(type: SSHErrType.noPrivateKey, message: l10n.privateKeyNotFoundFmt(id)); } return pki.key; } @@ -52,9 +56,16 @@ Future genClient( /// Handle keyboard-interactive authentication SSHUserInfoRequestHandler? onKeyboardInteractive, + Map? knownHostFingerprints, + void Function(String storageKey, String fingerprintHex)? onHostKeyAccepted, + Future Function(HostKeyPromptInfo info)? onHostKeyPrompt, }) async { onStatus?.call(GenSSHClientStatus.socket); + final hostKeyCache = Map.from(knownHostFingerprints ?? _loadKnownHostFingerprints()); + final hostKeyPersist = onHostKeyAccepted ?? _persistHostKeyFingerprint; + final hostKeyPrompt = onHostKeyPrompt ?? _defaultHostKeyPrompt; + String? alterUser; final socket = await () async { @@ -66,7 +77,14 @@ Future genClient( if (spi.jumpId != null) return Stores.server.box.get(spi.jumpId); }(); if (jumpSpi_ != null) { - final jumpClient = await genClient(jumpSpi_, privateKey: jumpPrivateKey, timeout: timeout); + final jumpClient = await genClient( + jumpSpi_, + privateKey: jumpPrivateKey, + timeout: timeout, + knownHostFingerprints: hostKeyCache, + onHostKeyAccepted: hostKeyPersist, + onHostKeyPrompt: onHostKeyPrompt, + ); return await jumpClient.forwardLocal(spi.ip, spi.port); } @@ -88,6 +106,13 @@ Future genClient( } }(); + final hostKeyVerifier = _HostKeyVerifier( + spi: spi, + cache: hostKeyCache, + persistCallback: hostKeyPersist, + prompt: hostKeyPrompt, + ); + final keyId = spi.keyId; if (keyId == null) { onStatus?.call(GenSSHClientStatus.pwd); @@ -96,9 +121,7 @@ Future genClient( username: alterUser ?? spi.user, onPasswordRequest: () => spi.pwd, onUserInfoRequest: onKeyboardInteractive, - - /// TODO: verify host key - onVerifyHostKey: (type, fingerprint) => true, + onVerifyHostKey: hostKeyVerifier.call, // printDebug: debugPrint, // printTrace: debugPrint, ); @@ -112,10 +135,220 @@ Future genClient( // Must use [compute] here, instead of [Computer.shared.start] identities: await compute(loadIndentity, privateKey), onUserInfoRequest: onKeyboardInteractive, - - /// TODO: verify host key - onVerifyHostKey: (type, fingerprint) => true, + onVerifyHostKey: hostKeyVerifier.call, // printDebug: debugPrint, // printTrace: debugPrint, ); } + +typedef _HostKeyPersistCallback = void Function(String storageKey, String fingerprintHex); + +class HostKeyPromptInfo { + HostKeyPromptInfo({ + required this.spi, + required this.keyType, + required this.fingerprintHex, + required this.fingerprintBase64, + required this.isMismatch, + this.previousFingerprintHex, + }); + + final Spi spi; + final String keyType; + final String fingerprintHex; + final String fingerprintBase64; + final bool isMismatch; + final String? previousFingerprintHex; +} + +class _HostKeyVerifier { + _HostKeyVerifier({ + required this.spi, + required Map cache, + required this.prompt, + this.persistCallback, + }) : _cache = cache; + + final Spi spi; + final Map _cache; + final _HostKeyPersistCallback? persistCallback; + final Future Function(HostKeyPromptInfo info) prompt; + + Future call(String keyType, Uint8List fingerprintBytes) async { + final storageKey = _hostKeyStorageKey(spi, keyType); + final fingerprintHex = _fingerprintToHex(fingerprintBytes); + final fingerprintBase64 = _fingerprintToBase64(fingerprintBytes); + final existing = _cache[storageKey]; + + if (existing == null) { + final accepted = await prompt( + HostKeyPromptInfo( + spi: spi, + keyType: keyType, + fingerprintHex: fingerprintHex, + fingerprintBase64: fingerprintBase64, + isMismatch: false, + ), + ); + if (!accepted) { + Loggers.app.warning('User rejected new SSH host key for ${spi.name} ($keyType).'); + return false; + } + _cache[storageKey] = fingerprintHex; + persistCallback?.call(storageKey, fingerprintHex); + Loggers.app.info('Trusted SSH host key for ${spi.name} ($keyType).'); + return true; + } + + if (existing == fingerprintHex) { + return true; + } + + final accepted = await prompt( + HostKeyPromptInfo( + spi: spi, + keyType: keyType, + fingerprintHex: fingerprintHex, + fingerprintBase64: fingerprintBase64, + isMismatch: true, + previousFingerprintHex: existing, + ), + ); + if (!accepted) { + Loggers.app.warning( + 'SSH host key mismatch for ${spi.name}', + 'expected $existing but received $fingerprintHex ($keyType)', + ); + return false; + } + + _cache[storageKey] = fingerprintHex; + persistCallback?.call(storageKey, fingerprintHex); + Loggers.app.warning('Updated stored SSH host key for ${spi.name} ($keyType) after user confirmation.'); + return true; + } +} + +Map _loadKnownHostFingerprints() { + try { + final prop = Stores.setting.sshKnownHostFingerprints; + return Map.from(prop.get()); + } catch (e, stack) { + Loggers.app.warning('Load SSH host key fingerprints failed', e, stack); + return {}; + } +} + +void _persistHostKeyFingerprint(String storageKey, String fingerprintHex) { + try { + final prop = Stores.setting.sshKnownHostFingerprints; + final updated = Map.from(prop.get()); + if (updated[storageKey] == fingerprintHex) { + return; + } + updated[storageKey] = fingerprintHex; + prop.put(updated); + Loggers.app.info('Stored SSH host key fingerprint for $storageKey'); + } catch (e, stack) { + Loggers.app.warning('Persist SSH host key fingerprint failed', e, stack); + } +} + +Future _defaultHostKeyPrompt(HostKeyPromptInfo info) async { + final ctx = AppNavigator.context; + if (ctx == null) { + Loggers.app.warning('Host key prompt skipped: navigator context unavailable.'); + return false; + } + + final hostLine = '${info.spi.user}@${info.spi.ip}:${info.spi.port}'; + final description = info.isMismatch + ? l10n.sshHostKeyChangedDesc(info.spi.name) + : l10n.sshHostKeyNewDesc(info.spi.name); + + final result = await ctx.showRoundDialog( + title: libL10n.attention, + barrierDismiss: false, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(description), + const SizedBox(height: 12), + SelectableText('${l10n.server}: ${info.spi.name}'), + SelectableText('${libL10n.addr}: $hostLine'), + SelectableText('${l10n.sshHostKeyType}: ${info.keyType}'), + SelectableText(l10n.sshHostKeyFingerprintMd5Hex(info.fingerprintHex)), + SelectableText(l10n.sshHostKeyFingerprintMd5Base64(info.fingerprintBase64)), + if (info.previousFingerprintHex != null) ...[ + const SizedBox(height: 12), + SelectableText(l10n.sshHostKeyStoredFingerprint(info.previousFingerprintHex!)), + ], + ], + ), + actions: [ + TextButton(onPressed: () => ctx.pop(false), child: Text(libL10n.cancel)), + TextButton(onPressed: () => ctx.pop(true), child: Text(libL10n.ok)), + ], + ); + + return result ?? false; +} + +Future ensureKnownHostKey( + Spi spi, { + Duration timeout = const Duration(seconds: 5), + SSHUserInfoRequestHandler? onKeyboardInteractive, +}) async { + final cache = _loadKnownHostFingerprints(); + if (_hasKnownHostFingerprintForSpi(spi, cache)) { + return; + } + + final jumpSpi = spi.jumpId != null ? Stores.server.box.get(spi.jumpId) : null; + if (jumpSpi != null && !_hasKnownHostFingerprintForSpi(jumpSpi, cache)) { + await ensureKnownHostKey( + jumpSpi, + timeout: timeout, + onKeyboardInteractive: onKeyboardInteractive, + ); + cache.addAll(_loadKnownHostFingerprints()); + if (_hasKnownHostFingerprintForSpi(spi, cache)) return; + } + + final client = await genClient( + spi, + timeout: timeout, + onKeyboardInteractive: onKeyboardInteractive, + knownHostFingerprints: cache, + ); + + try { + await client.authenticated; + } finally { + client.close(); + } +} + +bool _hasKnownHostFingerprintForSpi(Spi spi, Map cache) { + final prefix = '${_hostIdentifier(spi)}::'; + return cache.keys.any((key) => key.startsWith(prefix)); +} + +String _hostKeyStorageKey(Spi spi, String keyType) { + final base = _hostIdentifier(spi); + return '$base::$keyType'; +} + +String _hostIdentifier(Spi spi) => spi.id.isNotEmpty ? spi.id : spi.oldId; + +String _fingerprintToHex(Uint8List fingerprint) { + final buffer = StringBuffer(); + for (var i = 0; i < fingerprint.length; i++) { + if (i > 0) buffer.write(':'); + buffer.write(fingerprint[i].toRadixString(16).padLeft(2, '0')); + } + return buffer.toString(); +} + +String _fingerprintToBase64(Uint8List fingerprint) => base64.encode(fingerprint); diff --git a/lib/data/store/setting.dart b/lib/data/store/setting.dart index ec63d431..0f127879 100644 --- a/lib/data/store/setting.dart +++ b/lib/data/store/setting.dart @@ -72,6 +72,18 @@ class SettingStore extends HiveStore { late final editorFontSize = propertyDefault('editorFontSize', 12.5); + /// Trusted SSH host key fingerprints keyed by `serverId::keyType`. + late final sshKnownHostFingerprints = propertyDefault>( + 'sshKnownHostFingerprints', + const {}, + fromObj: (raw) { + if (raw is Map) { + return raw.map((key, value) => MapEntry(key.toString(), value.toString())); + } + return {}; + }, + ); + // Editor theme late final editorTheme = propertyDefault('editorTheme', Defaults.editorTheme); diff --git a/lib/generated/l10n/l10n.dart b/lib/generated/l10n/l10n.dart index 0faf2e70..940da745 100644 --- a/lib/generated/l10n/l10n.dart +++ b/lib/generated/l10n/l10n.dart @@ -1148,6 +1148,12 @@ abstract class AppLocalizations { /// **'Private Key'** String get privateKey; + /// No description provided for @privateKeyNotFoundFmt. + /// + /// In en, this message translates to: + /// **'Private key [{keyId}] not found.'** + String privateKeyNotFoundFmt(Object keyId); + /// No description provided for @process. /// /// In en, this message translates to: @@ -1472,6 +1478,42 @@ abstract class AppLocalizations { /// **'Imported {count} servers from SSH config'** String sshConfigImported(Object count); + /// No description provided for @sshHostKeyChangedDesc. + /// + /// In en, this message translates to: + /// **'The SSH host key changed for {serverName}. Only continue if you trust this server.'** + String sshHostKeyChangedDesc(Object serverName); + + /// No description provided for @sshHostKeyFingerprintMd5Base64. + /// + /// In en, this message translates to: + /// **'Fingerprint (MD5 base64): {fingerprint}'** + String sshHostKeyFingerprintMd5Base64(Object fingerprint); + + /// No description provided for @sshHostKeyFingerprintMd5Hex. + /// + /// In en, this message translates to: + /// **'Fingerprint (MD5 hex): {fingerprint}'** + String sshHostKeyFingerprintMd5Hex(Object fingerprint); + + /// Label for the SSH host key type displayed in the host key verification dialog. + /// + /// In en, this message translates to: + /// **'SSH host key type'** + String get sshHostKeyType; + + /// No description provided for @sshHostKeyNewDesc. + /// + /// In en, this message translates to: + /// **'A new SSH host key was received from {serverName}. Review the fingerprint before trusting.'** + String sshHostKeyNewDesc(Object serverName); + + /// No description provided for @sshHostKeyStoredFingerprint. + /// + /// In en, this message translates to: + /// **'Stored fingerprint: {fingerprint}'** + String sshHostKeyStoredFingerprint(Object fingerprint); + /// No description provided for @sshConfigManualSelect. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/l10n_de.dart b/lib/generated/l10n/l10n_de.dart index 3e085f89..f5a2c202 100644 --- a/lib/generated/l10n/l10n_de.dart +++ b/lib/generated/l10n/l10n_de.dart @@ -581,6 +581,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String get privateKey => 'Private Key'; + @override + String privateKeyNotFoundFmt(Object keyId) { + return 'Privater Schlüssel [$keyId] wurde nicht gefunden.'; + } + @override String get process => 'Prozess'; @@ -764,6 +769,34 @@ class AppLocalizationsDe extends AppLocalizations { return '$count Server aus SSH-Konfiguration importiert'; } + @override + String sshHostKeyChangedDesc(Object serverName) { + return 'Der SSH-Hostschlüssel für $serverName hat sich geändert. Fahren Sie nur fort, wenn Sie diesem Server vertrauen.'; + } + + @override + String sshHostKeyFingerprintMd5Base64(Object fingerprint) { + return 'Fingerabdruck (MD5 Base64): $fingerprint'; + } + + @override + String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + return 'Fingerabdruck (MD5 Hex): $fingerprint'; + } + + @override + String get sshHostKeyType => 'SSH-Hostschlüsseltyp'; + + @override + String sshHostKeyNewDesc(Object serverName) { + return 'Ein neuer SSH-Hostschlüssel wurde von $serverName empfangen. Prüfen Sie den Fingerabdruck, bevor Sie vertrauen.'; + } + + @override + String sshHostKeyStoredFingerprint(Object fingerprint) { + return 'Gespeicherter Fingerabdruck: $fingerprint'; + } + @override String get sshConfigManualSelect => 'Möchten Sie die SSH-Konfigurationsdatei manuell auswählen?'; diff --git a/lib/generated/l10n/l10n_en.dart b/lib/generated/l10n/l10n_en.dart index d8424efa..9c7ff4fe 100644 --- a/lib/generated/l10n/l10n_en.dart +++ b/lib/generated/l10n/l10n_en.dart @@ -578,6 +578,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get privateKey => 'Private Key'; + @override + String privateKeyNotFoundFmt(Object keyId) { + return 'Private key [$keyId] not found.'; + } + @override String get process => 'Process'; @@ -758,6 +763,34 @@ class AppLocalizationsEn extends AppLocalizations { return 'Imported $count servers from SSH config'; } + @override + String sshHostKeyChangedDesc(Object serverName) { + return 'The SSH host key changed for $serverName. Only continue if you trust this server.'; + } + + @override + String sshHostKeyFingerprintMd5Base64(Object fingerprint) { + return 'Fingerprint (MD5 base64): $fingerprint'; + } + + @override + String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + return 'Fingerprint (MD5 hex): $fingerprint'; + } + + @override + String get sshHostKeyType => 'SSH host key type'; + + @override + String sshHostKeyNewDesc(Object serverName) { + return 'A new SSH host key was received from $serverName. Review the fingerprint before trusting.'; + } + + @override + String sshHostKeyStoredFingerprint(Object fingerprint) { + return 'Stored fingerprint: $fingerprint'; + } + @override String get sshConfigManualSelect => 'Would you like to select the SSH config file manually?'; diff --git a/lib/generated/l10n/l10n_es.dart b/lib/generated/l10n/l10n_es.dart index 4127f338..01e0b741 100644 --- a/lib/generated/l10n/l10n_es.dart +++ b/lib/generated/l10n/l10n_es.dart @@ -583,6 +583,11 @@ class AppLocalizationsEs extends AppLocalizations { @override String get privateKey => 'Llave privada'; + @override + String privateKeyNotFoundFmt(Object keyId) { + return 'No se encontró la clave privada [$keyId].'; + } + @override String get process => 'Proceso'; @@ -767,6 +772,34 @@ class AppLocalizationsEs extends AppLocalizations { return 'Se importaron $count servidores desde la configuración SSH'; } + @override + String sshHostKeyChangedDesc(Object serverName) { + return 'La clave de host SSH de $serverName ha cambiado. Continúa solo si confías en este servidor.'; + } + + @override + String sshHostKeyFingerprintMd5Base64(Object fingerprint) { + return 'Huella (MD5 Base64): $fingerprint'; + } + + @override + String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + return 'Huella (MD5 hex): $fingerprint'; + } + + @override + String get sshHostKeyType => 'Tipo de clave de host SSH'; + + @override + String sshHostKeyNewDesc(Object serverName) { + return 'Se recibió una nueva clave de host SSH de $serverName. Revisa la huella antes de confiar.'; + } + + @override + String sshHostKeyStoredFingerprint(Object fingerprint) { + return 'Huella almacenada: $fingerprint'; + } + @override String get sshConfigManualSelect => '¿Te gustaría seleccionar manualmente el archivo de configuración SSH?'; diff --git a/lib/generated/l10n/l10n_fr.dart b/lib/generated/l10n/l10n_fr.dart index 768db657..6283e5b0 100644 --- a/lib/generated/l10n/l10n_fr.dart +++ b/lib/generated/l10n/l10n_fr.dart @@ -585,6 +585,11 @@ class AppLocalizationsFr extends AppLocalizations { @override String get privateKey => 'Clé privée'; + @override + String privateKeyNotFoundFmt(Object keyId) { + return 'Clé privée [$keyId] introuvable.'; + } + @override String get process => 'Processus'; @@ -769,6 +774,34 @@ class AppLocalizationsFr extends AppLocalizations { return '$count serveurs importés depuis la configuration SSH'; } + @override + String sshHostKeyChangedDesc(Object serverName) { + return 'La clé d\'hôte SSH de $serverName a changé. Ne continuez que si vous faites confiance à ce serveur.'; + } + + @override + String sshHostKeyFingerprintMd5Base64(Object fingerprint) { + return 'Empreinte (MD5 Base64) : $fingerprint'; + } + + @override + String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + return 'Empreinte (MD5 hex) : $fingerprint'; + } + + @override + String get sshHostKeyType => 'Type de clé d\'hôte SSH'; + + @override + String sshHostKeyNewDesc(Object serverName) { + return 'Une nouvelle clé d\'hôte SSH a été reçue de $serverName. Vérifiez l\'empreinte avant de faire confiance.'; + } + + @override + String sshHostKeyStoredFingerprint(Object fingerprint) { + return 'Empreinte enregistrée : $fingerprint'; + } + @override String get sshConfigManualSelect => 'Souhaitez-vous sélectionner manuellement le fichier de configuration SSH ?'; diff --git a/lib/generated/l10n/l10n_id.dart b/lib/generated/l10n/l10n_id.dart index 72c4afc4..205a2473 100644 --- a/lib/generated/l10n/l10n_id.dart +++ b/lib/generated/l10n/l10n_id.dart @@ -578,6 +578,11 @@ class AppLocalizationsId extends AppLocalizations { @override String get privateKey => 'Kunci Pribadi'; + @override + String privateKeyNotFoundFmt(Object keyId) { + return 'Kunci privat [$keyId] tidak ditemukan.'; + } + @override String get process => 'Proses'; @@ -759,6 +764,34 @@ class AppLocalizationsId extends AppLocalizations { return 'Berhasil mengimpor $count server dari konfigurasi SSH'; } + @override + String sshHostKeyChangedDesc(Object serverName) { + return 'Kunci host SSH untuk $serverName telah berubah. Lanjutkan hanya jika Anda mempercayai server ini.'; + } + + @override + String sshHostKeyFingerprintMd5Base64(Object fingerprint) { + return 'Sidik jari (MD5 Base64): $fingerprint'; + } + + @override + String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + return 'Sidik jari (MD5 hex): $fingerprint'; + } + + @override + String get sshHostKeyType => 'Jenis kunci host SSH'; + + @override + String sshHostKeyNewDesc(Object serverName) { + return 'Kunci host SSH baru diterima dari $serverName. Periksa sidik jarinya sebelum mempercayai.'; + } + + @override + String sshHostKeyStoredFingerprint(Object fingerprint) { + return 'Sidik jari tersimpan: $fingerprint'; + } + @override String get sshConfigManualSelect => 'Apakah Anda ingin memilih file konfigurasi SSH secara manual?'; diff --git a/lib/generated/l10n/l10n_ja.dart b/lib/generated/l10n/l10n_ja.dart index 80243ad2..bc284c4d 100644 --- a/lib/generated/l10n/l10n_ja.dart +++ b/lib/generated/l10n/l10n_ja.dart @@ -560,6 +560,11 @@ class AppLocalizationsJa extends AppLocalizations { @override String get privateKey => '秘密鍵'; + @override + String privateKeyNotFoundFmt(Object keyId) { + return '秘密鍵 [$keyId] が見つかりません。'; + } + @override String get process => 'プロセス'; @@ -737,6 +742,34 @@ class AppLocalizationsJa extends AppLocalizations { return 'SSH設定から$count個のサーバーをインポートしました'; } + @override + String sshHostKeyChangedDesc(Object serverName) { + return '$serverName の SSH ホスト鍵が変更されました。このサーバーを信頼できる場合のみ続行してください。'; + } + + @override + String sshHostKeyFingerprintMd5Base64(Object fingerprint) { + return 'フィンガープリント (MD5 Base64): $fingerprint'; + } + + @override + String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + return 'フィンガープリント (MD5 16進): $fingerprint'; + } + + @override + String get sshHostKeyType => 'SSH ホストキーの種類'; + + @override + String sshHostKeyNewDesc(Object serverName) { + return '$serverName から新しい SSH ホスト鍵を受信しました。信頼する前にフィンガープリントを確認してください。'; + } + + @override + String sshHostKeyStoredFingerprint(Object fingerprint) { + return '保存済みフィンガープリント: $fingerprint'; + } + @override String get sshConfigManualSelect => 'SSH設定ファイルを手動で選択しますか?'; diff --git a/lib/generated/l10n/l10n_nl.dart b/lib/generated/l10n/l10n_nl.dart index e9722473..fde37b1c 100644 --- a/lib/generated/l10n/l10n_nl.dart +++ b/lib/generated/l10n/l10n_nl.dart @@ -580,6 +580,11 @@ class AppLocalizationsNl extends AppLocalizations { @override String get privateKey => 'Privésleutel'; + @override + String privateKeyNotFoundFmt(Object keyId) { + return 'Privésleutel [$keyId] niet gevonden.'; + } + @override String get process => 'Proces'; @@ -763,6 +768,34 @@ class AppLocalizationsNl extends AppLocalizations { return '$count servers geïmporteerd uit SSH-configuratie'; } + @override + String sshHostKeyChangedDesc(Object serverName) { + return 'De SSH-hostsleutel voor $serverName is gewijzigd. Ga alleen verder als u deze server vertrouwt.'; + } + + @override + String sshHostKeyFingerprintMd5Base64(Object fingerprint) { + return 'Vingerafdruk (MD5 Base64): $fingerprint'; + } + + @override + String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + return 'Vingerafdruk (MD5 hex): $fingerprint'; + } + + @override + String get sshHostKeyType => 'Type SSH-hostsleutel'; + + @override + String sshHostKeyNewDesc(Object serverName) { + return 'Er is een nieuwe SSH-hostsleutel ontvangen van $serverName. Controleer de vingerafdruk voordat u vertrouwt.'; + } + + @override + String sshHostKeyStoredFingerprint(Object fingerprint) { + return 'Opgeslagen vingerafdruk: $fingerprint'; + } + @override String get sshConfigManualSelect => 'Wilt u het SSH-configuratiebestand handmatig selecteren?'; diff --git a/lib/generated/l10n/l10n_pt.dart b/lib/generated/l10n/l10n_pt.dart index 3adf3fec..935393fd 100644 --- a/lib/generated/l10n/l10n_pt.dart +++ b/lib/generated/l10n/l10n_pt.dart @@ -578,6 +578,11 @@ class AppLocalizationsPt extends AppLocalizations { @override String get privateKey => 'Chave privada'; + @override + String privateKeyNotFoundFmt(Object keyId) { + return 'Chave privada [$keyId] não encontrada.'; + } + @override String get process => 'Processo'; @@ -759,6 +764,34 @@ class AppLocalizationsPt extends AppLocalizations { return 'Importados $count servidores da configuração SSH'; } + @override + String sshHostKeyChangedDesc(Object serverName) { + return 'A chave de host SSH de $serverName foi alterada. Continue apenas se confiar neste servidor.'; + } + + @override + String sshHostKeyFingerprintMd5Base64(Object fingerprint) { + return 'Impressão digital (MD5 Base64): $fingerprint'; + } + + @override + String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + return 'Impressão digital (MD5 hex): $fingerprint'; + } + + @override + String get sshHostKeyType => 'Tipo de chave de host SSH'; + + @override + String sshHostKeyNewDesc(Object serverName) { + return 'Uma nova chave de host SSH foi recebida de $serverName. Verifique a impressão digital antes de confiar.'; + } + + @override + String sshHostKeyStoredFingerprint(Object fingerprint) { + return 'Impressão digital armazenada: $fingerprint'; + } + @override String get sshConfigManualSelect => 'Gostaria de selecionar manualmente o arquivo de configuração SSH?'; diff --git a/lib/generated/l10n/l10n_ru.dart b/lib/generated/l10n/l10n_ru.dart index fd5544ab..fe323ffd 100644 --- a/lib/generated/l10n/l10n_ru.dart +++ b/lib/generated/l10n/l10n_ru.dart @@ -581,6 +581,11 @@ class AppLocalizationsRu extends AppLocalizations { @override String get privateKey => 'Приватный ключ'; + @override + String privateKeyNotFoundFmt(Object keyId) { + return 'Закрытый ключ [$keyId] не найден.'; + } + @override String get process => 'Процесс'; @@ -764,6 +769,34 @@ class AppLocalizationsRu extends AppLocalizations { return 'Импортировано $count серверов из SSH-конфигурации'; } + @override + String sshHostKeyChangedDesc(Object serverName) { + return 'SSH-ключ хоста для $serverName изменился. Продолжайте только если доверяете этому серверу.'; + } + + @override + String sshHostKeyFingerprintMd5Base64(Object fingerprint) { + return 'Отпечаток (MD5 Base64): $fingerprint'; + } + + @override + String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + return 'Отпечаток (MD5 hex): $fingerprint'; + } + + @override + String get sshHostKeyType => 'Тип ключа хоста SSH'; + + @override + String sshHostKeyNewDesc(Object serverName) { + return 'Получен новый SSH-ключ хоста от $serverName. Проверьте отпечаток перед продолжением.'; + } + + @override + String sshHostKeyStoredFingerprint(Object fingerprint) { + return 'Сохранённый отпечаток: $fingerprint'; + } + @override String get sshConfigManualSelect => 'Хотели бы вы вручную выбрать файл конфигурации SSH?'; diff --git a/lib/generated/l10n/l10n_tr.dart b/lib/generated/l10n/l10n_tr.dart index 482b408f..f5e97d13 100644 --- a/lib/generated/l10n/l10n_tr.dart +++ b/lib/generated/l10n/l10n_tr.dart @@ -578,6 +578,11 @@ class AppLocalizationsTr extends AppLocalizations { @override String get privateKey => 'Özel Anahtar'; + @override + String privateKeyNotFoundFmt(Object keyId) { + return 'Özel anahtar [$keyId] bulunamadı.'; + } + @override String get process => 'İşlem'; @@ -760,6 +765,34 @@ class AppLocalizationsTr extends AppLocalizations { return 'SSH yapılandırmasından $count sunucu içe aktarıldı'; } + @override + String sshHostKeyChangedDesc(Object serverName) { + return '$serverName için SSH ana bilgisayar anahtarı değişti. Yalnızca bu sunucuya güveniyorsanız devam edin.'; + } + + @override + String sshHostKeyFingerprintMd5Base64(Object fingerprint) { + return 'Parmak izi (MD5 Base64): $fingerprint'; + } + + @override + String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + return 'Parmak izi (MD5 hex): $fingerprint'; + } + + @override + String get sshHostKeyType => 'SSH ana bilgisayar anahtarı türü'; + + @override + String sshHostKeyNewDesc(Object serverName) { + return '$serverName üzerinden yeni bir SSH ana bilgisayar anahtarı alındı. Güvenmeden önce parmak izini kontrol edin.'; + } + + @override + String sshHostKeyStoredFingerprint(Object fingerprint) { + return 'Kaydedilen parmak izi: $fingerprint'; + } + @override String get sshConfigManualSelect => 'SSH yapılandırma dosyasını manuel olarak seçmek ister misiniz?'; diff --git a/lib/generated/l10n/l10n_uk.dart b/lib/generated/l10n/l10n_uk.dart index 69ccba62..b731c937 100644 --- a/lib/generated/l10n/l10n_uk.dart +++ b/lib/generated/l10n/l10n_uk.dart @@ -582,6 +582,11 @@ class AppLocalizationsUk extends AppLocalizations { @override String get privateKey => 'Приватний ключ'; + @override + String privateKeyNotFoundFmt(Object keyId) { + return 'Приватний ключ [$keyId] не знайдено.'; + } + @override String get process => 'Процес'; @@ -764,6 +769,34 @@ class AppLocalizationsUk extends AppLocalizations { return 'Імпортовано $count серверів з SSH-конфігурації'; } + @override + String sshHostKeyChangedDesc(Object serverName) { + return 'SSH-ключ хоста для $serverName змінено. Продовжуйте лише якщо довіряєте цьому серверу.'; + } + + @override + String sshHostKeyFingerprintMd5Base64(Object fingerprint) { + return 'Відбиток (MD5 Base64): $fingerprint'; + } + + @override + String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + return 'Відбиток (MD5 hex): $fingerprint'; + } + + @override + String get sshHostKeyType => 'Тип ключа хоста SSH'; + + @override + String sshHostKeyNewDesc(Object serverName) { + return 'Отримано новий SSH-ключ хоста від $serverName. Перевірте відбиток перед тим, як довіряти.'; + } + + @override + String sshHostKeyStoredFingerprint(Object fingerprint) { + return 'Збережений відбиток: $fingerprint'; + } + @override String get sshConfigManualSelect => 'Чи хочете ви вручну вибрати файл конфігурації SSH?'; diff --git a/lib/generated/l10n/l10n_zh.dart b/lib/generated/l10n/l10n_zh.dart index 6b6a4100..a2395a7d 100644 --- a/lib/generated/l10n/l10n_zh.dart +++ b/lib/generated/l10n/l10n_zh.dart @@ -554,6 +554,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String get privateKey => '私钥'; + @override + String privateKeyNotFoundFmt(Object keyId) { + return '未找到私钥 [$keyId]。'; + } + @override String get process => '进程'; @@ -727,6 +732,34 @@ class AppLocalizationsZh extends AppLocalizations { return '从 SSH 配置导入了 $count 个服务器'; } + @override + String sshHostKeyChangedDesc(Object serverName) { + return '服务器 $serverName 的 SSH 主机密钥已更改,仅在信任该服务器时继续。'; + } + + @override + String sshHostKeyFingerprintMd5Base64(Object fingerprint) { + return '指纹(MD5 Base64):$fingerprint'; + } + + @override + String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + return '指纹(MD5 十六进制):$fingerprint'; + } + + @override + String get sshHostKeyType => 'SSH 主机密钥类型'; + + @override + String sshHostKeyNewDesc(Object serverName) { + return '收到来自 $serverName 的新 SSH 主机密钥,在信任前请检查指纹。'; + } + + @override + String sshHostKeyStoredFingerprint(Object fingerprint) { + return '已存储的指纹:$fingerprint'; + } + @override String get sshConfigManualSelect => '是否要手动选择 SSH 配置文件?'; @@ -1472,6 +1505,11 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get privateKey => '私鑰'; + @override + String privateKeyNotFoundFmt(Object keyId) { + return '未找到私鑰 [$keyId]。'; + } + @override String get process => '處理程序'; @@ -1645,6 +1683,34 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return '已從SSH設定匯入$count個伺服器'; } + @override + String sshHostKeyChangedDesc(Object serverName) { + return '伺服器 $serverName 的 SSH 主機金鑰已變更,僅在信任該伺服器時繼續。'; + } + + @override + String sshHostKeyFingerprintMd5Base64(Object fingerprint) { + return '指紋(MD5 Base64):$fingerprint'; + } + + @override + String sshHostKeyFingerprintMd5Hex(Object fingerprint) { + return '指紋(MD5 十六進位):$fingerprint'; + } + + @override + String get sshHostKeyType => 'SSH 主機金鑰類型'; + + @override + String sshHostKeyNewDesc(Object serverName) { + return '收到來自 $serverName 的新 SSH 主機金鑰,信任前請先檢查指紋。'; + } + + @override + String sshHostKeyStoredFingerprint(Object fingerprint) { + return '已儲存的指紋:$fingerprint'; + } + @override String get sshConfigManualSelect => '是否要手動選擇 SSH 設定檔案?'; diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 0e6cc89d..4d989dcf 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -171,6 +171,7 @@ "port": "Port", "preferDiskAmount": "Festplattenkapazität vorrangig anzeigen", "privateKey": "Private Key", + "privateKeyNotFoundFmt": "Privater Schlüssel [{keyId}] wurde nicht gefunden.", "process": "Prozess", "prune": "Beschneiden", "pushToken": "Push Token", @@ -225,6 +226,12 @@ "sshConfigImportPermission": "Möchten Sie die Berechtigung erteilen, ~/.ssh/config zu lesen und Server-Einstellungen automatisch zu importieren?", "sshConfigImportTip": "Bei der ersten Server-Erstellung zum Lesen von ~/.ssh/config auffordern", "sshConfigImported": "{count} Server aus SSH-Konfiguration importiert", + "sshHostKeyChangedDesc": "Der SSH-Hostschlüssel für {serverName} hat sich geändert. Fahren Sie nur fort, wenn Sie diesem Server vertrauen.", + "sshHostKeyFingerprintMd5Base64": "Fingerabdruck (MD5 Base64): {fingerprint}", + "sshHostKeyFingerprintMd5Hex": "Fingerabdruck (MD5 Hex): {fingerprint}", + "sshHostKeyType": "SSH-Hostschlüsseltyp", + "sshHostKeyNewDesc": "Ein neuer SSH-Hostschlüssel wurde von {serverName} empfangen. Prüfen Sie den Fingerabdruck, bevor Sie vertrauen.", + "sshHostKeyStoredFingerprint": "Gespeicherter Fingerabdruck: {fingerprint}", "sshConfigManualSelect": "Möchten Sie die SSH-Konfigurationsdatei manuell auswählen?", "sshConfigNoServers": "Keine Server in der SSH-Konfiguration gefunden", "sshConfigPermissionDenied": "Aufgrund der macOS-Berechtigungen kann nicht auf die SSH-Konfigurationsdatei zugegriffen werden.", @@ -287,4 +294,4 @@ "write": "Schreiben", "writeScriptFailTip": "Das Schreiben des Skripts ist fehlgeschlagen, möglicherweise aufgrund fehlender Berechtigungen oder das Verzeichnis existiert nicht.", "writeScriptTip": "Nach der Verbindung mit dem Server wird ein Skript in `~/.config/server_box` \n | `/tmp/server_box` geschrieben, um den Systemstatus zu überwachen. Sie können den Skriptinhalt überprüfen." -} \ No newline at end of file +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 107e69cd..53467523 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -171,6 +171,7 @@ "port": "Port", "preferDiskAmount": "Prioritize displaying disk capacity", "privateKey": "Private Key", + "privateKeyNotFoundFmt": "Private key [{keyId}] not found.", "process": "Process", "prune": "Prune", "pushToken": "Push token", @@ -225,6 +226,15 @@ "sshConfigImportPermission": "Would you like to give permission to read ~/.ssh/config and automatically import server settings?", "sshConfigImportTip": "Prompt to read ~/.ssh/config on first server creation", "sshConfigImported": "Imported {count} servers from SSH config", + "sshHostKeyChangedDesc": "The SSH host key changed for {serverName}. Only continue if you trust this server.", + "sshHostKeyFingerprintMd5Base64": "Fingerprint (MD5 base64): {fingerprint}", + "sshHostKeyFingerprintMd5Hex": "Fingerprint (MD5 hex): {fingerprint}", + "sshHostKeyType": "SSH host key type", + "@sshHostKeyType": { + "description": "Label for the SSH host key type displayed in the host key verification dialog." + }, + "sshHostKeyNewDesc": "A new SSH host key was received from {serverName}. Review the fingerprint before trusting.", + "sshHostKeyStoredFingerprint": "Stored fingerprint: {fingerprint}", "sshConfigManualSelect": "Would you like to select the SSH config file manually?", "sshConfigNoServers": "No servers found in SSH config", "sshConfigPermissionDenied": "Cannot access SSH config file due to macOS permissions.", @@ -287,4 +297,4 @@ "write": "Write", "writeScriptFailTip": "Writing to the script failed, possibly due to lack of permissions or the directory does not exist.", "writeScriptTip": "After connecting to the server, a script will be written to `~/.config/server_box` \n | `/tmp/server_box` to monitor the system status. You can review the script content." -} \ No newline at end of file +} diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 8db1dc81..8aa4f0dc 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -171,6 +171,7 @@ "port": "Puerto", "preferDiskAmount": "Priorizar la visualización de la capacidad del disco", "privateKey": "Llave privada", + "privateKeyNotFoundFmt": "No se encontró la clave privada [{keyId}].", "process": "Proceso", "prune": "Podar", "pushToken": "Token de notificaciones", @@ -225,6 +226,12 @@ "sshConfigImportPermission": "¿Te gustaría dar permiso para leer ~/.ssh/config e importar automáticamente la configuración de servidores?", "sshConfigImportTip": "Sugerencia para leer ~/.ssh/config al crear el primer servidor", "sshConfigImported": "Se importaron {count} servidores desde la configuración SSH", + "sshHostKeyChangedDesc": "La clave de host SSH de {serverName} ha cambiado. Continúa solo si confías en este servidor.", + "sshHostKeyFingerprintMd5Base64": "Huella (MD5 Base64): {fingerprint}", + "sshHostKeyFingerprintMd5Hex": "Huella (MD5 hex): {fingerprint}", + "sshHostKeyType": "Tipo de clave de host SSH", + "sshHostKeyNewDesc": "Se recibió una nueva clave de host SSH de {serverName}. Revisa la huella antes de confiar.", + "sshHostKeyStoredFingerprint": "Huella almacenada: {fingerprint}", "sshConfigManualSelect": "¿Te gustaría seleccionar manualmente el archivo de configuración SSH?", "sshConfigNoServers": "No se encontraron servidores en la configuración SSH", "sshConfigPermissionDenied": "No se puede acceder al archivo de configuración SSH debido a los permisos de macOS.", @@ -287,4 +294,4 @@ "write": "Escribir", "writeScriptFailTip": "La escritura en el script falló, posiblemente por falta de permisos o porque el directorio no existe.", "writeScriptTip": "Después de conectarse al servidor, se escribirá un script en `~/.config/server_box` \n | `/tmp/server_box` para monitorear el estado del sistema. Puedes revisar el contenido del script." -} \ No newline at end of file +} diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index f911b4d1..be91ce69 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -171,6 +171,7 @@ "port": "Port", "preferDiskAmount": "Prioriser l’affichage de la capacité du disque", "privateKey": "Clé privée", + "privateKeyNotFoundFmt": "Clé privée [{keyId}] introuvable.", "process": "Processus", "prune": "Élaguer", "pushToken": "Jeton d'identification", @@ -225,6 +226,12 @@ "sshConfigImportPermission": "Souhaitez-vous donner la permission de lire ~/.ssh/config et d'importer automatiquement les paramètres du serveur ?", "sshConfigImportTip": "Proposer de lire ~/.ssh/config lors de la première création de serveur", "sshConfigImported": "{count} serveurs importés depuis la configuration SSH", + "sshHostKeyChangedDesc": "La clé d'hôte SSH de {serverName} a changé. Ne continuez que si vous faites confiance à ce serveur.", + "sshHostKeyFingerprintMd5Base64": "Empreinte (MD5 Base64) : {fingerprint}", + "sshHostKeyFingerprintMd5Hex": "Empreinte (MD5 hex) : {fingerprint}", + "sshHostKeyType": "Type de clé d'hôte SSH", + "sshHostKeyNewDesc": "Une nouvelle clé d'hôte SSH a été reçue de {serverName}. Vérifiez l'empreinte avant de faire confiance.", + "sshHostKeyStoredFingerprint": "Empreinte enregistrée : {fingerprint}", "sshConfigManualSelect": "Souhaitez-vous sélectionner manuellement le fichier de configuration SSH ?", "sshConfigNoServers": "Aucun serveur trouvé dans la configuration SSH", "sshConfigPermissionDenied": "Impossible d'accéder au fichier de configuration SSH en raison des permissions macOS.", @@ -287,4 +294,4 @@ "write": "Écrire", "writeScriptFailTip": "Échec de l'écriture dans le script, probablement en raison d'un manque de permissions ou que le répertoire n'existe pas.", "writeScriptTip": "Après la connexion au serveur, un script sera écrit dans `~/.config/server_box` \n | `/tmp/server_box` pour surveiller l'état du système. Vous pouvez examiner le contenu du script." -} \ No newline at end of file +} diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index f38115b2..570d7536 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -171,6 +171,7 @@ "port": "Port", "preferDiskAmount": "Prioritaskan tampilan kapasitas disk", "privateKey": "Kunci Pribadi", + "privateKeyNotFoundFmt": "Kunci privat [{keyId}] tidak ditemukan.", "process": "Proses", "prune": "Pangkas", "pushToken": "Dorong token", @@ -225,6 +226,12 @@ "sshConfigImportPermission": "Apakah Anda ingin memberikan izin untuk membaca ~/.ssh/config dan secara otomatis mengimpor pengaturan server?", "sshConfigImportTip": "Prompt untuk membaca ~/.ssh/config saat pembuatan server pertama", "sshConfigImported": "Berhasil mengimpor {count} server dari konfigurasi SSH", + "sshHostKeyChangedDesc": "Kunci host SSH untuk {serverName} telah berubah. Lanjutkan hanya jika Anda mempercayai server ini.", + "sshHostKeyFingerprintMd5Base64": "Sidik jari (MD5 Base64): {fingerprint}", + "sshHostKeyFingerprintMd5Hex": "Sidik jari (MD5 hex): {fingerprint}", + "sshHostKeyType": "Jenis kunci host SSH", + "sshHostKeyNewDesc": "Kunci host SSH baru diterima dari {serverName}. Periksa sidik jarinya sebelum mempercayai.", + "sshHostKeyStoredFingerprint": "Sidik jari tersimpan: {fingerprint}", "sshConfigManualSelect": "Apakah Anda ingin memilih file konfigurasi SSH secara manual?", "sshConfigNoServers": "Tidak ada server yang ditemukan dalam konfigurasi SSH", "sshConfigPermissionDenied": "Tidak dapat mengakses file konfigurasi SSH karena izin macOS.", @@ -287,4 +294,4 @@ "write": "Tulis", "writeScriptFailTip": "Penulisan ke skrip gagal, mungkin karena tidak ada izin atau direktori tidak ada.", "writeScriptTip": "Setelah terhubung ke server, sebuah skrip akan ditulis ke `~/.config/server_box` \n | `/tmp/server_box` untuk memantau status sistem. Anda dapat meninjau konten skrip tersebut." -} \ No newline at end of file +} diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 25d12b53..81bdb997 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -171,6 +171,7 @@ "port": "ポート", "preferDiskAmount": "ディスク容量を優先的に表示", "privateKey": "秘密鍵", + "privateKeyNotFoundFmt": "秘密鍵 [{keyId}] が見つかりません。", "process": "プロセス", "prune": "剪定する", "pushToken": "プッシュトークン", @@ -225,6 +226,12 @@ "sshConfigImportPermission": "~/.ssh/configを読み取ってサーバー設定を自動的にインポートする権限を与えますか?", "sshConfigImportTip": "初回サーバー作成時に~/.ssh/configの読み取りを促す", "sshConfigImported": "SSH設定から{count}個のサーバーをインポートしました", + "sshHostKeyChangedDesc": "{serverName} の SSH ホスト鍵が変更されました。このサーバーを信頼できる場合のみ続行してください。", + "sshHostKeyFingerprintMd5Base64": "フィンガープリント (MD5 Base64): {fingerprint}", + "sshHostKeyFingerprintMd5Hex": "フィンガープリント (MD5 16進): {fingerprint}", + "sshHostKeyType": "SSH ホストキーの種類", + "sshHostKeyNewDesc": "{serverName} から新しい SSH ホスト鍵を受信しました。信頼する前にフィンガープリントを確認してください。", + "sshHostKeyStoredFingerprint": "保存済みフィンガープリント: {fingerprint}", "sshConfigManualSelect": "SSH設定ファイルを手動で選択しますか?", "sshConfigNoServers": "SSH設定でサーバーが見つかりませんでした", "sshConfigPermissionDenied": "macOSの権限により、SSH設定ファイルにアクセスできません。", @@ -287,4 +294,4 @@ "write": "書き込み", "writeScriptFailTip": "スクリプトの書き込みに失敗しました。権限がないかディレクトリが存在しない可能性があります。", "writeScriptTip": "サーバーに接続すると、システムの状態を監視するためのスクリプトが `~/.config/server_box` \n | `/tmp/server_box` に書き込まれます。スクリプトの内容を確認できます。" -} \ No newline at end of file +} diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 899f85cc..068a21e0 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -171,6 +171,7 @@ "port": "Poort", "preferDiskAmount": "Geef de schijfcapaciteit prioriteit bij weergave", "privateKey": "Privésleutel", + "privateKeyNotFoundFmt": "Privésleutel [{keyId}] niet gevonden.", "process": "Proces", "prune": "Snoeien", "pushToken": "Push-token", @@ -225,6 +226,12 @@ "sshConfigImportPermission": "Wilt u toestemming geven om ~/.ssh/config te lezen en automatisch serverinstellingen te importeren?", "sshConfigImportTip": "Prompt om ~/.ssh/config te lezen bij het aanmaken van de eerste server", "sshConfigImported": "{count} servers geïmporteerd uit SSH-configuratie", + "sshHostKeyChangedDesc": "De SSH-hostsleutel voor {serverName} is gewijzigd. Ga alleen verder als u deze server vertrouwt.", + "sshHostKeyFingerprintMd5Base64": "Vingerafdruk (MD5 Base64): {fingerprint}", + "sshHostKeyFingerprintMd5Hex": "Vingerafdruk (MD5 hex): {fingerprint}", + "sshHostKeyType": "Type SSH-hostsleutel", + "sshHostKeyNewDesc": "Er is een nieuwe SSH-hostsleutel ontvangen van {serverName}. Controleer de vingerafdruk voordat u vertrouwt.", + "sshHostKeyStoredFingerprint": "Opgeslagen vingerafdruk: {fingerprint}", "sshConfigManualSelect": "Wilt u het SSH-configuratiebestand handmatig selecteren?", "sshConfigNoServers": "Geen servers gevonden in SSH-configuratie", "sshConfigPermissionDenied": "Kan geen toegang krijgen tot SSH-configuratiebestand vanwege macOS-rechten.", @@ -287,4 +294,4 @@ "write": "Schrijven", "writeScriptFailTip": "Het schrijven naar het script is mislukt, mogelijk door gebrek aan rechten of omdat de map niet bestaat.", "writeScriptTip": "Na het verbinden met de server wordt een script geschreven naar `~/.config/server_box` \n | `/tmp/server_box` om de systeemstatus te monitoren. U kunt de inhoud van het script controleren." -} \ No newline at end of file +} diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index b6a4293f..b58c04c6 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -171,6 +171,7 @@ "port": "Porta", "preferDiskAmount": "Priorizar a exibição da capacidade do disco", "privateKey": "Chave privada", + "privateKeyNotFoundFmt": "Chave privada [{keyId}] não encontrada.", "process": "Processo", "prune": "Podar", "pushToken": "Token de notificação push", @@ -225,6 +226,12 @@ "sshConfigImportPermission": "Gostaria de dar permissão para ler ~/.ssh/config e importar automaticamente as configurações do servidor?", "sshConfigImportTip": "Sugestão para ler ~/.ssh/config na criação do primeiro servidor", "sshConfigImported": "Importados {count} servidores da configuração SSH", + "sshHostKeyChangedDesc": "A chave de host SSH de {serverName} foi alterada. Continue apenas se confiar neste servidor.", + "sshHostKeyFingerprintMd5Base64": "Impressão digital (MD5 Base64): {fingerprint}", + "sshHostKeyFingerprintMd5Hex": "Impressão digital (MD5 hex): {fingerprint}", + "sshHostKeyType": "Tipo de chave de host SSH", + "sshHostKeyNewDesc": "Uma nova chave de host SSH foi recebida de {serverName}. Verifique a impressão digital antes de confiar.", + "sshHostKeyStoredFingerprint": "Impressão digital armazenada: {fingerprint}", "sshConfigManualSelect": "Gostaria de selecionar manualmente o arquivo de configuração SSH?", "sshConfigNoServers": "Nenhum servidor encontrado na configuração SSH", "sshConfigPermissionDenied": "Não é possível acessar o arquivo de configuração SSH devido às permissões do macOS.", @@ -287,4 +294,4 @@ "write": "Escrita", "writeScriptFailTip": "Falha ao escrever no script, possivelmente devido à falta de permissões ou o diretório não existe.", "writeScriptTip": "Após conectar ao servidor, um script será escrito em `~/.config/server_box` \n | `/tmp/server_box` para monitorar o status do sistema. Você pode revisar o conteúdo do script." -} \ No newline at end of file +} diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index a4df1402..cc3c8f45 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -171,6 +171,7 @@ "port": "Порт", "preferDiskAmount": "Приоритетное отображение объёма диска", "privateKey": "Приватный ключ", + "privateKeyNotFoundFmt": "Закрытый ключ [{keyId}] не найден.", "process": "Процесс", "prune": "Обрезать", "pushToken": "Токен уведомлений", @@ -225,6 +226,12 @@ "sshConfigImportPermission": "Хотите ли вы дать разрешение на чтение ~/.ssh/config и автоматический импорт настроек сервера?", "sshConfigImportTip": "Предложение прочитать ~/.ssh/config при создании первого сервера", "sshConfigImported": "Импортировано {count} серверов из SSH-конфигурации", + "sshHostKeyChangedDesc": "SSH-ключ хоста для {serverName} изменился. Продолжайте только если доверяете этому серверу.", + "sshHostKeyFingerprintMd5Base64": "Отпечаток (MD5 Base64): {fingerprint}", + "sshHostKeyFingerprintMd5Hex": "Отпечаток (MD5 hex): {fingerprint}", + "sshHostKeyType": "Тип ключа хоста SSH", + "sshHostKeyNewDesc": "Получен новый SSH-ключ хоста от {serverName}. Проверьте отпечаток перед продолжением.", + "sshHostKeyStoredFingerprint": "Сохранённый отпечаток: {fingerprint}", "sshConfigManualSelect": "Хотели бы вы вручную выбрать файл конфигурации SSH?", "sshConfigNoServers": "Серверы не найдены в SSH-конфигурации", "sshConfigPermissionDenied": "Невозможно получить доступ к файлу конфигурации SSH из-за разрешений macOS.", @@ -287,4 +294,4 @@ "write": "Запись", "writeScriptFailTip": "Запись скрипта не удалась, возможно, из-за отсутствия прав или потому что, директории не существует.", "writeScriptTip": "После подключения к серверу скрипт будет записан в `~/.config/server_box` \n | `/tmp/server_box` для мониторинга состояния системы. Вы можете проверить содержимое скрипта." -} \ No newline at end of file +} diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 62842764..4b56f069 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -171,6 +171,7 @@ "port": "Port", "preferDiskAmount": "Disk kapasitesini öncelikli olarak göster", "privateKey": "Özel Anahtar", + "privateKeyNotFoundFmt": "Özel anahtar [{keyId}] bulunamadı.", "process": "İşlem", "prune": "Budamak", "pushToken": "Push belirteci", @@ -225,6 +226,12 @@ "sshConfigImportPermission": "~/.ssh/config dosyasını okumak ve sunucu ayarlarını otomatik olarak içe aktarmak için izin vermek ister misiniz?", "sshConfigImportTip": "İlk sunucu oluşturulurken ~/.ssh/config okuma istemi", "sshConfigImported": "SSH yapılandırmasından {count} sunucu içe aktarıldı", + "sshHostKeyChangedDesc": "{serverName} için SSH ana bilgisayar anahtarı değişti. Yalnızca bu sunucuya güveniyorsanız devam edin.", + "sshHostKeyFingerprintMd5Base64": "Parmak izi (MD5 Base64): {fingerprint}", + "sshHostKeyFingerprintMd5Hex": "Parmak izi (MD5 hex): {fingerprint}", + "sshHostKeyType": "SSH ana bilgisayar anahtarı türü", + "sshHostKeyNewDesc": "{serverName} üzerinden yeni bir SSH ana bilgisayar anahtarı alındı. Güvenmeden önce parmak izini kontrol edin.", + "sshHostKeyStoredFingerprint": "Kaydedilen parmak izi: {fingerprint}", "sshConfigManualSelect": "SSH yapılandırma dosyasını manuel olarak seçmek ister misiniz?", "sshConfigNoServers": "SSH yapılandırmasında sunucu bulunamadı", "sshConfigPermissionDenied": "macOS izinleri nedeniyle SSH yapılandırma dosyasına erişilemiyor.", @@ -287,4 +294,4 @@ "write": "Yaz", "writeScriptFailTip": "Betik yazma başarısız oldu, muhtemelen izin eksikliği veya dizin mevcut değil.", "writeScriptTip": "Sunucuya bağlandıktan sonra, sistem durumunu izlemek için `~/.config/server_box` \n | `/tmp/server_box` dizinine bir betik yazılacak. Betik içeriğini inceleyebilirsiniz." -} \ No newline at end of file +} diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 2a1b1231..2117c02a 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -171,6 +171,7 @@ "port": "Порт", "preferDiskAmount": "Пріоритетно показувати ємність диска", "privateKey": "Приватний ключ", + "privateKeyNotFoundFmt": "Приватний ключ [{keyId}] не знайдено.", "process": "Процес", "prune": "Обрізати", "pushToken": "Надіслати токен", @@ -225,6 +226,12 @@ "sshConfigImportPermission": "Чи хочете ви надати дозвіл на читання ~/.ssh/config та автоматичний імпорт налаштувань сервера?", "sshConfigImportTip": "Пропозиція прочитати ~/.ssh/config при створенні першого сервера", "sshConfigImported": "Імпортовано {count} серверів з SSH-конфігурації", + "sshHostKeyChangedDesc": "SSH-ключ хоста для {serverName} змінено. Продовжуйте лише якщо довіряєте цьому серверу.", + "sshHostKeyFingerprintMd5Base64": "Відбиток (MD5 Base64): {fingerprint}", + "sshHostKeyFingerprintMd5Hex": "Відбиток (MD5 hex): {fingerprint}", + "sshHostKeyType": "Тип ключа хоста SSH", + "sshHostKeyNewDesc": "Отримано новий SSH-ключ хоста від {serverName}. Перевірте відбиток перед тим, як довіряти.", + "sshHostKeyStoredFingerprint": "Збережений відбиток: {fingerprint}", "sshConfigManualSelect": "Чи хочете ви вручну вибрати файл конфігурації SSH?", "sshConfigNoServers": "Сервери не знайдені в SSH-конфігурації", "sshConfigPermissionDenied": "Неможливо отримати доступ до файлу конфігурації SSH через дозволи macOS.", @@ -287,4 +294,4 @@ "write": "Записати", "writeScriptFailTip": "Запис у скрипт не вдався, можливо, через брак дозволів або каталог не існує.", "writeScriptTip": "Після підключення до сервера скрипт буде записано у `~/.config/server_box` \n | `/tmp/server_box` для моніторингу стану системи. Ви можете переглянути вміст скрипта." -} \ No newline at end of file +} diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index b845ff12..9e619600 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -171,6 +171,7 @@ "port": "端口", "preferDiskAmount": "优先显示硬盘容量", "privateKey": "私钥", + "privateKeyNotFoundFmt": "未找到私钥 [{keyId}]。", "process": "进程", "prune": "修剪", "pushToken": "消息推送 Token", @@ -225,6 +226,12 @@ "sshConfigImportPermission": "是否允许读取 ~/.ssh/config 并自动导入服务器设置?", "sshConfigImportTip": "首次创建服务器时提示读取 ~/.ssh/config", "sshConfigImported": "从 SSH 配置导入了 {count} 个服务器", + "sshHostKeyChangedDesc": "服务器 {serverName} 的 SSH 主机密钥已更改,仅在信任该服务器时继续。", + "sshHostKeyFingerprintMd5Base64": "指纹(MD5 Base64):{fingerprint}", + "sshHostKeyFingerprintMd5Hex": "指纹(MD5 十六进制):{fingerprint}", + "sshHostKeyType": "SSH 主机密钥类型", + "sshHostKeyNewDesc": "收到来自 {serverName} 的新 SSH 主机密钥,在信任前请检查指纹。", + "sshHostKeyStoredFingerprint": "已存储的指纹:{fingerprint}", "sshConfigManualSelect": "是否要手动选择 SSH 配置文件?", "sshConfigNoServers": "SSH 配置中未找到服务器", "sshConfigPermissionDenied": "由于 macOS 权限限制,无法访问 SSH 配置文件。", @@ -287,4 +294,4 @@ "write": "写", "writeScriptFailTip": "写入脚本失败,可能是没有权限/目录不存在等", "writeScriptTip": "在连接服务器后,会向 `~/.config/server_box` \n | `/tmp/server_box` 写入脚本来监测系统状态,你可以审查脚本内容。" -} \ No newline at end of file +} diff --git a/lib/l10n/app_zh_tw.arb b/lib/l10n/app_zh_tw.arb index 7ae2d5e3..21ccd82a 100644 --- a/lib/l10n/app_zh_tw.arb +++ b/lib/l10n/app_zh_tw.arb @@ -171,6 +171,7 @@ "port": "埠", "preferDiskAmount": "優先顯示硬碟容量", "privateKey": "私鑰", + "privateKeyNotFoundFmt": "未找到私鑰 [{keyId}]。", "process": "處理程序", "prune": "修剪", "pushToken": "消息推送 Token", @@ -225,6 +226,12 @@ "sshConfigImportPermission": "您是否希望允許讀取 ~/.ssh/config 並自動匯入伺服器設定?", "sshConfigImportTip": "在建立第一個伺服器時提示讀取 ~/.ssh/config", "sshConfigImported": "已從SSH設定匯入{count}個伺服器", + "sshHostKeyChangedDesc": "伺服器 {serverName} 的 SSH 主機金鑰已變更,僅在信任該伺服器時繼續。", + "sshHostKeyFingerprintMd5Base64": "指紋(MD5 Base64):{fingerprint}", + "sshHostKeyFingerprintMd5Hex": "指紋(MD5 十六進位):{fingerprint}", + "sshHostKeyType": "SSH 主機金鑰類型", + "sshHostKeyNewDesc": "收到來自 {serverName} 的新 SSH 主機金鑰,信任前請先檢查指紋。", + "sshHostKeyStoredFingerprint": "已儲存的指紋:{fingerprint}", "sshConfigManualSelect": "是否要手動選擇 SSH 設定檔案?", "sshConfigNoServers": "SSH設定中未找到伺服器", "sshConfigPermissionDenied": "由於 macOS 權限限制,無法存取 SSH 設定檔案。", @@ -287,4 +294,4 @@ "write": "寫入", "writeScriptFailTip": "寫入腳本失敗,可能是沒有權限/目錄不存在等。", "writeScriptTip": "連線到伺服器後,將會在 `~/.config/server_box` \n | `/tmp/server_box` 中寫入一個腳本來監測系統狀態。你可以審查腳本內容。" -} \ No newline at end of file +} diff --git a/lib/view/page/server/connection_stats.dart b/lib/view/page/server/connection_stats.dart index 5c47577b..e5f2a2b7 100644 --- a/lib/view/page/server/connection_stats.dart +++ b/lib/view/page/server/connection_stats.dart @@ -41,11 +41,7 @@ class _ConnectionStatsPageState extends State { appBar: CustomAppBar( title: Text(l10n.connectionStats), actions: [ - IconButton( - onPressed: _loadStats, - icon: const Icon(Icons.refresh), - tooltip: libL10n.refresh, - ), + IconButton(onPressed: _loadStats, icon: const Icon(Icons.refresh), tooltip: libL10n.refresh), IconButton( onPressed: _showClearAllDialog, icon: const Icon(Icons.clear_all, color: Colors.red), @@ -75,140 +71,90 @@ class _ConnectionStatsPageState extends State { } Widget _buildServerStatsCard(ServerConnectionStats stats) { - final successRate = stats.totalAttempts == 0 - ? 'N/A' - : '${(stats.successRate * 100).toStringAsFixed(1)}%'; + final successRate = stats.totalAttempts == 0 ? 'N/A' : '${(stats.successRate * 100).toStringAsFixed(1)}%'; final lastSuccessTime = stats.lastSuccessTime; final lastFailureTime = stats.lastFailureTime; - return Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - stats.serverName, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + stats.serverName, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), - Text( - '${libL10n.success}: $successRate', - style: TextStyle( - fontSize: 16, - color: stats.successRate >= 0.8 - ? Colors.green - : stats.successRate >= 0.5 - ? Colors.orange - : Colors.red, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _buildStatItem( - l10n.totalAttempts, - stats.totalAttempts.toString(), - Icons.all_inclusive, - ), - _buildStatItem( - libL10n.success, - stats.successCount.toString(), - Icons.check_circle, - Colors.green, - ), - _buildStatItem( - libL10n.fail, - stats.failureCount.toString(), - Icons.error, - Colors.red, - ), - ], - ), - if (lastSuccessTime != null || lastFailureTime != null) ...[ - const SizedBox(height: 16), - const Divider(), - const SizedBox(height: 8), - if (lastSuccessTime != null) - _buildTimeItem( - l10n.lastSuccess, - lastSuccessTime, - Icons.check_circle, - Colors.green, - ), - if (lastFailureTime != null) - _buildTimeItem( - l10n.lastFailure, - lastFailureTime, - Icons.error, - Colors.red, + ), + Text( + '${libL10n.success}: $successRate', + style: TextStyle( + fontSize: 16, + color: stats.successRate >= 0.8 + ? Colors.green + : stats.successRate >= 0.5 + ? Colors.orange + : Colors.red, + fontWeight: FontWeight.bold, ), + ), ], + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildStatItem(l10n.totalAttempts, stats.totalAttempts.toString(), Icons.all_inclusive), + _buildStatItem( + libL10n.success, + stats.successCount.toString(), + Icons.check_circle, + Colors.green, + ), + _buildStatItem(libL10n.fail, stats.failureCount.toString(), Icons.error, Colors.red), + ], + ), + if (lastSuccessTime != null || lastFailureTime != null) ...[ const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - l10n.recentConnections, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - ), - ), - TextButton( - onPressed: () => _showServerDetailsDialog(stats), - child: Text(l10n.viewDetails), - ), - ], - ), + const Divider(), const SizedBox(height: 8), - ...stats.recentConnections.take(3).map(_buildConnectionItem), + if (lastSuccessTime != null) + _buildTimeItem(l10n.lastSuccess, lastSuccessTime, Icons.check_circle, Colors.green), + if (lastFailureTime != null) + _buildTimeItem(l10n.lastFailure, lastFailureTime, Icons.error, Colors.red), ], - ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(l10n.recentConnections, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), + TextButton(onPressed: () => _showServerDetailsDialog(stats), child: Text(l10n.viewDetails)), + ], + ), + const SizedBox(height: 8), + ...stats.recentConnections.take(3).map(_buildConnectionItem), + ], ), - ); + ).cardx; } - Widget _buildStatItem( - String label, - String value, - IconData icon, [ - Color? color, - ]) { + Widget _buildStatItem(String label, String value, IconData icon, [Color? color]) { return Column( children: [ Icon(icon, size: 24, color: color ?? Colors.grey), const SizedBox(height: 4), Text( value, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: color, - ), + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: color), ), Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])), ], ); } - Widget _buildTimeItem( - String label, - DateTime time, - IconData icon, - Color color, - ) { + Widget _buildTimeItem(String label, DateTime time, IconData icon, Color color) { final timeStr = time.simple(); return Padding( padding: const EdgeInsets.symmetric(vertical: 4), @@ -216,10 +162,7 @@ class _ConnectionStatsPageState extends State { children: [ Icon(icon, size: 16, color: color), UIs.width7, - Text( - '$label: ', - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), + Text('$label: ', style: TextStyle(fontSize: 12, color: Colors.grey[600])), Text(timeStr, style: const TextStyle(fontSize: 12)), ], ), @@ -244,13 +187,8 @@ class _ConnectionStatsPageState extends State { UIs.width7, Expanded( child: Text( - isSuccess - ? '${libL10n.success} (${stat.durationMs}ms)' - : stat.result.displayName, - style: TextStyle( - fontSize: 12, - color: isSuccess ? Colors.green : Colors.red, - ), + isSuccess ? '${libL10n.success} (${stat.durationMs}ms)' : stat.result.displayName, + style: TextStyle(fontSize: 12, color: isSuccess ? Colors.green : Colors.red), overflow: TextOverflow.ellipsis, ), ), @@ -289,9 +227,7 @@ extension on _ConnectionStatsPageState { isSuccess ? '${libL10n.success} (${stat.durationMs}ms)' : '${libL10n.fail}: ${stat.result.displayName}', - style: TextStyle( - color: isSuccess ? Colors.green : Colors.red, - ), + style: TextStyle(color: isSuccess ? Colors.green : Colors.red), ), if (!isSuccess && stat.errorMessage.isNotEmpty) Text( @@ -313,10 +249,7 @@ extension on _ConnectionStatsPageState { Navigator.of(context).pop(); _showClearServerStatsDialog(stats); }, - child: Text( - l10n.clearThisServerStats, - style: TextStyle(color: Colors.red), - ), + child: Text(l10n.clearThisServerStats, style: TextStyle(color: Colors.red)), ), ], ); diff --git a/lib/view/page/setting/entries/app.dart b/lib/view/page/setting/entries/app.dart index 7b0099b2..6291ab17 100644 --- a/lib/view/page/setting/entries/app.dart +++ b/lib/view/page/setting/entries/app.dart @@ -284,4 +284,122 @@ extension _App on _AppSettingsPageState { }, ); } + + Widget _buildEditRawSettings() { + return ListTile( + title: const Text('(Dev) Edit raw json'), + trailing: const Icon(Icons.keyboard_arrow_right), + onTap: _editRawSettings, + ); + } + + Future _editRawSettings() async { + final rawMap = Stores.setting.getAllMap(includeInternalKeys: true); + final map = Map.from(rawMap); + final initialKeys = Set.from(map.keys); + Map mapForEditor = map; + String? encryptedKey; + String? passwordUsed; + + Future resolvePassword() async { + final saved = await _setting.backupasswd.read(); + if (saved?.isNotEmpty == true) return saved; + final backupPwd = await SecureStoreProps.bakPwd.read(); + if (backupPwd?.isNotEmpty == true) return backupPwd; + final controller = TextEditingController(); + try { + final result = await context.showRoundDialog( + title: libL10n.pwd, + child: Input( + controller: controller, + label: libL10n.pwd, + obscureText: true, + onSubmitted: (_) => context.pop(controller.text.trim()), + ), + actions: [ + TextButton(onPressed: () => context.pop(null), child: Text(libL10n.cancel)), + TextButton(onPressed: () => context.pop(controller.text.trim()), child: Text(libL10n.ok)), + ], + ); + return result?.trim(); + } finally { + controller.dispose(); + } + } + + for (final entry in map.entries) { + final value = entry.value; + if (value is String && Cryptor.isEncrypted(value)) { + final password = await resolvePassword(); + if (password == null || password.isEmpty) { + context.showSnackBar(libL10n.cancel); + return; + } + try { + final decrypted = Cryptor.decrypt(value, password); + final decoded = json.decode(decrypted); + if (decoded is Map) { + mapForEditor = Map.from(decoded); + encryptedKey = entry.key; + passwordUsed = password; + break; + } else { + context.showRoundDialog(title: libL10n.fail, child: Text(l10n.invalid)); + return; + } + } catch (e, stack) { + final msg = e.toString().contains('Failed to decrypt') || e.toString().contains('incorrect password') + ? l10n.backupPasswordWrong + : '${libL10n.error}:\n$e'; + context.showRoundDialog(title: libL10n.fail, child: Text(msg)); + Loggers.app.warning('Decrypt raw settings failed', e, stack); + return; + } + } + } + + void onSave(EditorPageRet ret) { + if (ret.typ != EditorPageRetType.text) { + context.showRoundDialog(title: libL10n.fail, child: Text(l10n.invalid)); + return; + } + try { + final newSettings = json.decode(ret.val) as Map; + if (encryptedKey != null) { + final pwd = passwordUsed; + if (pwd == null || pwd.isEmpty) { + context.showRoundDialog(title: libL10n.fail, child: Text(l10n.invalid)); + return; + } + final encrypted = Cryptor.encrypt(json.encode(newSettings), pwd); + Stores.setting.box.put(encryptedKey, encrypted); + } else { + Stores.setting.box.putAll(newSettings); + final newKeys = newSettings.keys.toSet(); + final removedKeys = initialKeys.where((e) => !newKeys.contains(e)); + for (final key in removedKeys) { + Stores.setting.box.delete(key); + } + } + } catch (e, trace) { + context.showRoundDialog(title: libL10n.error, child: Text('${l10n.save}:\n$e')); + Loggers.app.warning('Update json settings failed', e, trace); + } + } + + /// Encode [map] to String with indent `\t` + final text = jsonIndentEncoder.convert(mapForEditor); + await EditorPage.route.go( + context, + args: EditorPageArgs( + text: text, + lang: ProgLang.json, + title: libL10n.setting, + onSave: onSave, + closeAfterSave: SettingStore.instance.closeAfterSave.fetch(), + softWrap: SettingStore.instance.editorSoftWrap.fetch(), + enableHighlight: SettingStore.instance.editorHighlight.fetch(), + ), + ); + } } diff --git a/lib/view/page/setting/entries/home_tabs.dart b/lib/view/page/setting/entries/home_tabs.dart index 854923d0..ab825722 100644 --- a/lib/view/page/setting/entries/home_tabs.dart +++ b/lib/view/page/setting/entries/home_tabs.dart @@ -83,10 +83,10 @@ class _HomeTabsConfigPageState extends ConsumerState { onTap: isSelected && canRemove ? () => _removeTab(tab) : null, ); - return Card( + return Padding( key: ValueKey(tab.name), - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: isSelected ? ReorderableDragStartListener(index: index, child: child) : child, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1), + child: (isSelected ? ReorderableDragStartListener(index: index, child: child) : child).cardx, ); } diff --git a/lib/view/page/setting/entries/server.dart b/lib/view/page/setting/entries/server.dart index 8495eaf9..196cfcbb 100644 --- a/lib/view/page/setting/entries/server.dart +++ b/lib/view/page/setting/entries/server.dart @@ -207,53 +207,6 @@ extension _Server on _AppSettingsPageState { ); } - Widget _buildEditRawSettings() { - return ListTile( - title: const Text('(Dev) Edit raw json'), - trailing: const Icon(Icons.keyboard_arrow_right), - onTap: _editRawSettings, - ); - } - - Future _editRawSettings() async { - final map = Stores.setting.getAllMap(includeInternalKeys: true); - final keys = map.keys; - - void onSave(EditorPageRet ret) { - if (ret.typ != EditorPageRetType.text) { - context.showRoundDialog(title: libL10n.fail, child: Text(l10n.invalid)); - return; - } - try { - final newSettings = json.decode(ret.val) as Map; - Stores.setting.box.putAll(newSettings); - final newKeys = newSettings.keys; - final removedKeys = keys.where((e) => !newKeys.contains(e)); - for (final key in removedKeys) { - Stores.setting.box.delete(key); - } - } catch (e, trace) { - context.showRoundDialog(title: libL10n.error, child: Text('${l10n.save}:\n$e')); - Loggers.app.warning('Update json settings failed', e, trace); - } - } - - /// Encode [map] to String with indent `\t` - final text = jsonIndentEncoder.convert(map); - await EditorPage.route.go( - context, - args: EditorPageArgs( - text: text, - lang: ProgLang.json, - title: libL10n.setting, - onSave: onSave, - closeAfterSave: SettingStore.instance.closeAfterSave.fetch(), - softWrap: SettingStore.instance.editorSoftWrap.fetch(), - enableHighlight: SettingStore.instance.editorHighlight.fetch(), - ), - ); - } - Widget _buildCpuView() { return ExpandTile( leading: const Icon(OctIcons.cpu, size: _kIconSize), diff --git a/lib/view/page/storage/local.dart b/lib/view/page/storage/local.dart index 52479d3c..1c2be656 100644 --- a/lib/view/page/storage/local.dart +++ b/lib/view/page/storage/local.dart @@ -4,6 +4,7 @@ import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:server_box/core/extension/context/locale.dart'; +import 'package:server_box/core/utils/host_key_helper.dart'; import 'package:server_box/data/model/app/path_with_prefix.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/model/sftp/worker.dart'; @@ -370,6 +371,10 @@ extension _OnTapFile on _LocalFilePageState { return; } + if (!await ensureHostKeyAcceptedForSftp(context, spi)) { + return; + } + ref.read(sftpProvider.notifier).add(SftpReq(spi, '$remotePath/$fileName', file.absolute.path, SftpReqType.upload)); context.showSnackBar(l10n.added2List); } diff --git a/lib/view/page/storage/sftp.dart b/lib/view/page/storage/sftp.dart index 61d459aa..53c97c39 100644 --- a/lib/view/page/storage/sftp.dart +++ b/lib/view/page/storage/sftp.dart @@ -9,6 +9,7 @@ import 'package:icons_plus/icons_plus.dart'; import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/core/extension/sftpfile.dart'; import 'package:server_box/core/utils/comparator.dart'; +import 'package:server_box/core/utils/host_key_helper.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/model/sftp/browser_status.dart'; import 'package:server_box/data/model/sftp/worker.dart'; @@ -46,7 +47,7 @@ class _SftpPageState extends ConsumerState with AfterLayoutMixin { late final SftpBrowserStatus _status; late final SSHClient _client; final _sortOption = _SortOption().vn; - + @override void initState() { super.initState(); @@ -286,6 +287,10 @@ extension _Actions on _SftpPageState { return; } + if (!await ensureHostKeyAcceptedForSftp(context, widget.args.spi)) { + return; + } + final remotePath = _getRemotePath(name); final localPath = _getLocalPath(remotePath); final completer = Completer(); @@ -298,7 +303,10 @@ extension _Actions on _SftpPageState { context, args: EditorPageArgs( path: localPath, - onSave: (_) { + onSave: (_) async { + if (!await ensureHostKeyAcceptedForSftp(context, req.spi)) { + return; + } ref .read(sftpProvider.notifier) .add(SftpReq(req.spi, remotePath, localPath, SftpReqType.upload)); @@ -322,6 +330,10 @@ extension _Actions on _SftpPageState { context.pop(); final remotePath = _getRemotePath(name); + if (!await ensureHostKeyAcceptedForSftp(context, widget.args.spi)) { + return; + } + ref .read(sftpProvider.notifier) .add(SftpReq(widget.args.spi, remotePath, _getLocalPath(remotePath), SftpReqType.download)); @@ -652,6 +664,9 @@ extension _Actions on _SftpPageState { final fileName = path.split(Platform.pathSeparator).lastOrNull; final remotePath = '$remoteDir/$fileName'; Loggers.app.info('SFTP upload local: $path, remote: $remotePath'); + if (!await ensureHostKeyAcceptedForSftp(context, widget.args.spi)) { + return; + } ref .read(sftpProvider.notifier) .add(SftpReq(widget.args.spi, remotePath, path, SftpReqType.upload));