mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2025-12-17 07:14:28 +01:00
feat: prompt user on host key verification (#943)
This commit is contained in:
13
.github/workflows/analysis.yml
vendored
13
.github/workflows/analysis.yml
vendored
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
8
lib/core/app_navigator.dart
Normal file
8
lib/core/app_navigator.dart
Normal 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;
|
||||
}
|
||||
26
lib/core/utils/host_key_helper.dart
Normal file
26
lib/core/utils/host_key_helper.dart
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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?';
|
||||
|
||||
@@ -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?';
|
||||
|
||||
@@ -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?';
|
||||
|
||||
@@ -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 ?';
|
||||
|
||||
@@ -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?';
|
||||
|
||||
@@ -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設定ファイルを手動で選択しますか?';
|
||||
|
||||
|
||||
@@ -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?';
|
||||
|
||||
@@ -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?';
|
||||
|
||||
@@ -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?';
|
||||
|
||||
@@ -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?';
|
||||
|
||||
@@ -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?';
|
||||
|
||||
@@ -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 設定檔案?';
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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設定ファイルにアクセスできません。",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 配置文件。",
|
||||
|
||||
@@ -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 設定檔案。",
|
||||
|
||||
@@ -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,140 +71,90 @@ 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(
|
||||
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<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)),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user