feat: prompt user on host key verification (#943)

This commit is contained in:
lollipopkit🏳️‍⚧️
2025-10-20 09:31:20 +08:00
committed by GitHub
parent 8cbb48ed67
commit 5272324be6
38 changed files with 1076 additions and 219 deletions

View File

@@ -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

View File

@@ -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<MyApp> {
return MaterialApp(
key: ValueKey(locale),
navigatorKey: AppNavigator.key,
builder: ResponsivePoints.builder,
locale: locale,
localizationsDelegates: const [LibLocalizations.delegate, ...AppLocalizations.localizationsDelegates],

View File

@@ -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<NavigatorState>();
static BuildContext? get context => key.currentContext;
}

View File

@@ -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<bool> 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<bool>(
fn: () async {
await ensureKnownHostKey(
spi,
onKeyboardInteractive: (_) => KeybordInteractive.defaultHandle(spi, ctx: context),
);
return true;
},
);
return error == null && result == true;
}

View File

@@ -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<SSHClient> genClient(
/// Handle keyboard-interactive authentication
SSHUserInfoRequestHandler? onKeyboardInteractive,
Map<String, String>? knownHostFingerprints,
void Function(String storageKey, String fingerprintHex)? onHostKeyAccepted,
Future<bool> Function(HostKeyPromptInfo info)? onHostKeyPrompt,
}) async {
onStatus?.call(GenSSHClientStatus.socket);
final hostKeyCache = Map<String, String>.from(knownHostFingerprints ?? _loadKnownHostFingerprints());
final hostKeyPersist = onHostKeyAccepted ?? _persistHostKeyFingerprint;
final hostKeyPrompt = onHostKeyPrompt ?? _defaultHostKeyPrompt;
String? alterUser;
final socket = await () async {
@@ -66,7 +77,14 @@ Future<SSHClient> 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<SSHClient> 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<SSHClient> 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<SSHClient> 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<String, String> cache,
required this.prompt,
this.persistCallback,
}) : _cache = cache;
final Spi spi;
final Map<String, String> _cache;
final _HostKeyPersistCallback? persistCallback;
final Future<bool> Function(HostKeyPromptInfo info) prompt;
Future<bool> 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<String, String> _loadKnownHostFingerprints() {
try {
final prop = Stores.setting.sshKnownHostFingerprints;
return Map<String, String>.from(prop.get());
} catch (e, stack) {
Loggers.app.warning('Load SSH host key fingerprints failed', e, stack);
return <String, String>{};
}
}
void _persistHostKeyFingerprint(String storageKey, String fingerprintHex) {
try {
final prop = Stores.setting.sshKnownHostFingerprints;
final updated = Map<String, String>.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<bool> _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<bool>(
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<void> 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<String, String> 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);

View File

@@ -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<Map<String, String>>(
'sshKnownHostFingerprints',
const {},
fromObj: (raw) {
if (raw is Map) {
return raw.map((key, value) => MapEntry(key.toString(), value.toString()));
}
return <String, String>{};
},
);
// Editor theme
late final editorTheme = propertyDefault('editorTheme', Defaults.editorTheme);

View File

@@ -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:

View File

@@ -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?';

View File

@@ -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?';

View File

@@ -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?';

View File

@@ -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 ?';

View File

@@ -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?';

View File

@@ -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設定ファイルを手動で選択しますか';

View File

@@ -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?';

View File

@@ -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?';

View File

@@ -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?';

View File

@@ -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?';

View File

@@ -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?';

View File

@@ -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 設定檔案?';

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -171,6 +171,7 @@
"port": "Port",
"preferDiskAmount": "Prioriser laffichage 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.",

View File

@@ -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.",

View File

@@ -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設定ファイルにアクセスできません。",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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 配置文件。",

View File

@@ -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 設定檔案。",

View File

@@ -41,11 +41,7 @@ class _ConnectionStatsPageState extends State<ConnectionStatsPage> {
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,15 +71,11 @@ class _ConnectionStatsPageState extends State<ConnectionStatsPage> {
}
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(
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -93,10 +85,7 @@ class _ConnectionStatsPageState extends State<ConnectionStatsPage> {
Expanded(
child: Text(
stats.serverName,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
Text(
@@ -117,23 +106,14 @@ class _ConnectionStatsPageState extends State<ConnectionStatsPage> {
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildStatItem(
l10n.totalAttempts,
stats.totalAttempts.toString(),
Icons.all_inclusive,
),
_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,
),
_buildStatItem(libL10n.fail, stats.failureCount.toString(), Icons.error, Colors.red),
],
),
if (lastSuccessTime != null || lastFailureTime != null) ...[
@@ -141,74 +121,40 @@ class _ConnectionStatsPageState extends State<ConnectionStatsPage> {
const Divider(),
const SizedBox(height: 8),
if (lastSuccessTime != null)
_buildTimeItem(
l10n.lastSuccess,
lastSuccessTime,
Icons.check_circle,
Colors.green,
),
_buildTimeItem(l10n.lastSuccess, lastSuccessTime, Icons.check_circle, Colors.green),
if (lastFailureTime != null)
_buildTimeItem(
l10n.lastFailure,
lastFailureTime,
Icons.error,
Colors.red,
),
_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),
),
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<ConnectionStatsPage> {
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<ConnectionStatsPage> {
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)),
),
],
);

View File

@@ -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<void> _editRawSettings() async {
final rawMap = Stores.setting.getAllMap(includeInternalKeys: true);
final map = Map<String, Object?>.from(rawMap);
final initialKeys = Set<String>.from(map.keys);
Map<String, Object?> mapForEditor = map;
String? encryptedKey;
String? passwordUsed;
Future<String?> 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<String>(
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<String, dynamic>) {
mapForEditor = Map<String, Object?>.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<String, dynamic>;
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(),
),
);
}
}

View File

@@ -83,10 +83,10 @@ class _HomeTabsConfigPageState extends ConsumerState<HomeTabsConfigPage> {
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,
);
}

View File

@@ -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<void> _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<String, dynamic>;
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),

View File

@@ -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);
}

View File

@@ -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';
@@ -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));