Compare commits

..

15 Commits

Author SHA1 Message Date
lollipopkit🏳️‍⚧️
c4c0fdf6ff add: cloudflared/Cloudflare Tunnel support
Fixes #949
2025-11-22 17:16:52 +08:00
lollipopkit🏳️‍⚧️
84921de7a7 add: store exes & presets 2025-11-01 23:47:37 +08:00
lollipopkit🏳️‍⚧️
12c8543352 add: cloudflared/Cloudflare Tunnel support
Fixes #949
2025-10-31 00:30:58 +08:00
lollipopkit🏳️‍⚧️
92a4601335 opt.: disable it on iOS 2025-10-25 20:37:14 +08:00
lollipopkit🏳️‍⚧️
b6ab8f1db5 feat: proxy cmd support
Fixes #949
2025-10-25 20:35:47 +08:00
Korb
ffda27d057 add: fdroid Russian metadata translation (#947)
* Create ru/short_description.txt

* Create ru/full_description.txt
2025-10-23 02:24:04 +08:00
lollipopkit🏳️‍⚧️
c548b4ef48 fix: container parsing (#948) 2025-10-23 02:21:14 +08:00
lollipopkit🏳️‍⚧️
70040c5840 bump: v1270 2025-10-20 09:32:07 +08:00
lollipopkit🏳️‍⚧️
5272324be6 feat: prompt user on host key verification (#943) 2025-10-20 09:31:20 +08:00
lollipopkit🏳️‍⚧️
8cbb48ed67 feat: support windows clipboard shortcuts (#941)
Fixes #902
2025-10-20 00:56:33 +08:00
lollipopkit🏳️‍⚧️
03720fa322 Merge branch 'main' of github.com:lollipopkit/flutter_server_box 2025-10-20 00:35:07 +08:00
lollipopkit🏳️‍⚧️
0b51719070 fix: synthesize hardware backspace repeat (#940) 2025-10-20 00:34:52 +08:00
lollipopkit🏳️‍⚧️
a84231393d opt.: ask ai hint 2025-10-19 23:38:08 +08:00
lollipopkit🏳️‍⚧️
d6c2cafce7 opt.: ssh disconnection helper (#937) 2025-10-19 13:40:17 +08:00
lollipopkit🏳️‍⚧️
729b76177e feat: ask ai (#936)
* feat: ask ai in ssh terminal
Fixes #934

* new(ask_ai): settings

* fix: app hot reload

* new: l10n

* chore: deps.

* opt.
2025-10-18 01:15:43 +08:00
80 changed files with 5234 additions and 724 deletions

View File

@@ -23,20 +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
.dart_tool/package_config.json
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

143
LICENSE
View File

@@ -1,5 +1,5 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
@@ -7,17 +7,15 @@
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
@@ -72,7 +60,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
GNU Affero General Public License for more details.
You should have received a copy of the GNU General Public License
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View File

@@ -5,7 +5,7 @@ English | [简体中文](README_zh.md)
<div align="center">
<a href="https://cdn.lpkt.cn/donate"><img alt="donate" src="https://img.shields.io/badge/donate-me-pink"></a>
<img alt="lang" src="https://img.shields.io/badge/lang-dart-cyan">
<img alt="license" src="https://img.shields.io/badge/license-GPLv3-yellow">
<img alt="license" src="https://img.shields.io/badge/license-AGPLv3-yellow">
<a href="https://deepwiki.com/lollipopkit/flutter_server_box"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
</div>
@@ -85,4 +85,4 @@ If I forgot to add your name to the contributors list, please add a comment in t
## 📝 License
`GPL v3 lollipopkit`
`AGPL v3 lollipopkit & all contributors`

View File

@@ -5,7 +5,7 @@
<div align="center">
<a href="https://cdn.lpkt.cn/donate"><img alt="donate" src="https://img.shields.io/badge/捐赠-我-pink"></a>
<img alt="语言" src="https://img.shields.io/badge/语言-dart-cyan">
<img alt="license" src="https://img.shields.io/badge/证书-GPLv3-yellow">
<img alt="license" src="https://img.shields.io/badge/证书-AGPLv3-yellow">
<a href="https://deepwiki.com/lollipopkit/flutter_server_box"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
</div>
@@ -86,4 +86,4 @@ Linux / Windows | [GitHub](https://github.com/lollipopkit/flutter_server_box/rel
## 📝 协议
`GPL v3 lollipopkit`
`AGPL v3 lollipopkit & 所有贡献者`

View File

@@ -0,0 +1,7 @@
Проект на базе Flutter, предоставляющий диаграммы состояний серверов под Linux, Unix и Windows и инструменты для управления ими.
Особая благодарность dartssh2 и xterm.dart.
* Диаграмма состояния (ЦП, датчики, видеокарта…), SSH Term, SFTP, Docker, пакеты, процессы…
* Платформозависимые: биометрическая аутентификация, push-уведомления, виджет, приложение для watchOS…
* Многоязычная поддержка: English, 简体中文; Deutsch, 繁體中文, Indonesian, Français, Dutch; Español, Русский язык, Português, 日本語

View File

@@ -0,0 +1 @@
Приложение для мониторинга серверов и набор инструментов управления ими

View File

@@ -748,7 +748,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 1262;
CURRENT_PROJECT_VERSION = 1270;
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
@@ -758,7 +758,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1262;
MARKETING_VERSION = 1.0.1270;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -884,7 +884,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 1262;
CURRENT_PROJECT_VERSION = 1270;
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
@@ -894,7 +894,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1262;
MARKETING_VERSION = 1.0.1270;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -912,7 +912,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 1262;
CURRENT_PROJECT_VERSION = 1270;
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
@@ -922,7 +922,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1262;
MARKETING_VERSION = 1.0.1270;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -943,7 +943,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1262;
CURRENT_PROJECT_VERSION = 1270;
DEVELOPMENT_TEAM = BA88US33G6;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
@@ -956,7 +956,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.1262;
MARKETING_VERSION = 1.0.1270;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
@@ -982,7 +982,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1262;
CURRENT_PROJECT_VERSION = 1270;
DEVELOPMENT_TEAM = BA88US33G6;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
@@ -995,7 +995,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.1262;
MARKETING_VERSION = 1.0.1270;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -1018,7 +1018,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1262;
CURRENT_PROJECT_VERSION = 1270;
DEVELOPMENT_TEAM = BA88US33G6;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
@@ -1031,7 +1031,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.1262;
MARKETING_VERSION = 1.0.1270;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -1054,7 +1054,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1262;
CURRENT_PROJECT_VERSION = 1270;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_PREVIEWS = YES;
@@ -1066,7 +1066,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1262;
MARKETING_VERSION = 1.0.1270;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
@@ -1095,7 +1095,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1262;
CURRENT_PROJECT_VERSION = 1270;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_PREVIEWS = YES;
@@ -1107,7 +1107,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1262;
MARKETING_VERSION = 1.0.1270;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
PRODUCT_NAME = ServerBox;
@@ -1133,7 +1133,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1262;
CURRENT_PROJECT_VERSION = 1270;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_PREVIEWS = YES;
@@ -1145,7 +1145,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1262;
MARKETING_VERSION = 1.0.1270;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
PRODUCT_NAME = ServerBox;

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';
@@ -11,9 +12,16 @@ import 'package:server_box/view/page/home.dart';
part 'intro.dart';
class MyApp extends StatelessWidget {
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late final Future<List<IntroPageBuilder>> _introFuture = _IntroPage.builders;
@override
Widget build(BuildContext context) {
_setup(context);
@@ -80,6 +88,7 @@ class MyApp extends StatelessWidget {
return MaterialApp(
key: ValueKey(locale),
navigatorKey: AppNavigator.key,
builder: ResponsivePoints.builder,
locale: locale,
localizationsDelegates: const [LibLocalizations.delegate, ...AppLocalizations.localizationsDelegates],
@@ -91,7 +100,7 @@ class MyApp extends StatelessWidget {
theme: light.fixWindowsFont,
darkTheme: (tMode < 3 ? dark : dark.toAmoled).fixWindowsFont,
home: FutureBuilder<List<IntroPageBuilder>>(
future: _IntroPage.builders,
future: _introFuture,
builder: (context, snapshot) {
context.setLibL10n();
final appL10n = AppLocalizations.of(context);

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,303 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:fl_lib/fl_lib.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:server_box/data/store/setting.dart';
/// Exception thrown when executable management fails
class ExecutableException implements Exception {
final String message;
ExecutableException(this.message);
@override
String toString() => 'ExecutableException: $message';
}
/// Information about an executable
class ExecutableInfo {
final String name;
final String? spokenName;
final String? version;
ExecutableInfo({required this.name, this.version, this.spokenName});
}
/// Generic executable manager for downloading and managing external tools
abstract final class ExecutableManager {
static const String _executablesDirName = 'executables';
static const int _posixExecuteBitsMask = 0x49; // Equivalent to POSIX octal 0o111
static late final Directory _executablesDir;
static final Map<String, ExecutableInfo> _customExecutables = {};
static bool _customExecutablesLoaded = false;
static Future<void> initialize() async {
final appDir = await getApplicationSupportDirectory();
_executablesDir = Directory(path.join(appDir.path, _executablesDirName));
if (!await _executablesDir.exists()) {
await _executablesDir.create(recursive: true);
}
_ensureCustomExecutablesLoaded();
}
/// Predefined executables
static final Map<String, ExecutableInfo> _predefinedExecutables = {
'cloudflared': ExecutableInfo(name: 'cloudflared'),
'ssh': ExecutableInfo(name: 'ssh'),
'nc': ExecutableInfo(name: 'nc'),
'socat': ExecutableInfo(name: 'socat'),
};
static void _ensureCustomExecutablesLoaded() {
if (_customExecutablesLoaded) return;
final List<dynamic> stored = SettingStore.instance.proxyCmdCustomExecs.get();
for (final raw in stored) {
final info = _parseExecutableInfo(raw);
if (info == null) continue;
_customExecutables[info.name] = info;
_predefinedExecutables[info.name] = info;
}
_customExecutablesLoaded = true;
}
static void _persistCustomExecutables() {
final values = _customExecutables.values
.map((info) => {
'name': info.name,
if (info.spokenName != null) 'spokenName': info.spokenName,
if (info.version != null) 'version': info.version,
})
.toList();
SettingStore.instance.proxyCmdCustomExecs.set(values);
}
static ExecutableInfo? _parseExecutableInfo(dynamic raw) {
if (raw is String) {
try {
return _parseExecutableInfo(jsonDecode(raw));
} catch (e) {
Loggers.app.warning('Failed to decode custom executable entry: $e');
return null;
}
}
if (raw is! Map) return null;
final name = raw['name']?.toString();
if (name == null || name.isEmpty) return null;
return ExecutableInfo(
name: name,
spokenName: raw['spokenName']?.toString(),
version: raw['version']?.toString(),
);
}
/// Check if an executable exists in PATH or local directory
static Future<bool> isExecutableAvailable(String name) async {
// First check if it's in PATH
final pathExecutable = await _lookupExecutableInSystemPath(name);
if (pathExecutable != null) {
return true;
}
// Check local executables directory
final localExecutable = _getLocalExecutablePath(name);
if (await localExecutable.exists()) {
return true;
}
return false;
}
/// Get the path to an executable (either in PATH or local)
static Future<String> getExecutablePath(String name) async {
// First check if it's in PATH
final pathExecutable = await _lookupExecutableInSystemPath(name);
if (pathExecutable != null) {
return pathExecutable;
}
// Check local executables directory
final localExecutable = _getLocalExecutablePath(name);
if (await localExecutable.exists()) {
return localExecutable.path;
}
throw ExecutableException('Executable $name not found in PATH or local directory');
}
/// Download an executable if it's not available
static Future<String> ensureExecutable(String name) async {
if (await isExecutableAvailable(name)) {
return await getExecutablePath(name);
}
throw ExecutableException('Executable "$name" not found and automatic installation is not implemented');
}
/// Remove a local executable
static Future<void> removeExecutable(String name) async {
final localExecutable = _getLocalExecutablePath(name);
if (await localExecutable.exists()) {
await localExecutable.delete();
Loggers.app.info('Removed local executable: $name');
}
}
/// List all locally downloaded executables
static Future<List<String>> listLocalExecutables() async {
if (!await _executablesDir.exists()) {
return [];
}
final executables = <String>[];
await for (final entity in _executablesDir.list()) {
if (entity is File && _isExecutable(entity)) {
executables.add(path.basenameWithoutExtension(entity.path));
}
}
return executables;
}
/// Get the size of a local executable
static Future<int> getExecutableSize(String name) async {
final localExecutable = _getLocalExecutablePath(name);
if (await localExecutable.exists()) {
return await localExecutable.length();
}
return 0;
}
/// Get the version of an executable
static Future<String?> getExecutableVersion(String name) async {
try {
final executablePath = await getExecutablePath(name);
// Try common version flags
final versionFlags = ['--version', '-v', '-V', 'version'];
for (final flag in versionFlags) {
try {
final result = await Process.run(executablePath, [flag]);
if (result.exitCode == 0) {
final output = result.stdout.toString().trim();
if (output.isNotEmpty) {
return output.split('\n').first; // Return first line only
}
}
} catch (e) {
// Try next flag
}
}
} catch (e) {
Loggers.app.warning('Error getting version for $name: $e');
}
return null;
}
/// Validate an executable by trying to run it with a help flag
static Future<bool> validateExecutable(String name) async {
try {
final executablePath = await getExecutablePath(name);
// Try to run the executable with a help flag
final helpFlags = ['--help', '-h', '-help'];
for (final flag in helpFlags) {
try {
final result = await Process.run(executablePath, [flag]);
if (result.exitCode == 0 || result.exitCode == 1) {
// Help often returns 1
return true;
}
} catch (e) {
// Try next flag
}
}
} catch (e) {
Loggers.app.warning('Error validating $name: $e');
return false;
}
return false;
}
static Future<String?> _lookupExecutableInSystemPath(String name) async {
final command = Platform.isWindows ? 'where' : 'which';
try {
final result = await Process.run(command, [name]);
if (result.exitCode != 0) {
return null;
}
final stdoutString = result.stdout.toString().trim();
if (stdoutString.isEmpty) {
return null;
}
final candidate = stdoutString
.split('\n')
.map((line) => line.trim())
.firstWhere((line) => line.isNotEmpty, orElse: () => '');
if (candidate.isEmpty) {
return null;
}
return candidate;
} catch (e) {
Loggers.app.warning('Error checking PATH for $name: $e');
return null;
}
}
/// Get the local path for an executable
static File _getLocalExecutablePath(String name) {
final extension = Platform.isWindows ? '.exe' : '';
return File(path.join(_executablesDir.path, '$name$extension'));
}
/// Check if a file is executable
static bool _isExecutable(File file) {
if (Platform.isWindows) {
return file.path.endsWith('.exe');
} else {
// Check file permissions
final stat = file.statSync();
return (stat.mode & _posixExecuteBitsMask) != 0; // Check execute bits
}
}
/// Get predefined executable info
static ExecutableInfo? getExecutableInfo(String name) {
_ensureCustomExecutablesLoaded();
return _predefinedExecutables[name];
}
/// Add a custom executable definition
static void addCustomExecutable(String name, ExecutableInfo info) {
_ensureCustomExecutablesLoaded();
_customExecutables[name] = info;
_predefinedExecutables[name] = info;
_persistCustomExecutables();
Loggers.app.info('Adding custom executable: $name');
}
/// Remove a custom executable definition
static void removeCustomExecutable(String name) {
_ensureCustomExecutablesLoaded();
final removed = _customExecutables.remove(name);
if (removed != null) {
_predefinedExecutables.remove(name);
_persistCustomExecutables();
Loggers.app.info('Removing custom executable: $name');
}
}
}

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

@@ -0,0 +1,300 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:dartssh2/dartssh2.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/core/utils/executable_manager.dart';
import 'package:server_box/core/utils/proxy_socket.dart';
import 'package:server_box/data/model/server/proxy_command_config.dart';
import 'package:server_box/data/store/setting.dart';
/// Exception thrown when proxy command execution fails
class ProxyCommandException implements Exception {
final String message;
final int? exitCode;
final String? stdout;
final String? stderr;
ProxyCommandException({required this.message, this.exitCode, this.stdout, this.stderr});
@override
String toString() {
return 'ProxyCommandException: $message'
'${exitCode != null ? ' (exit code: $exitCode)' : ''}'
'${stderr != null ? '\nStderr: $stderr' : ''}';
}
}
/// Generic proxy command executor that handles SSH ProxyCommand functionality
abstract final class ProxyCommandExecutor {
static final Map<String, ProxyCommandConfig> _customPresets = {};
static bool _customPresetsLoaded = false;
static void _ensureCustomPresetsLoaded() {
if (_customPresetsLoaded) return;
final List<dynamic> stored = SettingStore.instance.proxyCmdCustomPresets.get();
for (final raw in stored) {
final preset = _parsePreset(raw);
if (preset == null) continue;
_customPresets[preset.key] = preset.value;
}
_customPresetsLoaded = true;
}
static void _persistCustomPresets() {
final list = _customPresets.entries
.map((entry) => {
'name': entry.key,
'config': entry.value.toJson(),
})
.toList();
SettingStore.instance.proxyCmdCustomPresets.set(list);
}
static MapEntry<String, ProxyCommandConfig>? _parsePreset(dynamic raw) {
dynamic payload = raw;
if (payload is String) {
try {
payload = jsonDecode(payload);
} catch (e) {
Loggers.app.warning('Failed to decode custom proxy preset entry: $e');
return null;
}
}
if (payload is! Map) return null;
final name = payload['name']?.toString();
final configRaw = payload['config'];
if (name == null || name.isEmpty || configRaw is! Map) return null;
try {
final config = ProxyCommandConfig.fromJson(Map<String, dynamic>.from(configRaw));
return MapEntry(name, config);
} catch (e) {
Loggers.app.warning('Failed to parse custom proxy preset "$name": $e');
return null;
}
}
/// Execute a proxy command and return a socket connected through the proxy
static Future<SSHSocket> executeProxyCommand(
ProxyCommandConfig config, {
required String hostname,
required int port,
required String user,
}) async {
if (Platform.isIOS) {
throw ProxyCommandException(message: 'ProxyCommand is not supported on iOS');
}
final finalCommand = config.getFinalCommand(hostname: hostname, port: port, user: user);
final tokens = _tokenizeCommand(finalCommand);
if (tokens.isEmpty) {
throw ProxyCommandException(message: 'ProxyCommand resolved to an empty command');
}
final executableToken = tokens.first;
Loggers.app.info('Executing proxy command: $finalCommand');
// Ensure executable is available if required
String executablePath;
if (config.requiresExecutable && config.executableName != null) {
executablePath = await ExecutableManager.ensureExecutable(config.executableName!);
} else {
executablePath = await ExecutableManager.getExecutablePath(executableToken);
}
// Parse command and arguments
final args = tokens.skip(1).toList();
// Set up environment
final environment = {...Platform.environment, ...?config.environment};
// Start the process
Process process;
try {
process = await Process.start(
executablePath,
args,
workingDirectory: config.workingDirectory,
environment: environment,
);
} catch (e) {
throw ProxyCommandException(message: 'Failed to start proxy command: $e', exitCode: -1);
}
// Set up timeout handling
var timedOut = false;
final timeoutTimer = Timer(config.timeout, () {
timedOut = true;
process.kill();
});
try {
// For ProxyCommand, we create a ProxySocket that wraps the process
final socket = ProxySocket(process);
// Monitor the process for immediate failures
unawaited(
process.exitCode.then((code) {
if (code != 0 && !socket.closed && !timedOut) {
socket.close();
}
}),
);
return socket;
} catch (e) {
process.kill();
rethrow;
} finally {
timeoutTimer.cancel();
}
}
/// Validate proxy command configuration
static Future<String?> validateConfig(ProxyCommandConfig config) async {
if (Platform.isIOS) {
return 'ProxyCommand is not supported on iOS';
}
final testCommand = config.getFinalCommand(hostname: 'test.example.com', port: 22, user: 'testuser');
late final List<String> tokens;
try {
tokens = _tokenizeCommand(testCommand);
} on ProxyCommandException catch (e) {
return e.message;
}
if (tokens.isEmpty) {
return 'Proxy command must not be empty';
}
// Check if required placeholders are present
if (!config.command.contains('%h')) {
return 'Proxy command must contain %h (hostname) placeholder';
}
String executablePath;
// If executable is required, check if it exists and reuse resolved path
if (config.requiresExecutable && config.executableName != null) {
try {
executablePath = await ExecutableManager.ensureExecutable(config.executableName!);
} catch (e) {
return e.toString();
}
} else {
try {
executablePath = await ExecutableManager.getExecutablePath(tokens.first);
} catch (e) {
return e.toString();
}
}
// Try to validate command syntax (dry run)
try {
await Process.run(executablePath, ['--help']);
} catch (e) {
return 'Command validation failed: $e';
}
return null; // No error
}
/// Get available proxy command presets
static Map<String, ProxyCommandConfig> getPresets() {
_ensureCustomPresetsLoaded();
return {
...proxyCommandPresets,
..._customPresets,
};
}
/// Add a custom preset
static Future<void> addCustomPreset(String name, ProxyCommandConfig config) async {
_ensureCustomPresetsLoaded();
_customPresets[name] = config;
_persistCustomPresets();
Loggers.app.info('Adding custom proxy preset: $name');
}
/// Remove a custom preset
static Future<void> removeCustomPreset(String name) async {
_ensureCustomPresetsLoaded();
final removed = _customPresets.remove(name);
if (removed != null) {
_persistCustomPresets();
Loggers.app.info('Removing custom proxy preset: $name');
}
}
static List<String> tokenizeCommand(String command) => _tokenizeCommand(command);
static List<String> _tokenizeCommand(String command) {
final tokens = <String>[];
final buffer = StringBuffer();
String? quote;
var escaped = false;
void flush() {
if (buffer.isEmpty) return;
tokens.add(buffer.toString());
buffer.clear();
}
for (final rune in command.runes) {
final char = String.fromCharCode(rune);
if (escaped) {
buffer.write(char);
escaped = false;
continue;
}
if (quote != null) {
if (char == '\\' && quote == '"') {
escaped = true;
continue;
}
if (char == quote) {
quote = null;
continue;
}
buffer.write(char);
continue;
}
if (char == '\\') {
escaped = true;
continue;
}
if (char == '"' || char == "'") {
quote = char;
continue;
}
if (char.trim().isEmpty) {
flush();
continue;
}
buffer.write(char);
}
if (quote != null) {
throw ProxyCommandException(message: 'ProxyCommand has unmatched quote');
}
if (escaped) {
throw ProxyCommandException(message: 'ProxyCommand ends with an incomplete escape sequence');
}
flush();
return tokens;
}
}

View File

@@ -0,0 +1,125 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:dartssh2/dartssh2.dart';
import 'package:fl_lib/fl_lib.dart';
/// Socket implementation that communicates through a Process stdin/stdout
/// This is used for ProxyCommand functionality where the SSH connection
/// is proxied through an external command
class ProxySocket implements SSHSocket {
final Process _process;
final StreamController<Uint8List> _incomingController =
StreamController<Uint8List>();
final StreamController<List<int>> _outgoingController =
StreamController<List<int>>();
final Completer<void> _doneCompleter = Completer<void>();
bool _closed = false;
late StreamSubscription<Uint8List> _stdoutSubscription;
late StreamSubscription<Uint8List> _stderrSubscription;
ProxySocket(this._process) {
// Set up stdout reading
_stdoutSubscription = _process.stdout
.transform(Uint8ListStreamTransformer())
.listen(_onIncomingData,
onError: _onError,
onDone: _onProcessDone,
cancelOnError: true);
// Set up stderr reading (for logging)
_stderrSubscription = _process.stderr
.transform(Uint8ListStreamTransformer())
.listen((data) {
Loggers.app.warning('Proxy stderr: ${String.fromCharCodes(data)}');
});
// Set up outgoing data
_outgoingController.stream.listen(_onOutgoingData);
// Handle process exit
_process.exitCode.then((code) {
if (!_closed && code != 0) {
_onError('Proxy process exited with code: $code');
}
});
}
@override
Stream<Uint8List> get stream => _incomingController.stream;
@override
StreamSink<List<int>> get sink => _outgoingController.sink;
@override
Future<void> get done => _doneCompleter.future;
/// Check if the socket is closed
bool get closed => _closed;
@override
Future<void> close() async {
if (_closed) return;
_closed = true;
await _stdoutSubscription.cancel();
await _stderrSubscription.cancel();
await _outgoingController.close();
await _incomingController.close();
if (!_doneCompleter.isCompleted) {
_doneCompleter.complete();
}
// Kill the process if it's still running
try {
_process.kill();
} catch (e) {
Loggers.app.warning('Error killing proxy process: $e');
}
}
@override
void destroy() {
close();
}
void _onIncomingData(Uint8List data) {
if (!_closed) {
_incomingController.add(data);
}
}
void _onOutgoingData(List<int> data) {
if (!_closed) {
_process.stdin.add(data);
}
}
void _onError(dynamic error, [StackTrace? stackTrace]) {
if (!_closed) {
_incomingController.addError(error, stackTrace);
close();
}
}
void _onProcessDone() {
if (!_closed) {
_incomingController.close();
close();
}
}
}
/// Transformer to convert `Stream<List<int>>` to `Stream<Uint8List>`
class Uint8ListStreamTransformer
extends StreamTransformerBase<List<int>, Uint8List> {
const Uint8ListStreamTransformer();
@override
Stream<Uint8List> bind(Stream<List<int>> stream) {
return stream.map((data) => Uint8List.fromList(data));
}
}

View File

@@ -1,8 +1,14 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
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/core/utils/proxy_command_executor.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 +35,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,13 +58,19 @@ 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 {
// Proxy
// Check for Jump Server first - this needs special handling
final jumpSpi_ = () {
// Multi-thread or key login
if (jumpSpi != null) return jumpSpi;
@@ -66,27 +78,102 @@ 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);
// For jump server, we establish connection through the jump client
final jumpClient = await genClient(
jumpSpi_,
privateKey: jumpPrivateKey,
timeout: timeout,
knownHostFingerprints: hostKeyCache,
onHostKeyAccepted: hostKeyPersist,
onHostKeyPrompt: onHostKeyPrompt,
);
return await jumpClient.forwardLocal(spi.ip, spi.port);
}
final forwardChannel = await jumpClient.forwardLocal(spi.ip, spi.port);
// Direct
try {
return await SSHSocket.connect(spi.ip, spi.port, timeout: timeout);
} catch (e) {
Loggers.app.warning('genClient', e);
if (spi.alterUrl == null) rethrow;
try {
final res = spi.parseAlterUrl();
alterUser = res.$2;
return await SSHSocket.connect(res.$1, res.$3, timeout: timeout);
} catch (e) {
Loggers.app.warning('genClient alterUrl', e);
rethrow;
final hostKeyVerifier = _HostKeyVerifier(
spi: spi,
cache: hostKeyCache,
persistCallback: hostKeyPersist,
prompt: hostKeyPrompt,
);
final keyId = spi.keyId;
if (keyId == null) {
onStatus?.call(GenSSHClientStatus.pwd);
return SSHClient(
forwardChannel,
username: spi.user,
onPasswordRequest: () => spi.pwd,
onUserInfoRequest: onKeyboardInteractive,
onVerifyHostKey: hostKeyVerifier.call,
);
}
privateKey ??= getPrivateKey(keyId);
onStatus?.call(GenSSHClientStatus.key);
return SSHClient(
forwardChannel,
username: spi.user,
identities: await compute(loadIndentity, privateKey),
onUserInfoRequest: onKeyboardInteractive,
onVerifyHostKey: hostKeyVerifier.call,
);
}
}();
// For ProxyCommand and direct connections, get SSHSocket
SSHSocket? socket;
try {
final proxyCommand = spi.proxyCommand;
// ProxyCommand support - Check for ProxyCommand configuration first
if (proxyCommand != null && !Platform.isIOS) {
try {
Loggers.app.info('Connecting via ProxyCommand: ${proxyCommand.command}');
socket = await ProxyCommandExecutor.executeProxyCommand(
proxyCommand,
hostname: spi.ip,
port: spi.port,
user: spi.user,
);
} catch (e) {
Loggers.app.warning('ProxyCommand failed', e);
if (!proxyCommand.retryOnFailure) {
rethrow;
}
// If retry is enabled, fall through to direct connection
Loggers.app.info('ProxyCommand failed, falling back to direct connection');
}
} else if (proxyCommand != null && Platform.isIOS) {
Loggers.app.info('ProxyCommand configuration is ignored on iOS');
}
// Direct connection (or fallback)
socket ??= await () async {
try {
return await SSHSocket.connect(spi.ip, spi.port, timeout: timeout);
} catch (e) {
Loggers.app.warning('genClient', e);
if (spi.alterUrl == null) rethrow;
try {
final res = spi.parseAlterUrl();
alterUser = res.$2;
return await SSHSocket.connect(res.$1, res.$3, timeout: timeout);
} catch (e) {
Loggers.app.warning('genClient alterUrl', e);
rethrow;
}
}
}();
} catch (e) {
Loggers.app.warning('Failed to establish connection', e);
rethrow;
}
final hostKeyVerifier = _HostKeyVerifier(
spi: spi,
cache: hostKeyCache,
persistCallback: hostKeyPersist,
prompt: hostKeyPrompt,
);
final keyId = spi.keyId;
if (keyId == null) {
@@ -96,8 +183,7 @@ Future<SSHClient> genClient(
username: alterUser ?? spi.user,
onPasswordRequest: () => spi.pwd,
onUserInfoRequest: onKeyboardInteractive,
// printDebug: debugPrint,
// printTrace: debugPrint,
onVerifyHostKey: hostKeyVerifier.call,
);
}
privateKey ??= getPrivateKey(keyId);
@@ -106,10 +192,220 @@ Future<SSHClient> genClient(
return SSHClient(
socket,
username: spi.user,
// Must use [compute] here, instead of [Computer.shared.start]
identities: await compute(loadIndentity, privateKey),
onUserInfoRequest: onKeyboardInteractive,
// printDebug: debugPrint,
// printTrace: debugPrint,
onVerifyHostKey: hostKeyVerifier.call,
);
}
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

@@ -149,11 +149,28 @@ abstract final class SSHConfig {
/// Extract jump host from ProxyJump or ProxyCommand
static String? _extractJumpHost(String value) {
// For ProxyJump, the format is usually: user@host:port
// For ProxyCommand, it's more complex and might need custom parsing
if (value.contains('@')) {
return value.split(' ').first;
// Normalize whitespace
final parts = value.trim().split(RegExp(r'\s+'));
// Try to find a token that looks like a user@host[:port]
// This covers common patterns like:
// - ProxyJump user@host
// - ProxyCommand ssh -W %h:%p user@host
for (final token in parts) {
if (token.contains('@')) {
// Strip any surrounding quotes just in case
var cleaned = token;
if ((cleaned.startsWith("'") && cleaned.endsWith("'")) ||
(cleaned.startsWith('"') && cleaned.endsWith('"'))) {
cleaned = cleaned.substring(1, cleaned.length - 1);
}
return cleaned;
}
}
// ProxyJump may also be provided as just a hostname (no user@)
// In that case we don't have enough information to build an oldId-style reference,
// so we ignore it here and let the user configure a jump server manually.
return null;
}

View File

@@ -0,0 +1,74 @@
import 'package:meta/meta.dart';
/// Chat message exchanged with the Ask AI service.
enum AskAiMessageRole { user, assistant }
@immutable
class AskAiMessage {
const AskAiMessage({
required this.role,
required this.content,
});
final AskAiMessageRole role;
final String content;
String get apiRole {
switch (role) {
case AskAiMessageRole.user:
return 'user';
case AskAiMessageRole.assistant:
return 'assistant';
}
}
}
/// Recommended command returned by the AI tool call.
@immutable
class AskAiCommand {
const AskAiCommand({
required this.command,
this.description = '',
this.toolName,
});
final String command;
final String description;
final String? toolName;
}
@immutable
sealed class AskAiEvent {
const AskAiEvent();
}
/// Incremental text delta emitted while streaming the AI response.
class AskAiContentDelta extends AskAiEvent {
const AskAiContentDelta(this.delta);
final String delta;
}
/// Emits when a tool call returns a runnable command suggestion.
class AskAiToolSuggestion extends AskAiEvent {
const AskAiToolSuggestion(this.command);
final AskAiCommand command;
}
/// Signals that the stream finished successfully.
class AskAiCompleted extends AskAiEvent {
const AskAiCompleted({
required this.fullText,
required this.commands,
});
final String fullText;
final List<AskAiCommand> commands;
}
/// Signals that the stream terminated with an error before completion.
class AskAiStreamError extends AskAiEvent {
const AskAiStreamError(this.error, this.stackTrace);
final Object error;
final StackTrace? stackTrace;
}

View File

@@ -37,12 +37,12 @@ final class PodmanImg implements ContainerImg {
String toRawJson() => json.encode(toJson());
factory PodmanImg.fromJson(Map<String, dynamic> json) => PodmanImg(
repository: json['repository'],
tag: json['tag'],
id: json['Id'],
created: json['Created'],
size: json['Size'],
containers: json['Containers'],
repository: _asString(json['repository']),
tag: _asString(json['tag']),
id: _asString(json['Id']),
created: _asInt(json['Created']),
size: _asInt(json['Size']),
containers: _asInt(json['Containers']),
);
Map<String, dynamic> toJson() => {
@@ -119,3 +119,16 @@ final class DockerImg implements ContainerImg {
'Tag': tag,
};
}
String? _asString(dynamic val) {
if (val == null) return null;
if (val is String) return val;
return val.toString();
}
int? _asInt(dynamic val) {
if (val == null) return null;
if (val is int) return val;
if (val is double) return val.toInt();
return int.tryParse(val.toString());
}

View File

@@ -0,0 +1,81 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:server_box/core/utils/proxy_command_executor.dart' show ProxyCommandException;
part 'proxy_command_config.freezed.dart';
part 'proxy_command_config.g.dart';
/// ProxyCommand configuration for SSH connections
@freezed
abstract class ProxyCommandConfig with _$ProxyCommandConfig {
const factory ProxyCommandConfig({
/// Command template with placeholders
/// Available placeholders: %h (hostname), %p (port), %r (user)
required String command,
/// Command arguments (optional, can be included in command)
List<String>? args,
/// Working directory for the command
String? workingDirectory,
/// Environment variables for the command
Map<String, String>? environment,
/// Timeout for command execution
@Default(Duration(seconds: 30)) Duration timeout,
/// Whether to retry on connection failure
@Default(false) bool retryOnFailure,
/// Maximum retry attempts
@Default(3) int maxRetries,
/// Whether the proxy command requires executable download
@Default(false) bool requiresExecutable,
/// Executable name for download management
String? executableName,
/// Executable download URL
String? executableDownloadUrl,
}) = _ProxyCommandConfig;
factory ProxyCommandConfig.fromJson(Map<String, dynamic> json) => _$ProxyCommandConfigFromJson(json);
}
/// Common proxy command presets
const Map<String, ProxyCommandConfig> proxyCommandPresets = {
'cloudflare_access': ProxyCommandConfig(
command: 'cloudflared access ssh --hostname %h',
requiresExecutable: true,
executableName: 'cloudflared',
timeout: Duration(seconds: 15),
),
'ssh_via_bastion': ProxyCommandConfig(
command: 'ssh -W %h:%p bastion.example.com',
timeout: Duration(seconds: 10),
),
'nc_netcat': ProxyCommandConfig(command: 'nc %h %p', timeout: Duration(seconds: 10)),
'socat': ProxyCommandConfig(
command: 'socat - PROXY:%h:%p,proxyport=8080',
timeout: Duration(seconds: 10),
),
};
/// Extension for ProxyCommandConfig to add utility methods
extension ProxyCommandConfigExtension on ProxyCommandConfig {
/// Get the final command with placeholders replaced
String getFinalCommand({required String hostname, required int port, required String user}) {
if (!command.contains('%h') && !command.contains('%p') && !command.contains('%r')) {
throw ProxyCommandException(
message: 'Proxy command "$command" must include at least one placeholder (%h, %p, %r)',
);
}
var finalCommand = command;
finalCommand = finalCommand.replaceAll('%h', hostname);
finalCommand = finalCommand.replaceAll('%p', port.toString());
finalCommand = finalCommand.replaceAll('%r', user);
return finalCommand;
}
}

View File

@@ -0,0 +1,344 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'proxy_command_config.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ProxyCommandConfig {
/// Command template with placeholders
/// Available placeholders: %h (hostname), %p (port), %r (user)
String get command;/// Command arguments (optional, can be included in command)
List<String>? get args;/// Working directory for the command
String? get workingDirectory;/// Environment variables for the command
Map<String, String>? get environment;/// Timeout for command execution
Duration get timeout;/// Whether to retry on connection failure
bool get retryOnFailure;/// Maximum retry attempts
int get maxRetries;/// Whether the proxy command requires executable download
bool get requiresExecutable;/// Executable name for download management
String? get executableName;/// Executable download URL
String? get executableDownloadUrl;
/// Create a copy of ProxyCommandConfig
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ProxyCommandConfigCopyWith<ProxyCommandConfig> get copyWith => _$ProxyCommandConfigCopyWithImpl<ProxyCommandConfig>(this as ProxyCommandConfig, _$identity);
/// Serializes this ProxyCommandConfig to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ProxyCommandConfig&&(identical(other.command, command) || other.command == command)&&const DeepCollectionEquality().equals(other.args, args)&&(identical(other.workingDirectory, workingDirectory) || other.workingDirectory == workingDirectory)&&const DeepCollectionEquality().equals(other.environment, environment)&&(identical(other.timeout, timeout) || other.timeout == timeout)&&(identical(other.retryOnFailure, retryOnFailure) || other.retryOnFailure == retryOnFailure)&&(identical(other.maxRetries, maxRetries) || other.maxRetries == maxRetries)&&(identical(other.requiresExecutable, requiresExecutable) || other.requiresExecutable == requiresExecutable)&&(identical(other.executableName, executableName) || other.executableName == executableName)&&(identical(other.executableDownloadUrl, executableDownloadUrl) || other.executableDownloadUrl == executableDownloadUrl));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,command,const DeepCollectionEquality().hash(args),workingDirectory,const DeepCollectionEquality().hash(environment),timeout,retryOnFailure,maxRetries,requiresExecutable,executableName,executableDownloadUrl);
@override
String toString() {
return 'ProxyCommandConfig(command: $command, args: $args, workingDirectory: $workingDirectory, environment: $environment, timeout: $timeout, retryOnFailure: $retryOnFailure, maxRetries: $maxRetries, requiresExecutable: $requiresExecutable, executableName: $executableName, executableDownloadUrl: $executableDownloadUrl)';
}
}
/// @nodoc
abstract mixin class $ProxyCommandConfigCopyWith<$Res> {
factory $ProxyCommandConfigCopyWith(ProxyCommandConfig value, $Res Function(ProxyCommandConfig) _then) = _$ProxyCommandConfigCopyWithImpl;
@useResult
$Res call({
String command, List<String>? args, String? workingDirectory, Map<String, String>? environment, Duration timeout, bool retryOnFailure, int maxRetries, bool requiresExecutable, String? executableName, String? executableDownloadUrl
});
}
/// @nodoc
class _$ProxyCommandConfigCopyWithImpl<$Res>
implements $ProxyCommandConfigCopyWith<$Res> {
_$ProxyCommandConfigCopyWithImpl(this._self, this._then);
final ProxyCommandConfig _self;
final $Res Function(ProxyCommandConfig) _then;
/// Create a copy of ProxyCommandConfig
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? command = null,Object? args = freezed,Object? workingDirectory = freezed,Object? environment = freezed,Object? timeout = null,Object? retryOnFailure = null,Object? maxRetries = null,Object? requiresExecutable = null,Object? executableName = freezed,Object? executableDownloadUrl = freezed,}) {
return _then(_self.copyWith(
command: null == command ? _self.command : command // ignore: cast_nullable_to_non_nullable
as String,args: freezed == args ? _self.args : args // ignore: cast_nullable_to_non_nullable
as List<String>?,workingDirectory: freezed == workingDirectory ? _self.workingDirectory : workingDirectory // ignore: cast_nullable_to_non_nullable
as String?,environment: freezed == environment ? _self.environment : environment // ignore: cast_nullable_to_non_nullable
as Map<String, String>?,timeout: null == timeout ? _self.timeout : timeout // ignore: cast_nullable_to_non_nullable
as Duration,retryOnFailure: null == retryOnFailure ? _self.retryOnFailure : retryOnFailure // ignore: cast_nullable_to_non_nullable
as bool,maxRetries: null == maxRetries ? _self.maxRetries : maxRetries // ignore: cast_nullable_to_non_nullable
as int,requiresExecutable: null == requiresExecutable ? _self.requiresExecutable : requiresExecutable // ignore: cast_nullable_to_non_nullable
as bool,executableName: freezed == executableName ? _self.executableName : executableName // ignore: cast_nullable_to_non_nullable
as String?,executableDownloadUrl: freezed == executableDownloadUrl ? _self.executableDownloadUrl : executableDownloadUrl // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// Adds pattern-matching-related methods to [ProxyCommandConfig].
extension ProxyCommandConfigPatterns on ProxyCommandConfig {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ProxyCommandConfig value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ProxyCommandConfig() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ProxyCommandConfig value) $default,){
final _that = this;
switch (_that) {
case _ProxyCommandConfig():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ProxyCommandConfig value)? $default,){
final _that = this;
switch (_that) {
case _ProxyCommandConfig() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String command, List<String>? args, String? workingDirectory, Map<String, String>? environment, Duration timeout, bool retryOnFailure, int maxRetries, bool requiresExecutable, String? executableName, String? executableDownloadUrl)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ProxyCommandConfig() when $default != null:
return $default(_that.command,_that.args,_that.workingDirectory,_that.environment,_that.timeout,_that.retryOnFailure,_that.maxRetries,_that.requiresExecutable,_that.executableName,_that.executableDownloadUrl);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String command, List<String>? args, String? workingDirectory, Map<String, String>? environment, Duration timeout, bool retryOnFailure, int maxRetries, bool requiresExecutable, String? executableName, String? executableDownloadUrl) $default,) {final _that = this;
switch (_that) {
case _ProxyCommandConfig():
return $default(_that.command,_that.args,_that.workingDirectory,_that.environment,_that.timeout,_that.retryOnFailure,_that.maxRetries,_that.requiresExecutable,_that.executableName,_that.executableDownloadUrl);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String command, List<String>? args, String? workingDirectory, Map<String, String>? environment, Duration timeout, bool retryOnFailure, int maxRetries, bool requiresExecutable, String? executableName, String? executableDownloadUrl)? $default,) {final _that = this;
switch (_that) {
case _ProxyCommandConfig() when $default != null:
return $default(_that.command,_that.args,_that.workingDirectory,_that.environment,_that.timeout,_that.retryOnFailure,_that.maxRetries,_that.requiresExecutable,_that.executableName,_that.executableDownloadUrl);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _ProxyCommandConfig implements ProxyCommandConfig {
const _ProxyCommandConfig({required this.command, final List<String>? args, this.workingDirectory, final Map<String, String>? environment, this.timeout = const Duration(seconds: 30), this.retryOnFailure = false, this.maxRetries = 3, this.requiresExecutable = false, this.executableName, this.executableDownloadUrl}): _args = args,_environment = environment;
factory _ProxyCommandConfig.fromJson(Map<String, dynamic> json) => _$ProxyCommandConfigFromJson(json);
/// Command template with placeholders
/// Available placeholders: %h (hostname), %p (port), %r (user)
@override final String command;
/// Command arguments (optional, can be included in command)
final List<String>? _args;
/// Command arguments (optional, can be included in command)
@override List<String>? get args {
final value = _args;
if (value == null) return null;
if (_args is EqualUnmodifiableListView) return _args;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(value);
}
/// Working directory for the command
@override final String? workingDirectory;
/// Environment variables for the command
final Map<String, String>? _environment;
/// Environment variables for the command
@override Map<String, String>? get environment {
final value = _environment;
if (value == null) return null;
if (_environment is EqualUnmodifiableMapView) return _environment;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(value);
}
/// Timeout for command execution
@override@JsonKey() final Duration timeout;
/// Whether to retry on connection failure
@override@JsonKey() final bool retryOnFailure;
/// Maximum retry attempts
@override@JsonKey() final int maxRetries;
/// Whether the proxy command requires executable download
@override@JsonKey() final bool requiresExecutable;
/// Executable name for download management
@override final String? executableName;
/// Executable download URL
@override final String? executableDownloadUrl;
/// Create a copy of ProxyCommandConfig
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ProxyCommandConfigCopyWith<_ProxyCommandConfig> get copyWith => __$ProxyCommandConfigCopyWithImpl<_ProxyCommandConfig>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$ProxyCommandConfigToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ProxyCommandConfig&&(identical(other.command, command) || other.command == command)&&const DeepCollectionEquality().equals(other._args, _args)&&(identical(other.workingDirectory, workingDirectory) || other.workingDirectory == workingDirectory)&&const DeepCollectionEquality().equals(other._environment, _environment)&&(identical(other.timeout, timeout) || other.timeout == timeout)&&(identical(other.retryOnFailure, retryOnFailure) || other.retryOnFailure == retryOnFailure)&&(identical(other.maxRetries, maxRetries) || other.maxRetries == maxRetries)&&(identical(other.requiresExecutable, requiresExecutable) || other.requiresExecutable == requiresExecutable)&&(identical(other.executableName, executableName) || other.executableName == executableName)&&(identical(other.executableDownloadUrl, executableDownloadUrl) || other.executableDownloadUrl == executableDownloadUrl));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,command,const DeepCollectionEquality().hash(_args),workingDirectory,const DeepCollectionEquality().hash(_environment),timeout,retryOnFailure,maxRetries,requiresExecutable,executableName,executableDownloadUrl);
@override
String toString() {
return 'ProxyCommandConfig(command: $command, args: $args, workingDirectory: $workingDirectory, environment: $environment, timeout: $timeout, retryOnFailure: $retryOnFailure, maxRetries: $maxRetries, requiresExecutable: $requiresExecutable, executableName: $executableName, executableDownloadUrl: $executableDownloadUrl)';
}
}
/// @nodoc
abstract mixin class _$ProxyCommandConfigCopyWith<$Res> implements $ProxyCommandConfigCopyWith<$Res> {
factory _$ProxyCommandConfigCopyWith(_ProxyCommandConfig value, $Res Function(_ProxyCommandConfig) _then) = __$ProxyCommandConfigCopyWithImpl;
@override @useResult
$Res call({
String command, List<String>? args, String? workingDirectory, Map<String, String>? environment, Duration timeout, bool retryOnFailure, int maxRetries, bool requiresExecutable, String? executableName, String? executableDownloadUrl
});
}
/// @nodoc
class __$ProxyCommandConfigCopyWithImpl<$Res>
implements _$ProxyCommandConfigCopyWith<$Res> {
__$ProxyCommandConfigCopyWithImpl(this._self, this._then);
final _ProxyCommandConfig _self;
final $Res Function(_ProxyCommandConfig) _then;
/// Create a copy of ProxyCommandConfig
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? command = null,Object? args = freezed,Object? workingDirectory = freezed,Object? environment = freezed,Object? timeout = null,Object? retryOnFailure = null,Object? maxRetries = null,Object? requiresExecutable = null,Object? executableName = freezed,Object? executableDownloadUrl = freezed,}) {
return _then(_ProxyCommandConfig(
command: null == command ? _self.command : command // ignore: cast_nullable_to_non_nullable
as String,args: freezed == args ? _self._args : args // ignore: cast_nullable_to_non_nullable
as List<String>?,workingDirectory: freezed == workingDirectory ? _self.workingDirectory : workingDirectory // ignore: cast_nullable_to_non_nullable
as String?,environment: freezed == environment ? _self._environment : environment // ignore: cast_nullable_to_non_nullable
as Map<String, String>?,timeout: null == timeout ? _self.timeout : timeout // ignore: cast_nullable_to_non_nullable
as Duration,retryOnFailure: null == retryOnFailure ? _self.retryOnFailure : retryOnFailure // ignore: cast_nullable_to_non_nullable
as bool,maxRetries: null == maxRetries ? _self.maxRetries : maxRetries // ignore: cast_nullable_to_non_nullable
as int,requiresExecutable: null == requiresExecutable ? _self.requiresExecutable : requiresExecutable // ignore: cast_nullable_to_non_nullable
as bool,executableName: freezed == executableName ? _self.executableName : executableName // ignore: cast_nullable_to_non_nullable
as String?,executableDownloadUrl: freezed == executableDownloadUrl ? _self.executableDownloadUrl : executableDownloadUrl // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
// dart format on

View File

@@ -0,0 +1,39 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'proxy_command_config.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_ProxyCommandConfig _$ProxyCommandConfigFromJson(Map<String, dynamic> json) =>
_ProxyCommandConfig(
command: json['command'] as String,
args: (json['args'] as List<dynamic>?)?.map((e) => e as String).toList(),
workingDirectory: json['workingDirectory'] as String?,
environment: (json['environment'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
timeout: json['timeout'] == null
? const Duration(seconds: 30)
: Duration(microseconds: (json['timeout'] as num).toInt()),
retryOnFailure: json['retryOnFailure'] as bool? ?? false,
maxRetries: (json['maxRetries'] as num?)?.toInt() ?? 3,
requiresExecutable: json['requiresExecutable'] as bool? ?? false,
executableName: json['executableName'] as String?,
executableDownloadUrl: json['executableDownloadUrl'] as String?,
);
Map<String, dynamic> _$ProxyCommandConfigToJson(_ProxyCommandConfig instance) =>
<String, dynamic>{
'command': instance.command,
'args': instance.args,
'workingDirectory': instance.workingDirectory,
'environment': instance.environment,
'timeout': instance.timeout.inMicroseconds,
'retryOnFailure': instance.retryOnFailure,
'maxRetries': instance.maxRetries,
'requiresExecutable': instance.requiresExecutable,
'executableName': instance.executableName,
'executableDownloadUrl': instance.executableDownloadUrl,
};

View File

@@ -4,6 +4,7 @@ import 'package:fl_lib/fl_lib.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:server_box/data/model/app/error.dart';
import 'package:server_box/data/model/server/custom.dart';
import 'package:server_box/data/model/server/proxy_command_config.dart';
import 'package:server_box/data/model/server/system.dart';
import 'package:server_box/data/model/server/wol_cfg.dart';
import 'package:server_box/data/store/server.dart';
@@ -21,7 +22,7 @@ part 'server_private_info.g.dart';
abstract class Spi with _$Spi {
const Spi._();
@JsonSerializable(includeIfNull: false)
@JsonSerializable(includeIfNull: false, explicitToJson: true)
const factory Spi({
required String name,
required String ip,
@@ -45,14 +46,18 @@ abstract class Spi with _$Spi {
@Default('') @JsonKey(fromJson: Spi.parseId) String id,
/// Custom system type (unix or windows). If set, skip auto-detection.
@JsonKey(includeIfNull: false) SystemType? customSystemType,
SystemType? customSystemType,
/// Disabled command types for this server
@JsonKey(includeIfNull: false) List<String>? disabledCmdTypes,
List<String>? disabledCmdTypes,
/// ProxyCommand configuration for SSH connections
ProxyCommandConfig? proxyCommand,
}) = _Spi;
factory Spi.fromJson(Map<String, dynamic> json) => _$SpiFromJson(json);
@override
String toString() => 'Spi<$oldId>';

View File

@@ -19,8 +19,9 @@ mixin _$Spi {
@JsonKey(name: 'pubKeyId') String? get keyId; List<String>? get tags; String? get alterUrl; bool get autoConnect;/// [id] of the jump server
String? get jumpId; ServerCustom? get custom; WakeOnLanCfg? get wolCfg;/// It only applies to SSH terminal.
Map<String, String>? get envs;@JsonKey(fromJson: Spi.parseId) String get id;/// Custom system type (unix or windows). If set, skip auto-detection.
@JsonKey(includeIfNull: false) SystemType? get customSystemType;/// Disabled command types for this server
@JsonKey(includeIfNull: false) List<String>? get disabledCmdTypes;
SystemType? get customSystemType;/// Disabled command types for this server
List<String>? get disabledCmdTypes;/// ProxyCommand configuration for SSH connections
ProxyCommandConfig? get proxyCommand;
/// Create a copy of Spi
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -33,12 +34,12 @@ $SpiCopyWith<Spi> get copyWith => _$SpiCopyWithImpl<Spi>(this as Spi, _$identity
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is Spi&&(identical(other.name, name) || other.name == name)&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.port, port) || other.port == port)&&(identical(other.user, user) || other.user == user)&&(identical(other.pwd, pwd) || other.pwd == pwd)&&(identical(other.keyId, keyId) || other.keyId == keyId)&&const DeepCollectionEquality().equals(other.tags, tags)&&(identical(other.alterUrl, alterUrl) || other.alterUrl == alterUrl)&&(identical(other.autoConnect, autoConnect) || other.autoConnect == autoConnect)&&(identical(other.jumpId, jumpId) || other.jumpId == jumpId)&&(identical(other.custom, custom) || other.custom == custom)&&(identical(other.wolCfg, wolCfg) || other.wolCfg == wolCfg)&&const DeepCollectionEquality().equals(other.envs, envs)&&(identical(other.id, id) || other.id == id)&&(identical(other.customSystemType, customSystemType) || other.customSystemType == customSystemType)&&const DeepCollectionEquality().equals(other.disabledCmdTypes, disabledCmdTypes));
return identical(this, other) || (other.runtimeType == runtimeType&&other is Spi&&(identical(other.name, name) || other.name == name)&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.port, port) || other.port == port)&&(identical(other.user, user) || other.user == user)&&(identical(other.pwd, pwd) || other.pwd == pwd)&&(identical(other.keyId, keyId) || other.keyId == keyId)&&const DeepCollectionEquality().equals(other.tags, tags)&&(identical(other.alterUrl, alterUrl) || other.alterUrl == alterUrl)&&(identical(other.autoConnect, autoConnect) || other.autoConnect == autoConnect)&&(identical(other.jumpId, jumpId) || other.jumpId == jumpId)&&(identical(other.custom, custom) || other.custom == custom)&&(identical(other.wolCfg, wolCfg) || other.wolCfg == wolCfg)&&const DeepCollectionEquality().equals(other.envs, envs)&&(identical(other.id, id) || other.id == id)&&(identical(other.customSystemType, customSystemType) || other.customSystemType == customSystemType)&&const DeepCollectionEquality().equals(other.disabledCmdTypes, disabledCmdTypes)&&(identical(other.proxyCommand, proxyCommand) || other.proxyCommand == proxyCommand));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,name,ip,port,user,pwd,keyId,const DeepCollectionEquality().hash(tags),alterUrl,autoConnect,jumpId,custom,wolCfg,const DeepCollectionEquality().hash(envs),id,customSystemType,const DeepCollectionEquality().hash(disabledCmdTypes));
int get hashCode => Object.hash(runtimeType,name,ip,port,user,pwd,keyId,const DeepCollectionEquality().hash(tags),alterUrl,autoConnect,jumpId,custom,wolCfg,const DeepCollectionEquality().hash(envs),id,customSystemType,const DeepCollectionEquality().hash(disabledCmdTypes),proxyCommand);
@@ -49,11 +50,11 @@ abstract mixin class $SpiCopyWith<$Res> {
factory $SpiCopyWith(Spi value, $Res Function(Spi) _then) = _$SpiCopyWithImpl;
@useResult
$Res call({
String name, String ip, int port, String user, String? pwd,@JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs,@JsonKey(fromJson: Spi.parseId) String id,@JsonKey(includeIfNull: false) SystemType? customSystemType,@JsonKey(includeIfNull: false) List<String>? disabledCmdTypes
String name, String ip, int port, String user, String? pwd,@JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs,@JsonKey(fromJson: Spi.parseId) String id, SystemType? customSystemType, List<String>? disabledCmdTypes, ProxyCommandConfig? proxyCommand
});
$ProxyCommandConfigCopyWith<$Res>? get proxyCommand;
}
/// @nodoc
@@ -66,7 +67,7 @@ class _$SpiCopyWithImpl<$Res>
/// Create a copy of Spi
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? ip = null,Object? port = null,Object? user = null,Object? pwd = freezed,Object? keyId = freezed,Object? tags = freezed,Object? alterUrl = freezed,Object? autoConnect = null,Object? jumpId = freezed,Object? custom = freezed,Object? wolCfg = freezed,Object? envs = freezed,Object? id = null,Object? customSystemType = freezed,Object? disabledCmdTypes = freezed,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? ip = null,Object? port = null,Object? user = null,Object? pwd = freezed,Object? keyId = freezed,Object? tags = freezed,Object? alterUrl = freezed,Object? autoConnect = null,Object? jumpId = freezed,Object? custom = freezed,Object? wolCfg = freezed,Object? envs = freezed,Object? id = null,Object? customSystemType = freezed,Object? disabledCmdTypes = freezed,Object? proxyCommand = freezed,}) {
return _then(_self.copyWith(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,ip: null == ip ? _self.ip : ip // ignore: cast_nullable_to_non_nullable
@@ -84,10 +85,23 @@ as WakeOnLanCfg?,envs: freezed == envs ? _self.envs : envs // ignore: cast_nulla
as Map<String, String>?,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,customSystemType: freezed == customSystemType ? _self.customSystemType : customSystemType // ignore: cast_nullable_to_non_nullable
as SystemType?,disabledCmdTypes: freezed == disabledCmdTypes ? _self.disabledCmdTypes : disabledCmdTypes // ignore: cast_nullable_to_non_nullable
as List<String>?,
as List<String>?,proxyCommand: freezed == proxyCommand ? _self.proxyCommand : proxyCommand // ignore: cast_nullable_to_non_nullable
as ProxyCommandConfig?,
));
}
/// Create a copy of Spi
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$ProxyCommandConfigCopyWith<$Res>? get proxyCommand {
if (_self.proxyCommand == null) {
return null;
}
return $ProxyCommandConfigCopyWith<$Res>(_self.proxyCommand!, (value) {
return _then(_self.copyWith(proxyCommand: value));
});
}
}
@@ -169,10 +183,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs, @JsonKey(fromJson: Spi.parseId) String id, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List<String>? disabledCmdTypes)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs, @JsonKey(fromJson: Spi.parseId) String id, SystemType? customSystemType, List<String>? disabledCmdTypes, ProxyCommandConfig? proxyCommand)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _Spi() when $default != null:
return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes);case _:
return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes,_that.proxyCommand);case _:
return orElse();
}
@@ -190,10 +204,10 @@ return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs, @JsonKey(fromJson: Spi.parseId) String id, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List<String>? disabledCmdTypes) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs, @JsonKey(fromJson: Spi.parseId) String id, SystemType? customSystemType, List<String>? disabledCmdTypes, ProxyCommandConfig? proxyCommand) $default,) {final _that = this;
switch (_that) {
case _Spi():
return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes);case _:
return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes,_that.proxyCommand);case _:
throw StateError('Unexpected subclass');
}
@@ -210,10 +224,10 @@ return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs, @JsonKey(fromJson: Spi.parseId) String id, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List<String>? disabledCmdTypes)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs, @JsonKey(fromJson: Spi.parseId) String id, SystemType? customSystemType, List<String>? disabledCmdTypes, ProxyCommandConfig? proxyCommand)? $default,) {final _that = this;
switch (_that) {
case _Spi() when $default != null:
return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes);case _:
return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes,_that.proxyCommand);case _:
return null;
}
@@ -223,9 +237,9 @@ return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,
/// @nodoc
@JsonSerializable(includeIfNull: false)
@JsonSerializable(includeIfNull: false, explicitToJson: true)
class _Spi extends Spi {
const _Spi({required this.name, required this.ip, required this.port, required this.user, this.pwd, @JsonKey(name: 'pubKeyId') this.keyId, final List<String>? tags, this.alterUrl, this.autoConnect = true, this.jumpId, this.custom, this.wolCfg, final Map<String, String>? envs, @JsonKey(fromJson: Spi.parseId) this.id = '', @JsonKey(includeIfNull: false) this.customSystemType, @JsonKey(includeIfNull: false) final List<String>? disabledCmdTypes}): _tags = tags,_envs = envs,_disabledCmdTypes = disabledCmdTypes,super._();
const _Spi({required this.name, required this.ip, required this.port, required this.user, this.pwd, @JsonKey(name: 'pubKeyId') this.keyId, final List<String>? tags, this.alterUrl, this.autoConnect = true, this.jumpId, this.custom, this.wolCfg, final Map<String, String>? envs, @JsonKey(fromJson: Spi.parseId) this.id = '', this.customSystemType, final List<String>? disabledCmdTypes, this.proxyCommand}): _tags = tags,_envs = envs,_disabledCmdTypes = disabledCmdTypes,super._();
factory _Spi.fromJson(Map<String, dynamic> json) => _$SpiFromJson(json);
@override final String name;
@@ -263,11 +277,11 @@ class _Spi extends Spi {
@override@JsonKey(fromJson: Spi.parseId) final String id;
/// Custom system type (unix or windows). If set, skip auto-detection.
@override@JsonKey(includeIfNull: false) final SystemType? customSystemType;
@override final SystemType? customSystemType;
/// Disabled command types for this server
final List<String>? _disabledCmdTypes;
/// Disabled command types for this server
@override@JsonKey(includeIfNull: false) List<String>? get disabledCmdTypes {
@override List<String>? get disabledCmdTypes {
final value = _disabledCmdTypes;
if (value == null) return null;
if (_disabledCmdTypes is EqualUnmodifiableListView) return _disabledCmdTypes;
@@ -275,6 +289,8 @@ class _Spi extends Spi {
return EqualUnmodifiableListView(value);
}
/// ProxyCommand configuration for SSH connections
@override final ProxyCommandConfig? proxyCommand;
/// Create a copy of Spi
/// with the given fields replaced by the non-null parameter values.
@@ -289,12 +305,12 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Spi&&(identical(other.name, name) || other.name == name)&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.port, port) || other.port == port)&&(identical(other.user, user) || other.user == user)&&(identical(other.pwd, pwd) || other.pwd == pwd)&&(identical(other.keyId, keyId) || other.keyId == keyId)&&const DeepCollectionEquality().equals(other._tags, _tags)&&(identical(other.alterUrl, alterUrl) || other.alterUrl == alterUrl)&&(identical(other.autoConnect, autoConnect) || other.autoConnect == autoConnect)&&(identical(other.jumpId, jumpId) || other.jumpId == jumpId)&&(identical(other.custom, custom) || other.custom == custom)&&(identical(other.wolCfg, wolCfg) || other.wolCfg == wolCfg)&&const DeepCollectionEquality().equals(other._envs, _envs)&&(identical(other.id, id) || other.id == id)&&(identical(other.customSystemType, customSystemType) || other.customSystemType == customSystemType)&&const DeepCollectionEquality().equals(other._disabledCmdTypes, _disabledCmdTypes));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Spi&&(identical(other.name, name) || other.name == name)&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.port, port) || other.port == port)&&(identical(other.user, user) || other.user == user)&&(identical(other.pwd, pwd) || other.pwd == pwd)&&(identical(other.keyId, keyId) || other.keyId == keyId)&&const DeepCollectionEquality().equals(other._tags, _tags)&&(identical(other.alterUrl, alterUrl) || other.alterUrl == alterUrl)&&(identical(other.autoConnect, autoConnect) || other.autoConnect == autoConnect)&&(identical(other.jumpId, jumpId) || other.jumpId == jumpId)&&(identical(other.custom, custom) || other.custom == custom)&&(identical(other.wolCfg, wolCfg) || other.wolCfg == wolCfg)&&const DeepCollectionEquality().equals(other._envs, _envs)&&(identical(other.id, id) || other.id == id)&&(identical(other.customSystemType, customSystemType) || other.customSystemType == customSystemType)&&const DeepCollectionEquality().equals(other._disabledCmdTypes, _disabledCmdTypes)&&(identical(other.proxyCommand, proxyCommand) || other.proxyCommand == proxyCommand));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,name,ip,port,user,pwd,keyId,const DeepCollectionEquality().hash(_tags),alterUrl,autoConnect,jumpId,custom,wolCfg,const DeepCollectionEquality().hash(_envs),id,customSystemType,const DeepCollectionEquality().hash(_disabledCmdTypes));
int get hashCode => Object.hash(runtimeType,name,ip,port,user,pwd,keyId,const DeepCollectionEquality().hash(_tags),alterUrl,autoConnect,jumpId,custom,wolCfg,const DeepCollectionEquality().hash(_envs),id,customSystemType,const DeepCollectionEquality().hash(_disabledCmdTypes),proxyCommand);
@@ -305,11 +321,11 @@ abstract mixin class _$SpiCopyWith<$Res> implements $SpiCopyWith<$Res> {
factory _$SpiCopyWith(_Spi value, $Res Function(_Spi) _then) = __$SpiCopyWithImpl;
@override @useResult
$Res call({
String name, String ip, int port, String user, String? pwd,@JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs,@JsonKey(fromJson: Spi.parseId) String id,@JsonKey(includeIfNull: false) SystemType? customSystemType,@JsonKey(includeIfNull: false) List<String>? disabledCmdTypes
String name, String ip, int port, String user, String? pwd,@JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs,@JsonKey(fromJson: Spi.parseId) String id, SystemType? customSystemType, List<String>? disabledCmdTypes, ProxyCommandConfig? proxyCommand
});
@override $ProxyCommandConfigCopyWith<$Res>? get proxyCommand;
}
/// @nodoc
@@ -322,7 +338,7 @@ class __$SpiCopyWithImpl<$Res>
/// Create a copy of Spi
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? ip = null,Object? port = null,Object? user = null,Object? pwd = freezed,Object? keyId = freezed,Object? tags = freezed,Object? alterUrl = freezed,Object? autoConnect = null,Object? jumpId = freezed,Object? custom = freezed,Object? wolCfg = freezed,Object? envs = freezed,Object? id = null,Object? customSystemType = freezed,Object? disabledCmdTypes = freezed,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? ip = null,Object? port = null,Object? user = null,Object? pwd = freezed,Object? keyId = freezed,Object? tags = freezed,Object? alterUrl = freezed,Object? autoConnect = null,Object? jumpId = freezed,Object? custom = freezed,Object? wolCfg = freezed,Object? envs = freezed,Object? id = null,Object? customSystemType = freezed,Object? disabledCmdTypes = freezed,Object? proxyCommand = freezed,}) {
return _then(_Spi(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,ip: null == ip ? _self.ip : ip // ignore: cast_nullable_to_non_nullable
@@ -340,11 +356,24 @@ as WakeOnLanCfg?,envs: freezed == envs ? _self._envs : envs // ignore: cast_null
as Map<String, String>?,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,customSystemType: freezed == customSystemType ? _self.customSystemType : customSystemType // ignore: cast_nullable_to_non_nullable
as SystemType?,disabledCmdTypes: freezed == disabledCmdTypes ? _self._disabledCmdTypes : disabledCmdTypes // ignore: cast_nullable_to_non_nullable
as List<String>?,
as List<String>?,proxyCommand: freezed == proxyCommand ? _self.proxyCommand : proxyCommand // ignore: cast_nullable_to_non_nullable
as ProxyCommandConfig?,
));
}
/// Create a copy of Spi
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$ProxyCommandConfigCopyWith<$Res>? get proxyCommand {
if (_self.proxyCommand == null) {
return null;
}
return $ProxyCommandConfigCopyWith<$Res>(_self.proxyCommand!, (value) {
return _then(_self.copyWith(proxyCommand: value));
});
}
}
// dart format on

View File

@@ -34,6 +34,11 @@ _Spi _$SpiFromJson(Map<String, dynamic> json) => _Spi(
disabledCmdTypes: (json['disabledCmdTypes'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
proxyCommand: json['proxyCommand'] == null
? null
: ProxyCommandConfig.fromJson(
json['proxyCommand'] as Map<String, dynamic>,
),
);
Map<String, dynamic> _$SpiToJson(_Spi instance) => <String, dynamic>{
@@ -47,12 +52,13 @@ Map<String, dynamic> _$SpiToJson(_Spi instance) => <String, dynamic>{
'alterUrl': ?instance.alterUrl,
'autoConnect': instance.autoConnect,
'jumpId': ?instance.jumpId,
'custom': ?instance.custom,
'wolCfg': ?instance.wolCfg,
'custom': ?instance.custom?.toJson(),
'wolCfg': ?instance.wolCfg?.toJson(),
'envs': ?instance.envs,
'id': instance.id,
'customSystemType': ?_$SystemTypeEnumMap[instance.customSystemType],
'disabledCmdTypes': ?instance.disabledCmdTypes,
'proxyCommand': ?instance.proxyCommand?.toJson(),
};
const _$SystemTypeEnumMap = {

View File

@@ -0,0 +1,343 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:meta/meta.dart';
import 'package:riverpod/riverpod.dart';
import 'package:server_box/data/model/ai/ask_ai_models.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/data/store/setting.dart';
final askAiRepositoryProvider = Provider<AskAiRepository>((ref) {
return AskAiRepository();
});
class AskAiRepository {
AskAiRepository({Dio? dio}) : _dio = dio ?? Dio();
final Dio _dio;
SettingStore get _settings => Stores.setting;
/// Streams the AI response using the configured endpoint.
Stream<AskAiEvent> ask({
required String selection,
String? localeHint,
List<AskAiMessage> conversation = const [],
}) async* {
final baseUrl = _settings.askAiBaseUrl.fetch().trim();
final apiKey = _settings.askAiApiKey.fetch().trim();
final model = _settings.askAiModel.fetch().trim();
final missing = <AskAiConfigField>[];
if (baseUrl.isEmpty) missing.add(AskAiConfigField.baseUrl);
if (apiKey.isEmpty) missing.add(AskAiConfigField.apiKey);
if (model.isEmpty) missing.add(AskAiConfigField.model);
if (missing.isNotEmpty) {
throw AskAiConfigException(missingFields: missing);
}
final parsedBaseUri = Uri.tryParse(baseUrl);
final hasScheme = parsedBaseUri?.hasScheme ?? false;
final hasHost = (parsedBaseUri?.host ?? '').isNotEmpty;
if (!hasScheme || !hasHost) {
throw AskAiConfigException(invalidBaseUrl: baseUrl);
}
final uri = _composeUri(baseUrl, '/v1/chat/completions');
final authHeader = apiKey.startsWith('Bearer ') ? apiKey : 'Bearer $apiKey';
final headers = <String, String>{
Headers.acceptHeader: 'text/event-stream',
Headers.contentTypeHeader: Headers.jsonContentType,
'Authorization': authHeader,
};
final requestBody = _buildRequestBody(
model: model,
selection: selection,
localeHint: localeHint,
conversation: conversation,
);
Response<ResponseBody> response;
try {
response = await _dio.postUri<ResponseBody>(
uri,
data: jsonEncode(requestBody),
options: Options(
responseType: ResponseType.stream,
headers: headers,
sendTimeout: const Duration(seconds: 20),
receiveTimeout: const Duration(minutes: 2),
),
);
} on DioException catch (e) {
throw AskAiNetworkException(message: e.message ?? 'Request failed', cause: e);
}
final body = response.data;
if (body == null) {
throw AskAiNetworkException(message: 'Empty response body');
}
final contentBuffer = StringBuffer();
final commands = <AskAiCommand>[];
final toolBuilders = <int, _ToolCallBuilder>{};
final utf8Stream = body.stream.cast<List<int>>().transform(utf8.decoder);
final carry = StringBuffer();
try {
await for (final chunk in utf8Stream) {
carry.write(chunk);
final segments = carry.toString().split('\n\n');
carry
..clear()
..write(segments.removeLast());
for (final segment in segments) {
final lines = segment.split('\n');
for (final rawLine in lines) {
final line = rawLine.trim();
if (line.isEmpty || !line.startsWith('data:')) {
continue;
}
final payload = line.substring(5).trim();
if (payload.isEmpty) {
continue;
}
if (payload == '[DONE]') {
yield AskAiCompleted(
fullText: contentBuffer.toString(),
commands: List.unmodifiable(commands),
);
return;
}
Map<String, dynamic> json;
try {
json = jsonDecode(payload) as Map<String, dynamic>;
} catch (e, s) {
yield AskAiStreamError(e, s);
continue;
}
final choices = json['choices'];
if (choices is! List || choices.isEmpty) {
continue;
}
for (final choice in choices) {
if (choice is! Map<String, dynamic>) {
continue;
}
final delta = choice['delta'];
if (delta is Map<String, dynamic>) {
final content = delta['content'];
if (content is String && content.isNotEmpty) {
contentBuffer.write(content);
yield AskAiContentDelta(content);
} else if (content is List) {
for (final item in content) {
if (item is Map<String, dynamic>) {
final text = item['text'] as String?;
if (text != null && text.isNotEmpty) {
contentBuffer.write(text);
yield AskAiContentDelta(text);
}
}
}
}
final toolCalls = delta['tool_calls'];
if (toolCalls is List) {
for (final toolCall in toolCalls) {
if (toolCall is! Map<String, dynamic>) continue;
final index = toolCall['index'] as int? ?? 0;
final builder = toolBuilders.putIfAbsent(index, _ToolCallBuilder.new);
final function = toolCall['function'];
if (function is Map<String, dynamic>) {
builder.name ??= function['name'] as String?;
final args = function['arguments'] as String?;
if (args != null && args.isNotEmpty) {
builder.arguments.write(args);
final command = builder.tryBuild();
if (command != null) {
commands.add(command);
yield AskAiToolSuggestion(command);
}
}
}
}
}
}
final finishReason = choice['finish_reason'];
if (finishReason == 'tool_calls') {
for (final builder in toolBuilders.values) {
final command = builder.tryBuild(force: true);
if (command != null) {
commands.add(command);
yield AskAiToolSuggestion(command);
}
}
toolBuilders.clear();
}
}
}
}
}
// Flush remaining buffer if [DONE] not received.
if (contentBuffer.isNotEmpty || commands.isNotEmpty) {
yield AskAiCompleted(
fullText: contentBuffer.toString(),
commands: List.unmodifiable(commands),
);
}
} catch (e, s) {
yield AskAiStreamError(e, s);
return;
}
}
Map<String, dynamic> _buildRequestBody({
required String model,
required String selection,
required List<AskAiMessage> conversation,
String? localeHint,
}) {
final promptBuffer = StringBuffer()
..writeln('你是一个 SSH 终端助手。')
..writeln('用户会提供一段终端输出或命令,请结合上下文给出解释。')
..writeln('当需要给出可执行命令时,调用 `recommend_shell` 工具,并提供简短描述。')
..writeln('仅在非常确定命令安全时才给出建议。');
if (localeHint != null && localeHint.isNotEmpty) {
promptBuffer
.writeln('请优先使用用户的语言输出:$localeHint');
}
final messages = <Map<String, String>>[
{
'role': 'system',
'content': promptBuffer.toString(),
},
...conversation.map((message) => {
'role': message.apiRole,
'content': message.content,
}),
{
'role': 'user',
'content': '以下是终端选中的内容:\n$selection',
},
];
return {
'model': model,
'stream': true,
'messages': messages,
'tools': [
{
'type': 'function',
'function': {
'name': 'recommend_shell',
'description': '返回一个用户可以直接复制执行的终端命令。',
'parameters': {
'type': 'object',
'required': ['command'],
'properties': {
'command': {
'type': 'string',
'description': '完整的终端命令,确保可以被粘贴后直接执行。',
},
'description': {
'type': 'string',
'description': '简述该命令的作用或注意事项。',
},
},
},
},
},
],
};
}
Uri _composeUri(String base, String path) {
final sanitizedBase = base.replaceAll(RegExp(r'/+$'), '');
final sanitizedPath = path.replaceFirst(RegExp(r'^/+'), '');
return Uri.parse('$sanitizedBase/$sanitizedPath');
}
}
class _ToolCallBuilder {
_ToolCallBuilder();
final StringBuffer arguments = StringBuffer();
String? name;
bool _emitted = false;
AskAiCommand? tryBuild({bool force = false}) {
if (_emitted && !force) return null;
final raw = arguments.toString();
try {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
final command = decoded['command'] as String?;
if (command == null || command.trim().isEmpty) {
if (force) {
_emitted = true;
}
return null;
}
final description = decoded['description'] as String? ?? decoded['explanation'] as String? ?? '';
_emitted = true;
return AskAiCommand(
command: command.trim(),
description: description.trim(),
toolName: name,
);
} on FormatException {
if (force) {
_emitted = true;
}
return null;
}
}
}
@immutable
enum AskAiConfigField { baseUrl, apiKey, model }
class AskAiConfigException implements Exception {
const AskAiConfigException({this.missingFields = const [], this.invalidBaseUrl});
final List<AskAiConfigField> missingFields;
final String? invalidBaseUrl;
bool get hasInvalidBaseUrl => (invalidBaseUrl ?? '').isNotEmpty;
@override
String toString() {
final parts = <String>[];
if (missingFields.isNotEmpty) {
parts.add('missing: ${missingFields.map((e) => e.name).join(', ')}');
}
if (hasInvalidBaseUrl) {
parts.add('invalidBaseUrl: $invalidBaseUrl');
}
if (parts.isEmpty) {
return 'AskAiConfigException()';
}
return 'AskAiConfigException(${parts.join('; ')})';
}
}
@immutable
class AskAiNetworkException implements Exception {
const AskAiNetworkException({required this.message, this.cause});
final String message;
final Object? cause;
@override
String toString() => 'AskAiNetworkException(message: $message)';
}

View File

@@ -40,22 +40,15 @@ class ContainerNotifier extends _$ContainerNotifier {
ContainerState build(SSHClient? client, String userName, String hostId, BuildContext context) {
final type = Stores.container.getType(hostId);
final initialState = ContainerState(type: type);
// Async initialization
Future.microtask(() => refresh());
return initialState;
}
Future<void> setType(ContainerType type) async {
state = state.copyWith(
type: type,
error: null,
runLog: null,
items: null,
images: null,
version: null,
);
state = state.copyWith(type: type, error: null, runLog: null, items: null, images: null, version: null);
Stores.container.setType(type, hostId);
sudoCompleter = Completer<bool>();
await refresh();
@@ -180,9 +173,13 @@ class ContainerNotifier extends _$ContainerNotifier {
try {
final statsLines = statsRaw.split('\n');
statsLines.removeWhere((element) => element.isEmpty);
for (var item in state.items!) {
final items = state.items;
if (items == null) return;
for (var item in items) {
final id = item.id;
if (id == null) continue;
if (id.length < 5) continue;
final statsLine = statsLines.firstWhereOrNull(
/// Use 5 characters to match the container id, possibility of mismatch
/// is very low.
@@ -267,7 +264,6 @@ class ContainerNotifier extends _$ContainerNotifier {
}
}
const _jsonFmt = '--format "{{json .}}"';
enum ContainerCmdType {

View File

@@ -58,7 +58,7 @@ final class ContainerNotifierProvider
}
}
String _$containerNotifierHash() => r'fea65e66499234b0a59bffff8d69c4ab8c93b2fd';
String _$containerNotifierHash() => r'e6ced8a914631253daabe0de452e0338078cd1d9';
final class ContainerNotifierFamily extends $Family
with

View File

@@ -3,6 +3,6 @@
abstract class BuildData {
static const String name = "ServerBox";
static const int build = 1262;
static const int build = 1270;
static const int script = 69;
}

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);
@@ -142,6 +154,11 @@ class SettingStore extends HiveStore {
/// Whether collapse UI items by default
late final collapseUIDefault = propertyDefault('collapseUIDefault', true);
/// Terminal AI helper configuration
late final askAiBaseUrl = propertyDefault('askAiBaseUrl', 'https://api.openai.com');
late final askAiApiKey = propertyDefault('askAiApiKey', '');
late final askAiModel = propertyDefault('askAiModel', 'gpt-4o-mini');
late final serverFuncBtns = listProperty('serverBtns', defaultValue: ServerFuncBtn.defaultIdxs);
/// Docker is more popular than podman, set to `false` to use docker
@@ -211,6 +228,10 @@ class SettingStore extends HiveStore {
late final betaTest = propertyDefault('betaTest', false);
late final proxyCmdCustomExecs = listProperty('proxyCmdCustomExecs');
late final proxyCmdCustomPresets = listProperty('proxyCmdCustomPresets');
/// For desktop only.
/// Record the position and size of the window.
late final windowState = property<WindowState>(

View File

@@ -155,6 +155,102 @@ abstract class AppLocalizations {
/// **'Already in last directory.'**
String get alreadyLastDir;
/// No description provided for @askAi.
///
/// In en, this message translates to:
/// **'Ask AI'**
String get askAi;
/// No description provided for @askAiApiKey.
///
/// In en, this message translates to:
/// **'API Key'**
String get askAiApiKey;
/// No description provided for @askAiAwaitingResponse.
///
/// In en, this message translates to:
/// **'Waiting for AI response...'**
String get askAiAwaitingResponse;
/// No description provided for @askAiBaseUrl.
///
/// In en, this message translates to:
/// **'Base URL'**
String get askAiBaseUrl;
/// No description provided for @askAiCommandInserted.
///
/// In en, this message translates to:
/// **'Command inserted into terminal'**
String get askAiCommandInserted;
/// No description provided for @askAiConfigMissing.
///
/// In en, this message translates to:
/// **'Please configure {fields} in Settings.'**
String askAiConfigMissing(Object fields);
/// No description provided for @askAiConfirmExecute.
///
/// In en, this message translates to:
/// **'Confirm before executing'**
String get askAiConfirmExecute;
/// No description provided for @askAiConversation.
///
/// In en, this message translates to:
/// **'AI conversation'**
String get askAiConversation;
/// No description provided for @askAiDisclaimer.
///
/// In en, this message translates to:
/// **'AI may be incorrect. Review carefully before applying.'**
String get askAiDisclaimer;
/// No description provided for @askAiFollowUpHint.
///
/// In en, this message translates to:
/// **'Ask a follow-up...'**
String get askAiFollowUpHint;
/// No description provided for @askAiInsertTerminal.
///
/// In en, this message translates to:
/// **'Insert into terminal'**
String get askAiInsertTerminal;
/// No description provided for @askAiModel.
///
/// In en, this message translates to:
/// **'Model'**
String get askAiModel;
/// No description provided for @askAiNoResponse.
///
/// In en, this message translates to:
/// **'No response'**
String get askAiNoResponse;
/// No description provided for @askAiRecommendedCommand.
///
/// In en, this message translates to:
/// **'AI suggested command'**
String get askAiRecommendedCommand;
/// No description provided for @askAiSelectedContent.
///
/// In en, this message translates to:
/// **'Selected content'**
String get askAiSelectedContent;
/// No description provided for @askAiUsageHint.
///
/// In en, this message translates to:
/// **'Used in SSH Terminal'**
String get askAiUsageHint;
/// No description provided for @atLeastOneTab.
///
/// In en, this message translates to:
@@ -285,13 +381,13 @@ abstract class AppLocalizations {
///
/// In en, this message translates to:
/// **'Are you sure you want to clear connection statistics for server \"{serverName}\"? This action cannot be undone.'**
String clearServerStatsContent(String serverName);
String clearServerStatsContent(Object serverName);
/// No description provided for @clearServerStatsTitle.
///
/// In en, this message translates to:
/// **'Clear {serverName} Statistics'**
String clearServerStatsTitle(String serverName);
String clearServerStatsTitle(Object serverName);
/// No description provided for @clearThisServerStats.
///
@@ -1052,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:
@@ -1376,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

@@ -28,6 +28,57 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get alreadyLastDir => 'Bereits im letzten Verzeichnis.';
@override
String get askAi => 'KI fragen';
@override
String get askAiApiKey => 'API-Schlüssel';
@override
String get askAiAwaitingResponse => 'Warte auf KI-Antwort...';
@override
String get askAiBaseUrl => 'Basis-URL';
@override
String get askAiCommandInserted => 'Befehl ins Terminal eingefügt';
@override
String askAiConfigMissing(Object fields) {
return 'Bitte konfigurieren Sie $fields in den Einstellungen.';
}
@override
String get askAiConfirmExecute => 'Vor Ausführung bestätigen';
@override
String get askAiConversation => 'KI-Unterhaltung';
@override
String get askAiDisclaimer =>
'KI kann Fehler machen. Bitte vorsichtig verwenden.';
@override
String get askAiFollowUpHint => 'Weitere Frage stellen...';
@override
String get askAiInsertTerminal => 'In Terminal einfügen';
@override
String get askAiModel => 'Modell';
@override
String get askAiNoResponse => 'Keine Antwort';
@override
String get askAiRecommendedCommand => 'KI-empfohlener Befehl';
@override
String get askAiSelectedContent => 'Ausgewählter Inhalt';
@override
String get askAiUsageHint => 'Verwendet im SSH-Terminal';
@override
String get atLeastOneTab => 'Mindestens ein Tab muss ausgewählt sein';
@@ -99,12 +150,12 @@ class AppLocalizationsDe extends AppLocalizations {
String get clearAllStatsTitle => 'Alle Statistiken löschen';
@override
String clearServerStatsContent(String serverName) {
String clearServerStatsContent(Object serverName) {
return 'Sind Sie sicher, dass Sie die Verbindungsstatistiken für Server \"$serverName\" löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.';
}
@override
String clearServerStatsTitle(String serverName) {
String clearServerStatsTitle(Object serverName) {
return '$serverName Statistiken löschen';
}
@@ -530,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';
@@ -713,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

@@ -28,6 +28,57 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get alreadyLastDir => 'Already in last directory.';
@override
String get askAi => 'Ask AI';
@override
String get askAiApiKey => 'API Key';
@override
String get askAiAwaitingResponse => 'Waiting for AI response...';
@override
String get askAiBaseUrl => 'Base URL';
@override
String get askAiCommandInserted => 'Command inserted into terminal';
@override
String askAiConfigMissing(Object fields) {
return 'Please configure $fields in Settings.';
}
@override
String get askAiConfirmExecute => 'Confirm before executing';
@override
String get askAiConversation => 'AI conversation';
@override
String get askAiDisclaimer =>
'AI may be incorrect. Review carefully before applying.';
@override
String get askAiFollowUpHint => 'Ask a follow-up...';
@override
String get askAiInsertTerminal => 'Insert into terminal';
@override
String get askAiModel => 'Model';
@override
String get askAiNoResponse => 'No response';
@override
String get askAiRecommendedCommand => 'AI suggested command';
@override
String get askAiSelectedContent => 'Selected content';
@override
String get askAiUsageHint => 'Used in SSH Terminal';
@override
String get atLeastOneTab => 'At least one tab must be selected';
@@ -98,12 +149,12 @@ class AppLocalizationsEn extends AppLocalizations {
String get clearAllStatsTitle => 'Clear All Statistics';
@override
String clearServerStatsContent(String serverName) {
String clearServerStatsContent(Object serverName) {
return 'Are you sure you want to clear connection statistics for server \"$serverName\"? This action cannot be undone.';
}
@override
String clearServerStatsTitle(String serverName) {
String clearServerStatsTitle(Object serverName) {
return 'Clear $serverName Statistics';
}
@@ -527,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';
@@ -707,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

@@ -27,6 +27,57 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get alreadyLastDir => 'Ya estás en el directorio superior';
@override
String get askAi => 'Preguntar a la IA';
@override
String get askAiApiKey => 'Clave API';
@override
String get askAiAwaitingResponse => 'Esperando la respuesta de la IA...';
@override
String get askAiBaseUrl => 'URL base';
@override
String get askAiCommandInserted => 'Comando insertado en el terminal';
@override
String askAiConfigMissing(Object fields) {
return 'Configura $fields en Ajustes.';
}
@override
String get askAiConfirmExecute => 'Confirmar antes de ejecutar';
@override
String get askAiConversation => 'Conversación con la IA';
@override
String get askAiDisclaimer =>
'La IA puede equivocarse. Úsala con precaución.';
@override
String get askAiFollowUpHint => 'Haz una pregunta adicional...';
@override
String get askAiInsertTerminal => 'Insertar en el terminal';
@override
String get askAiModel => 'Modelo';
@override
String get askAiNoResponse => 'Sin respuesta';
@override
String get askAiRecommendedCommand => 'Comando sugerido por la IA';
@override
String get askAiSelectedContent => 'Contenido seleccionado';
@override
String get askAiUsageHint => 'Usado en el terminal SSH';
@override
String get atLeastOneTab => 'Al menos una pestaña debe estar seleccionada';
@@ -99,12 +150,12 @@ class AppLocalizationsEs extends AppLocalizations {
String get clearAllStatsTitle => 'Limpiar todas las estadísticas';
@override
String clearServerStatsContent(String serverName) {
String clearServerStatsContent(Object serverName) {
return '¿Estás seguro de que quieres limpiar las estadísticas de conexión del servidor \"$serverName\"? Esta acción no se puede deshacer.';
}
@override
String clearServerStatsTitle(String serverName) {
String clearServerStatsTitle(Object serverName) {
return 'Limpiar estadísticas de $serverName';
}
@@ -532,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';
@@ -716,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

@@ -27,6 +27,57 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get alreadyLastDir => 'Déjà dans le dernier répertoire.';
@override
String get askAi => 'Demander à l\'IA';
@override
String get askAiApiKey => 'Clé API';
@override
String get askAiAwaitingResponse => 'En attente de la réponse de l\'IA...';
@override
String get askAiBaseUrl => 'URL de base';
@override
String get askAiCommandInserted => 'Commande insérée dans le terminal';
@override
String askAiConfigMissing(Object fields) {
return 'Veuillez configurer $fields dans les paramètres.';
}
@override
String get askAiConfirmExecute => 'Confirmer avant d\'exécuter';
@override
String get askAiConversation => 'Conversation avec l\'IA';
@override
String get askAiDisclaimer =>
'L\'IA peut se tromper. Utilisez-la avec prudence.';
@override
String get askAiFollowUpHint => 'Poser une question supplémentaire...';
@override
String get askAiInsertTerminal => 'Insérer dans le terminal';
@override
String get askAiModel => 'Modèle';
@override
String get askAiNoResponse => 'Aucune réponse';
@override
String get askAiRecommendedCommand => 'Commande suggérée par l\'IA';
@override
String get askAiSelectedContent => 'Contenu sélectionné';
@override
String get askAiUsageHint => 'Utilisé dans le terminal SSH';
@override
String get atLeastOneTab => 'Au moins un onglet doit être sélectionné';
@@ -99,12 +150,12 @@ class AppLocalizationsFr extends AppLocalizations {
String get clearAllStatsTitle => 'Effacer toutes les statistiques';
@override
String clearServerStatsContent(String serverName) {
String clearServerStatsContent(Object serverName) {
return 'Êtes-vous sûr de vouloir effacer les statistiques de connexion du serveur \"$serverName\" ? Cette action ne peut pas être annulée.';
}
@override
String clearServerStatsTitle(String serverName) {
String clearServerStatsTitle(Object serverName) {
return 'Effacer les statistiques de $serverName';
}
@@ -534,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';
@@ -718,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

@@ -28,6 +28,56 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get alreadyLastDir => 'Sudah di direktori terakhir.';
@override
String get askAi => 'Tanya AI';
@override
String get askAiApiKey => 'Kunci API';
@override
String get askAiAwaitingResponse => 'Menunggu respons AI...';
@override
String get askAiBaseUrl => 'URL dasar';
@override
String get askAiCommandInserted => 'Perintah dimasukkan ke terminal';
@override
String askAiConfigMissing(Object fields) {
return 'Harap konfigurasikan $fields di Pengaturan.';
}
@override
String get askAiConfirmExecute => 'Konfirmasi sebelum menjalankan';
@override
String get askAiConversation => 'Percakapan AI';
@override
String get askAiDisclaimer => 'AI bisa saja salah. Gunakan dengan hati-hati.';
@override
String get askAiFollowUpHint => 'Ajukan pertanyaan lanjutan...';
@override
String get askAiInsertTerminal => 'Masukkan ke terminal';
@override
String get askAiModel => 'Model';
@override
String get askAiNoResponse => 'Tidak ada respons';
@override
String get askAiRecommendedCommand => 'Perintah yang disarankan AI';
@override
String get askAiSelectedContent => 'Konten yang dipilih';
@override
String get askAiUsageHint => 'Digunakan di Terminal SSH';
@override
String get atLeastOneTab => 'Setidaknya satu tab harus dipilih';
@@ -98,12 +148,12 @@ class AppLocalizationsId extends AppLocalizations {
String get clearAllStatsTitle => 'Hapus Semua Statistik';
@override
String clearServerStatsContent(String serverName) {
String clearServerStatsContent(Object serverName) {
return 'Apakah Anda yakin ingin menghapus statistik koneksi untuk server \"$serverName\"? Tindakan ini tidak dapat dibatalkan.';
}
@override
String clearServerStatsTitle(String serverName) {
String clearServerStatsTitle(Object serverName) {
return 'Hapus Statistik $serverName';
}
@@ -528,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';
@@ -709,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

@@ -27,6 +27,56 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get alreadyLastDir => 'すでに最上位のディレクトリです';
@override
String get askAi => 'AI に質問';
@override
String get askAiApiKey => 'API キー';
@override
String get askAiAwaitingResponse => 'AI の応答を待機中...';
@override
String get askAiBaseUrl => 'ベース URL';
@override
String get askAiCommandInserted => 'コマンドをターミナルに挿入しました';
@override
String askAiConfigMissing(Object fields) {
return '設定で $fields を構成してください。';
}
@override
String get askAiConfirmExecute => '実行前に確認';
@override
String get askAiConversation => 'AI 会話';
@override
String get askAiDisclaimer => 'AI が誤る可能性があります。注意してご利用ください。';
@override
String get askAiFollowUpHint => '追質問をする...';
@override
String get askAiInsertTerminal => 'ターミナルに挿入';
@override
String get askAiModel => 'モデル';
@override
String get askAiNoResponse => '応答なし';
@override
String get askAiRecommendedCommand => 'AI 推奨コマンド';
@override
String get askAiSelectedContent => '選択した内容';
@override
String get askAiUsageHint => 'SSH ターミナルで使用';
@override
String get atLeastOneTab => '少なくとも1つのタブを選択する必要があります';
@@ -93,12 +143,12 @@ class AppLocalizationsJa extends AppLocalizations {
String get clearAllStatsTitle => 'すべての統計をクリア';
@override
String clearServerStatsContent(String serverName) {
String clearServerStatsContent(Object serverName) {
return 'サーバー\"$serverName\"の接続統計を削除してもよろしいですか?この操作は元に戻せません。';
}
@override
String clearServerStatsTitle(String serverName) {
String clearServerStatsTitle(Object serverName) {
return '$serverNameの統計をクリア';
}
@@ -510,6 +560,11 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get privateKey => '秘密鍵';
@override
String privateKeyNotFoundFmt(Object keyId) {
return '秘密鍵 [$keyId] が見つかりません。';
}
@override
String get process => 'プロセス';
@@ -687,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

@@ -28,6 +28,56 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get alreadyLastDir => 'Al in de laatst gebruikte map.';
@override
String get askAi => 'AI vragen';
@override
String get askAiApiKey => 'API-sleutel';
@override
String get askAiAwaitingResponse => 'Wachten op AI-reactie...';
@override
String get askAiBaseUrl => 'Basis-URL';
@override
String get askAiCommandInserted => 'Commando in terminal ingevoegd';
@override
String askAiConfigMissing(Object fields) {
return 'Configureer $fields in de instellingen.';
}
@override
String get askAiConfirmExecute => 'Bevestigen voor uitvoeren';
@override
String get askAiConversation => 'AI-gesprek';
@override
String get askAiDisclaimer => 'AI kan fouten maken. Gebruik het zorgvuldig.';
@override
String get askAiFollowUpHint => 'Stel een vervolgvraag...';
@override
String get askAiInsertTerminal => 'In terminal invoegen';
@override
String get askAiModel => 'Model';
@override
String get askAiNoResponse => 'Geen reactie';
@override
String get askAiRecommendedCommand => 'Door AI voorgestelde opdracht';
@override
String get askAiSelectedContent => 'Geselecteerde inhoud';
@override
String get askAiUsageHint => 'Gebruikt in de SSH-terminal';
@override
String get atLeastOneTab =>
'Er moet minimaal één tabblad worden geselecteerd';
@@ -99,12 +149,12 @@ class AppLocalizationsNl extends AppLocalizations {
String get clearAllStatsTitle => 'Alle statistieken wissen';
@override
String clearServerStatsContent(String serverName) {
String clearServerStatsContent(Object serverName) {
return 'Weet u zeker dat u de verbindingsstatistieken voor server \"$serverName\" wilt wissen? Deze actie kan niet ongedaan worden gemaakt.';
}
@override
String clearServerStatsTitle(String serverName) {
String clearServerStatsTitle(Object serverName) {
return 'Statistieken van $serverName wissen';
}
@@ -530,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';
@@ -713,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

@@ -27,6 +27,56 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get alreadyLastDir => 'Já é o diretório mais alto';
@override
String get askAi => 'Perguntar à IA';
@override
String get askAiApiKey => 'Chave de API';
@override
String get askAiAwaitingResponse => 'Aguardando resposta da IA...';
@override
String get askAiBaseUrl => 'URL base';
@override
String get askAiCommandInserted => 'Comando inserido no terminal';
@override
String askAiConfigMissing(Object fields) {
return 'Configure $fields nas configurações.';
}
@override
String get askAiConfirmExecute => 'Confirmar antes de executar';
@override
String get askAiConversation => 'Conversa com a IA';
@override
String get askAiDisclaimer => 'A IA pode errar. Use com cautela.';
@override
String get askAiFollowUpHint => 'Faça uma pergunta adicional...';
@override
String get askAiInsertTerminal => 'Inserir no terminal';
@override
String get askAiModel => 'Modelo';
@override
String get askAiNoResponse => 'Sem resposta';
@override
String get askAiRecommendedCommand => 'Comando sugerido pela IA';
@override
String get askAiSelectedContent => 'Conteúdo selecionado';
@override
String get askAiUsageHint => 'Usado no terminal SSH';
@override
String get atLeastOneTab => 'Pelo menos uma aba deve ser selecionada';
@@ -99,12 +149,12 @@ class AppLocalizationsPt extends AppLocalizations {
String get clearAllStatsTitle => 'Limpar todas as estatísticas';
@override
String clearServerStatsContent(String serverName) {
String clearServerStatsContent(Object serverName) {
return 'Tem certeza de que deseja limpar as estatísticas de conexão para o servidor \"$serverName\"? Esta ação não pode ser desfeita.';
}
@override
String clearServerStatsTitle(String serverName) {
String clearServerStatsTitle(Object serverName) {
return 'Limpar estatísticas de $serverName';
}
@@ -528,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';
@@ -709,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

@@ -27,6 +27,57 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get alreadyLastDir => 'Уже в корневом каталоге';
@override
String get askAi => 'Спросить ИИ';
@override
String get askAiApiKey => 'Ключ API';
@override
String get askAiAwaitingResponse => 'Ожидание ответа ИИ...';
@override
String get askAiBaseUrl => 'Базовый URL';
@override
String get askAiCommandInserted => 'Команда вставлена в терминал';
@override
String askAiConfigMissing(Object fields) {
return 'Настройте $fields в настройках.';
}
@override
String get askAiConfirmExecute => 'Подтвердите перед выполнением';
@override
String get askAiConversation => 'Разговор с ИИ';
@override
String get askAiDisclaimer =>
'ИИ может ошибаться. Используйте с осторожностью.';
@override
String get askAiFollowUpHint => 'Задайте дополнительный вопрос...';
@override
String get askAiInsertTerminal => 'Вставить в терминал';
@override
String get askAiModel => 'Модель';
@override
String get askAiNoResponse => 'Нет ответа';
@override
String get askAiRecommendedCommand => 'Команда, предложенная ИИ';
@override
String get askAiSelectedContent => 'Выбранное содержимое';
@override
String get askAiUsageHint => 'Используется в SSH-терминале';
@override
String get atLeastOneTab => 'Должна быть выбрана хотя бы одна вкладка';
@@ -99,12 +150,12 @@ class AppLocalizationsRu extends AppLocalizations {
String get clearAllStatsTitle => 'Очистить всю статистику';
@override
String clearServerStatsContent(String serverName) {
String clearServerStatsContent(Object serverName) {
return 'Вы уверены, что хотите очистить статистику соединений для сервера \"$serverName\"? Это действие не может быть отменено.';
}
@override
String clearServerStatsTitle(String serverName) {
String clearServerStatsTitle(Object serverName) {
return 'Очистить статистику $serverName';
}
@@ -530,6 +581,11 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get privateKey => 'Приватный ключ';
@override
String privateKeyNotFoundFmt(Object keyId) {
return 'Закрытый ключ [$keyId] не найден.';
}
@override
String get process => 'Процесс';
@@ -713,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

@@ -27,6 +27,57 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get alreadyLastDir => 'Zaten son dizindesiniz.';
@override
String get askAi => 'Yapay zekaya sor';
@override
String get askAiApiKey => 'API anahtarı';
@override
String get askAiAwaitingResponse => 'Yapay zekâ yanıtı bekleniyor...';
@override
String get askAiBaseUrl => 'Temel URL';
@override
String get askAiCommandInserted => 'Komut terminale eklendi';
@override
String askAiConfigMissing(Object fields) {
return 'Lütfen Ayarlar\'da $fields öğesini yapılandırın.';
}
@override
String get askAiConfirmExecute => 'Çalıştırmadan önce onayla';
@override
String get askAiConversation => 'YZ sohbeti';
@override
String get askAiDisclaimer =>
'Yapay zeka hata yapabilir. Lütfen dikkatli kullanın.';
@override
String get askAiFollowUpHint => 'Yeni bir soru sor...';
@override
String get askAiInsertTerminal => 'Terminale ekle';
@override
String get askAiModel => 'Model';
@override
String get askAiNoResponse => 'Yanıt yok';
@override
String get askAiRecommendedCommand => 'YZ önerilen komut';
@override
String get askAiSelectedContent => 'Seçilen içerik';
@override
String get askAiUsageHint => 'SSH Terminalinde kullanılır';
@override
String get atLeastOneTab => 'En az bir sekme seçilmelidir';
@@ -97,12 +148,12 @@ class AppLocalizationsTr extends AppLocalizations {
String get clearAllStatsTitle => 'Tüm İstatistikleri Temizle';
@override
String clearServerStatsContent(String serverName) {
String clearServerStatsContent(Object serverName) {
return '\"$serverName\" sunucusu için bağlantı istatistiklerini temizlemek istediğinizden emin misiniz? Bu işlem geri alınamaz.';
}
@override
String clearServerStatsTitle(String serverName) {
String clearServerStatsTitle(Object serverName) {
return '$serverName İstatistiklerini Temizle';
}
@@ -527,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';
@@ -709,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

@@ -27,6 +27,56 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get alreadyLastDir => 'Вже в останньому каталозі.';
@override
String get askAi => 'Запитати ШІ';
@override
String get askAiApiKey => 'Ключ API';
@override
String get askAiAwaitingResponse => 'Очікування відповіді ШІ...';
@override
String get askAiBaseUrl => 'Базова URL';
@override
String get askAiCommandInserted => 'Команду вставлено в термінал';
@override
String askAiConfigMissing(Object fields) {
return 'Налаштуйте $fields у налаштуваннях.';
}
@override
String get askAiConfirmExecute => 'Підтвердити перед виконанням';
@override
String get askAiConversation => 'Розмова з ШІ';
@override
String get askAiDisclaimer => 'ШІ може помилятися. Користуйтеся обережно.';
@override
String get askAiFollowUpHint => 'Поставте додаткове запитання...';
@override
String get askAiInsertTerminal => 'Вставити в термінал';
@override
String get askAiModel => 'Модель';
@override
String get askAiNoResponse => 'Відповідь відсутня';
@override
String get askAiRecommendedCommand => 'Команда, запропонована ШІ';
@override
String get askAiSelectedContent => 'Вибраний вміст';
@override
String get askAiUsageHint => 'Використовується в SSH-терміналі';
@override
String get atLeastOneTab => 'Потрібно вибрати принаймні одну вкладку';
@@ -99,12 +149,12 @@ class AppLocalizationsUk extends AppLocalizations {
String get clearAllStatsTitle => 'Очистити всю статистику';
@override
String clearServerStatsContent(String serverName) {
String clearServerStatsContent(Object serverName) {
return 'Ви впевнені, що хочете очистити статистику з\'єднань для сервера \"$serverName\"? Цю дію не можна скасувати.';
}
@override
String clearServerStatsTitle(String serverName) {
String clearServerStatsTitle(Object serverName) {
return 'Очистити статистику $serverName';
}
@@ -532,6 +582,11 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get privateKey => 'Приватний ключ';
@override
String privateKeyNotFoundFmt(Object keyId) {
return 'Приватний ключ [$keyId] не знайдено.';
}
@override
String get process => 'Процес';
@@ -714,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

@@ -26,6 +26,56 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get alreadyLastDir => '已是顶级目录';
@override
String get askAi => '问 AI';
@override
String get askAiApiKey => 'API 密钥';
@override
String get askAiAwaitingResponse => '等待 AI 响应...';
@override
String get askAiBaseUrl => '基础 URL';
@override
String get askAiCommandInserted => '命令已插入终端';
@override
String askAiConfigMissing(Object fields) {
return '请前往设置配置 $fields';
}
@override
String get askAiConfirmExecute => '执行前确认';
@override
String get askAiConversation => 'AI 对话';
@override
String get askAiDisclaimer => 'AI 可能会犯错,请谨慎使用。';
@override
String get askAiFollowUpHint => '继续提问...';
@override
String get askAiInsertTerminal => '插入终端';
@override
String get askAiModel => '模型';
@override
String get askAiNoResponse => '无回复内容';
@override
String get askAiRecommendedCommand => 'AI 推荐命令';
@override
String get askAiSelectedContent => '选中的内容';
@override
String get askAiUsageHint => '用于 SSH 终端';
@override
String get atLeastOneTab => '至少需要选择一个标签';
@@ -91,12 +141,12 @@ class AppLocalizationsZh extends AppLocalizations {
String get clearAllStatsTitle => '清空所有统计';
@override
String clearServerStatsContent(String serverName) {
String clearServerStatsContent(Object serverName) {
return '确定要清空服务器 \"$serverName\" 的连接统计数据吗?此操作无法撤销。';
}
@override
String clearServerStatsTitle(String serverName) {
String clearServerStatsTitle(Object serverName) {
return '清空 $serverName 统计';
}
@@ -504,6 +554,11 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get privateKey => '私钥';
@override
String privateKeyNotFoundFmt(Object keyId) {
return '未找到私钥 [$keyId]。';
}
@override
String get process => '进程';
@@ -677,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 配置文件?';
@@ -894,6 +977,56 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get alreadyLastDir => '已是頂層目錄';
@override
String get askAi => '詢問 AI';
@override
String get askAiApiKey => 'API 金鑰';
@override
String get askAiAwaitingResponse => '等待 AI 回應...';
@override
String get askAiBaseUrl => '基礎 URL';
@override
String get askAiCommandInserted => '指令已插入終端機';
@override
String askAiConfigMissing(Object fields) {
return '請前往設定配置 $fields';
}
@override
String get askAiConfirmExecute => '執行前確認';
@override
String get askAiConversation => 'AI 對話';
@override
String get askAiDisclaimer => 'AI 可能會犯錯,請謹慎使用。';
@override
String get askAiFollowUpHint => '繼續提問...';
@override
String get askAiInsertTerminal => '插入終端機';
@override
String get askAiModel => '模型';
@override
String get askAiNoResponse => '無回覆內容';
@override
String get askAiRecommendedCommand => 'AI 推薦指令';
@override
String get askAiSelectedContent => '選取的內容';
@override
String get askAiUsageHint => '於 SSH 終端機中使用';
@override
String get atLeastOneTab => '至少需要選擇一個標籤';
@@ -959,12 +1092,12 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String get clearAllStatsTitle => '清空所有統計';
@override
String clearServerStatsContent(String serverName) {
String clearServerStatsContent(Object serverName) {
return '確定要清空伺服器 \"$serverName\" 的連線統計資料嗎?此操作無法撤銷。';
}
@override
String clearServerStatsTitle(String serverName) {
String clearServerStatsTitle(Object serverName) {
return '清空 $serverName 統計';
}
@@ -1372,6 +1505,11 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get privateKey => '私鑰';
@override
String privateKeyNotFoundFmt(Object keyId) {
return '未找到私鑰 [$keyId]。';
}
@override
String get process => '處理程序';
@@ -1545,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

@@ -3,6 +3,7 @@ import 'package:server_box/data/model/app/menu/server_func.dart';
import 'package:server_box/data/model/app/net_view.dart';
import 'package:server_box/data/model/server/custom.dart';
import 'package:server_box/data/model/server/private_key_info.dart';
import 'package:server_box/data/model/server/proxy_command_config.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/snippet.dart';
import 'package:server_box/data/model/server/system.dart';
@@ -19,5 +20,6 @@ import 'package:server_box/data/model/ssh/virtual_key.dart';
AdapterSpec<ServerCustom>(),
AdapterSpec<WakeOnLanCfg>(),
AdapterSpec<SystemType>(),
AdapterSpec<ProxyCommandConfig>(),
])
part 'hive_adapters.g.dart';

View File

@@ -113,13 +113,14 @@ class SpiAdapter extends TypeAdapter<Spi> {
id: fields[13] == null ? '' : fields[13] as String,
customSystemType: fields[14] as SystemType?,
disabledCmdTypes: (fields[15] as List?)?.cast<String>(),
proxyCommand: fields[16] as ProxyCommandConfig?,
);
}
@override
void write(BinaryWriter writer, Spi obj) {
writer
..writeByte(16)
..writeByte(17)
..writeByte(0)
..write(obj.name)
..writeByte(1)
@@ -151,7 +152,9 @@ class SpiAdapter extends TypeAdapter<Spi> {
..writeByte(14)
..write(obj.customSystemType)
..writeByte(15)
..write(obj.disabledCmdTypes);
..write(obj.disabledCmdTypes)
..writeByte(16)
..write(obj.proxyCommand);
}
@override
@@ -604,3 +607,66 @@ class SystemTypeAdapter extends TypeAdapter<SystemType> {
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class ProxyCommandConfigAdapter extends TypeAdapter<ProxyCommandConfig> {
@override
final typeId = 10;
@override
ProxyCommandConfig read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ProxyCommandConfig(
command: fields[0] as String,
args: (fields[1] as List?)?.cast<String>(),
workingDirectory: fields[2] as String?,
environment: (fields[3] as Map?)?.cast<String, String>(),
timeout: fields[4] == null
? const Duration(seconds: 30)
: fields[4] as Duration,
retryOnFailure: fields[5] == null ? false : fields[5] as bool,
maxRetries: fields[6] == null ? 3 : (fields[6] as num).toInt(),
requiresExecutable: fields[7] == null ? false : fields[7] as bool,
executableName: fields[8] as String?,
executableDownloadUrl: fields[9] as String?,
);
}
@override
void write(BinaryWriter writer, ProxyCommandConfig obj) {
writer
..writeByte(10)
..writeByte(0)
..write(obj.command)
..writeByte(1)
..write(obj.args)
..writeByte(2)
..write(obj.workingDirectory)
..writeByte(3)
..write(obj.environment)
..writeByte(4)
..write(obj.timeout)
..writeByte(5)
..write(obj.retryOnFailure)
..writeByte(6)
..write(obj.maxRetries)
..writeByte(7)
..write(obj.requiresExecutable)
..writeByte(8)
..write(obj.executableName)
..writeByte(9)
..write(obj.executableDownloadUrl);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ProxyCommandConfigAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -1,7 +1,7 @@
# Generated by Hive CE
# Manual modifications may be necessary for certain migrations
# Check in to version control
nextTypeId: 10
nextTypeId: 11
types:
PrivateKeyInfo:
typeId: 1
@@ -27,7 +27,7 @@ types:
index: 4
Spi:
typeId: 3
nextIndex: 16
nextIndex: 17
fields:
name:
index: 0
@@ -61,6 +61,8 @@ types:
index: 14
disabledCmdTypes:
index: 15
proxyCommand:
index: 16
VirtKey:
typeId: 4
nextIndex: 45
@@ -221,3 +223,27 @@ types:
index: 1
windows:
index: 2
ProxyCommandConfig:
typeId: 10
nextIndex: 10
fields:
command:
index: 0
args:
index: 1
workingDirectory:
index: 2
environment:
index: 3
timeout:
index: 4
retryOnFailure:
index: 5
maxRetries:
index: 6
requiresExecutable:
index: 7
executableName:
index: 8
executableDownloadUrl:
index: 9

View File

@@ -14,6 +14,7 @@ extension HiveRegistrar on HiveInterface {
registerAdapter(ConnectionStatAdapter());
registerAdapter(NetViewTypeAdapter());
registerAdapter(PrivateKeyInfoAdapter());
registerAdapter(ProxyCommandConfigAdapter());
registerAdapter(ServerConnectionStatsAdapter());
registerAdapter(ServerCustomAdapter());
registerAdapter(ServerFuncBtnAdapter());
@@ -32,6 +33,7 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface {
registerAdapter(ConnectionStatAdapter());
registerAdapter(NetViewTypeAdapter());
registerAdapter(PrivateKeyInfoAdapter());
registerAdapter(ProxyCommandConfigAdapter());
registerAdapter(ServerConnectionStatsAdapter());
registerAdapter(ServerCustomAdapter());
registerAdapter(ServerFuncBtnAdapter());

View File

@@ -1,25 +1,27 @@
{
"@@locale": "de",
"@clearServerStatsContent": {
"placeholders": {
"serverName": {
"type": "String"
}
}
},
"@clearServerStatsTitle": {
"placeholders": {
"serverName": {
"type": "String"
}
}
},
"aboutThanks": "Vielen Dank an die folgenden Personen, die daran teilgenommen haben.\n",
"acceptBeta": "Akzeptieren Sie Testversion-Updates",
"addSystemPrivateKeyTip": "Derzeit haben Sie keinen privaten Schlüssel, fügen Sie den Schlüssel hinzu, der mit dem System geliefert wird (~/.ssh/id_rsa)?",
"added2List": "Zur Aufgabenliste hinzugefügt",
"addr": "Adresse",
"alreadyLastDir": "Bereits im letzten Verzeichnis.",
"askAi": "KI fragen",
"askAiApiKey": "API-Schlüssel",
"askAiAwaitingResponse": "Warte auf KI-Antwort...",
"askAiBaseUrl": "Basis-URL",
"askAiCommandInserted": "Befehl ins Terminal eingefügt",
"askAiConfigMissing": "Bitte konfigurieren Sie {fields} in den Einstellungen.",
"askAiConfirmExecute": "Vor Ausführung bestätigen",
"askAiConversation": "KI-Unterhaltung",
"askAiDisclaimer": "KI kann Fehler machen. Bitte vorsichtig verwenden.",
"askAiFollowUpHint": "Weitere Frage stellen...",
"askAiInsertTerminal": "In Terminal einfügen",
"askAiModel": "Modell",
"askAiNoResponse": "Keine Antwort",
"askAiRecommendedCommand": "KI-empfohlener Befehl",
"askAiSelectedContent": "Ausgewählter Inhalt",
"askAiUsageHint": "Verwendet im SSH-Terminal",
"atLeastOneTab": "Mindestens ein Tab muss ausgewählt sein",
"authFailTip": "Authentifizierung fehlgeschlagen, bitte überprüfen Sie, ob das Passwort/Schlüssel/Host/Benutzer usw. falsch sind.",
"autoBackupConflict": "Es kann nur eine automatische Sicherung gleichzeitig aktiviert werden.",
@@ -169,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",
@@ -223,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.",
@@ -285,4 +294,4 @@
"write": "Schreiben",
"writeScriptFailTip": "Das Schreiben des Skripts ist fehlgeschlagen, möglicherweise aufgrund fehlender Berechtigungen oder das Verzeichnis existiert nicht.",
"writeScriptTip": "Nach der Verbindung mit dem Server wird ein Skript in `~/.config/server_box` \n | `/tmp/server_box` geschrieben, um den Systemstatus zu überwachen. Sie können den Skriptinhalt überprüfen."
}
}

View File

@@ -1,25 +1,27 @@
{
"@@locale": "en",
"@clearServerStatsContent": {
"placeholders": {
"serverName": {
"type": "String"
}
}
},
"@clearServerStatsTitle": {
"placeholders": {
"serverName": {
"type": "String"
}
}
},
"aboutThanks": "Thanks to the following people who participated in.",
"acceptBeta": "Accept beta version updates",
"addSystemPrivateKeyTip": "Currently private keys don't exist, do you want to add the one that comes with the system (~/.ssh/id_rsa)?",
"added2List": "Added to task list",
"addr": "Address",
"alreadyLastDir": "Already in last directory.",
"askAi": "Ask AI",
"askAiApiKey": "API Key",
"askAiAwaitingResponse": "Waiting for AI response...",
"askAiBaseUrl": "Base URL",
"askAiCommandInserted": "Command inserted into terminal",
"askAiConfigMissing": "Please configure {fields} in Settings.",
"askAiConfirmExecute": "Confirm before executing",
"askAiConversation": "AI conversation",
"askAiDisclaimer": "AI may be incorrect. Review carefully before applying.",
"askAiFollowUpHint": "Ask a follow-up...",
"askAiInsertTerminal": "Insert into terminal",
"askAiModel": "Model",
"askAiNoResponse": "No response",
"askAiRecommendedCommand": "AI suggested command",
"askAiSelectedContent": "Selected content",
"askAiUsageHint": "Used in SSH Terminal",
"atLeastOneTab": "At least one tab must be selected",
"authFailTip": "Authentication failed, please check whether credentials are correct",
"autoBackupConflict": "Only one automatic backup can be turned on at the same time.",
@@ -169,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",
@@ -223,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.",
@@ -285,4 +297,4 @@
"write": "Write",
"writeScriptFailTip": "Writing to the script failed, possibly due to lack of permissions or the directory does not exist.",
"writeScriptTip": "After connecting to the server, a script will be written to `~/.config/server_box` \n | `/tmp/server_box` to monitor the system status. You can review the script content."
}
}

View File

@@ -1,25 +1,27 @@
{
"@@locale": "es",
"@clearServerStatsContent": {
"placeholders": {
"serverName": {
"type": "String"
}
}
},
"@clearServerStatsTitle": {
"placeholders": {
"serverName": {
"type": "String"
}
}
},
"aboutThanks": "Gracias a los siguientes participantes.",
"acceptBeta": "Aceptar actualizaciones de la versión de prueba",
"addSystemPrivateKeyTip": "Actualmente no hay ninguna llave privada, ¿quieres agregar la que viene por defecto en el sistema (~/.ssh/id_rsa)?",
"added2List": "Añadido a la lista de tareas",
"addr": "Dirección",
"alreadyLastDir": "Ya estás en el directorio superior",
"askAi": "Preguntar a la IA",
"askAiApiKey": "Clave API",
"askAiAwaitingResponse": "Esperando la respuesta de la IA...",
"askAiBaseUrl": "URL base",
"askAiCommandInserted": "Comando insertado en el terminal",
"askAiConfigMissing": "Configura {fields} en Ajustes.",
"askAiConfirmExecute": "Confirmar antes de ejecutar",
"askAiConversation": "Conversación con la IA",
"askAiDisclaimer": "La IA puede equivocarse. Úsala con precaución.",
"askAiFollowUpHint": "Haz una pregunta adicional...",
"askAiInsertTerminal": "Insertar en el terminal",
"askAiModel": "Modelo",
"askAiNoResponse": "Sin respuesta",
"askAiRecommendedCommand": "Comando sugerido por la IA",
"askAiSelectedContent": "Contenido seleccionado",
"askAiUsageHint": "Usado en el terminal SSH",
"atLeastOneTab": "Al menos una pestaña debe estar seleccionada",
"authFailTip": "La autenticación ha fallado, por favor verifica si la contraseña/llave/host/usuario, etc., son incorrectos.",
"autoBackupConflict": "Solo se puede activar una copia de seguridad automática a la vez",
@@ -169,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",
@@ -223,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.",
@@ -285,4 +294,4 @@
"write": "Escribir",
"writeScriptFailTip": "La escritura en el script falló, posiblemente por falta de permisos o porque el directorio no existe.",
"writeScriptTip": "Después de conectarse al servidor, se escribirá un script en `~/.config/server_box` \n | `/tmp/server_box` para monitorear el estado del sistema. Puedes revisar el contenido del script."
}
}

View File

@@ -1,25 +1,27 @@
{
"@@locale": "fr",
"@clearServerStatsContent": {
"placeholders": {
"serverName": {
"type": "String"
}
}
},
"@clearServerStatsTitle": {
"placeholders": {
"serverName": {
"type": "String"
}
}
},
"aboutThanks": "Merci aux personnes suivantes qui ont participé.",
"acceptBeta": "Accepter les mises à jour de la version de test",
"addSystemPrivateKeyTip": "Actuellement, vous n'avez aucune clé privée. Souhaitez-vous ajouter celle qui vient avec le système (~/.ssh/id_rsa) ?",
"added2List": "Ajouté à la liste des tâches",
"addr": "Adresse",
"alreadyLastDir": "Déjà dans le dernier répertoire.",
"askAi": "Demander à l'IA",
"askAiApiKey": "Clé API",
"askAiAwaitingResponse": "En attente de la réponse de l'IA...",
"askAiBaseUrl": "URL de base",
"askAiCommandInserted": "Commande insérée dans le terminal",
"askAiConfigMissing": "Veuillez configurer {fields} dans les paramètres.",
"askAiConfirmExecute": "Confirmer avant d'exécuter",
"askAiConversation": "Conversation avec l'IA",
"askAiDisclaimer": "L'IA peut se tromper. Utilisez-la avec prudence.",
"askAiFollowUpHint": "Poser une question supplémentaire...",
"askAiInsertTerminal": "Insérer dans le terminal",
"askAiModel": "Modèle",
"askAiNoResponse": "Aucune réponse",
"askAiRecommendedCommand": "Commande suggérée par l'IA",
"askAiSelectedContent": "Contenu sélectionné",
"askAiUsageHint": "Utilisé dans le terminal SSH",
"atLeastOneTab": "Au moins un onglet doit être sélectionné",
"authFailTip": "Échec de l'authentification. Veuillez vérifier si le mot de passe/clé/hôte/utilisateur, etc., est incorrect.",
"autoBackupConflict": "Un seul sauvegarde automatique peut être activé en même temps.",
@@ -169,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",
@@ -223,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.",
@@ -285,4 +294,4 @@
"write": "Écrire",
"writeScriptFailTip": "Échec de l'écriture dans le script, probablement en raison d'un manque de permissions ou que le répertoire n'existe pas.",
"writeScriptTip": "Après la connexion au serveur, un script sera écrit dans `~/.config/server_box` \n | `/tmp/server_box` pour surveiller l'état du système. Vous pouvez examiner le contenu du script."
}
}

View File

@@ -1,25 +1,27 @@
{
"@@locale": "id",
"@clearServerStatsContent": {
"placeholders": {
"serverName": {
"type": "String"
}
}
},
"@clearServerStatsTitle": {
"placeholders": {
"serverName": {
"type": "String"
}
}
},
"aboutThanks": "Terima kasih kepada orang -orang berikut yang berpartisipasi.",
"acceptBeta": "Terima pembaruan versi uji coba",
"addSystemPrivateKeyTip": "Saat ini tidak memiliki kunci privat, apakah Anda menambahkan kunci yang disertakan dengan sistem (~/.ssh/id_rsa)?",
"added2List": "Ditambahkan ke Daftar Tugas",
"addr": "Alamat",
"alreadyLastDir": "Sudah di direktori terakhir.",
"askAi": "Tanya AI",
"askAiApiKey": "Kunci API",
"askAiAwaitingResponse": "Menunggu respons AI...",
"askAiBaseUrl": "URL dasar",
"askAiCommandInserted": "Perintah dimasukkan ke terminal",
"askAiConfigMissing": "Harap konfigurasikan {fields} di Pengaturan.",
"askAiConfirmExecute": "Konfirmasi sebelum menjalankan",
"askAiConversation": "Percakapan AI",
"askAiDisclaimer": "AI bisa saja salah. Gunakan dengan hati-hati.",
"askAiFollowUpHint": "Ajukan pertanyaan lanjutan...",
"askAiInsertTerminal": "Masukkan ke terminal",
"askAiModel": "Model",
"askAiNoResponse": "Tidak ada respons",
"askAiRecommendedCommand": "Perintah yang disarankan AI",
"askAiSelectedContent": "Konten yang dipilih",
"askAiUsageHint": "Digunakan di Terminal SSH",
"atLeastOneTab": "Setidaknya satu tab harus dipilih",
"authFailTip": "Otentikasi gagal, silakan periksa apakah kata sandi/kunci/host/pengguna, dll, salah.",
"autoBackupConflict": "Hanya satu pencadangan otomatis yang dapat diaktifkan pada saat yang bersamaan.",
@@ -169,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",
@@ -223,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.",
@@ -285,4 +294,4 @@
"write": "Tulis",
"writeScriptFailTip": "Penulisan ke skrip gagal, mungkin karena tidak ada izin atau direktori tidak ada.",
"writeScriptTip": "Setelah terhubung ke server, sebuah skrip akan ditulis ke `~/.config/server_box` \n | `/tmp/server_box` untuk memantau status sistem. Anda dapat meninjau konten skrip tersebut."
}
}

View File

@@ -1,25 +1,27 @@
{
"@@locale": "ja",
"@clearServerStatsContent": {
"placeholders": {
"serverName": {
"type": "String"
}
}
},
"@clearServerStatsTitle": {
"placeholders": {
"serverName": {
"type": "String"
}
}
},
"aboutThanks": "以下の参加者に感謝します。",
"acceptBeta": "テストバージョンの更新を受け入れる",
"addSystemPrivateKeyTip": "現在秘密鍵がありません。システムのデフォルト(~/.ssh/id_rsa)を追加しますか?",
"added2List": "タスクリストに追加されました",
"addr": "アドレス",
"alreadyLastDir": "すでに最上位のディレクトリです",
"askAi": "AI に質問",
"askAiApiKey": "API キー",
"askAiAwaitingResponse": "AI の応答を待機中...",
"askAiBaseUrl": "ベース URL",
"askAiCommandInserted": "コマンドをターミナルに挿入しました",
"askAiConfigMissing": "設定で {fields} を構成してください。",
"askAiConfirmExecute": "実行前に確認",
"askAiConversation": "AI 会話",
"askAiDisclaimer": "AI が誤る可能性があります。注意してご利用ください。",
"askAiFollowUpHint": "追質問をする...",
"askAiInsertTerminal": "ターミナルに挿入",
"askAiModel": "モデル",
"askAiNoResponse": "応答なし",
"askAiRecommendedCommand": "AI 推奨コマンド",
"askAiSelectedContent": "選択した内容",
"askAiUsageHint": "SSH ターミナルで使用",
"atLeastOneTab": "少なくとも1つのタブを選択する必要があります",
"authFailTip": "認証に失敗しました。パスワード/鍵/ホスト/ユーザーなどが間違っていないか確認してください。",
"autoBackupConflict": "自動バックアップは一度に一つしか開始できません",
@@ -169,6 +171,7 @@
"port": "ポート",
"preferDiskAmount": "ディスク容量を優先的に表示",
"privateKey": "秘密鍵",
"privateKeyNotFoundFmt": "秘密鍵 [{keyId}] が見つかりません。",
"process": "プロセス",
"prune": "剪定する",
"pushToken": "プッシュトークン",
@@ -223,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設定ファイルにアクセスできません。",
@@ -285,4 +294,4 @@
"write": "書き込み",
"writeScriptFailTip": "スクリプトの書き込みに失敗しました。権限がないかディレクトリが存在しない可能性があります。",
"writeScriptTip": "サーバーに接続すると、システムの状態を監視するためのスクリプトが `~/.config/server_box` \n | `/tmp/server_box` に書き込まれます。スクリプトの内容を確認できます。"
}
}

View File

@@ -1,25 +1,27 @@
{
"@@locale": "nl",
"@clearServerStatsContent": {
"placeholders": {
"serverName": {
"type": "String"
}
}
},
"@clearServerStatsTitle": {
"placeholders": {
"serverName": {
"type": "String"
}
}
},
"aboutThanks": "Met dank aan de volgende mensen die hebben deelgenomen aan.",
"acceptBeta": "Accepteer testversie-updates",
"addSystemPrivateKeyTip": "Er is momenteel geen privésleutel, wilt u degene toevoegen die bij het systeem wordt geleverd (~/.ssh/id_rsa)?",
"added2List": "Toegevoegd aan takenlijst",
"addr": "Adres",
"alreadyLastDir": "Al in de laatst gebruikte map.",
"askAi": "AI vragen",
"askAiApiKey": "API-sleutel",
"askAiAwaitingResponse": "Wachten op AI-reactie...",
"askAiBaseUrl": "Basis-URL",
"askAiCommandInserted": "Commando in terminal ingevoegd",
"askAiConfigMissing": "Configureer {fields} in de instellingen.",
"askAiConfirmExecute": "Bevestigen voor uitvoeren",
"askAiConversation": "AI-gesprek",
"askAiDisclaimer": "AI kan fouten maken. Gebruik het zorgvuldig.",
"askAiFollowUpHint": "Stel een vervolgvraag...",
"askAiInsertTerminal": "In terminal invoegen",
"askAiModel": "Model",
"askAiNoResponse": "Geen reactie",
"askAiRecommendedCommand": "Door AI voorgestelde opdracht",
"askAiSelectedContent": "Geselecteerde inhoud",
"askAiUsageHint": "Gebruikt in de SSH-terminal",
"atLeastOneTab": "Er moet minimaal één tabblad worden geselecteerd",
"authFailTip": "Authenticatie mislukt, controleer of het wachtwoord/sleutel/host/gebruiker, enz., incorrect zijn.",
"autoBackupConflict": "Er kan slechts één automatische back-up tegelijk worden ingeschakeld.",
@@ -169,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",
@@ -223,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.",
@@ -285,4 +294,4 @@
"write": "Schrijven",
"writeScriptFailTip": "Het schrijven naar het script is mislukt, mogelijk door gebrek aan rechten of omdat de map niet bestaat.",
"writeScriptTip": "Na het verbinden met de server wordt een script geschreven naar `~/.config/server_box` \n | `/tmp/server_box` om de systeemstatus te monitoren. U kunt de inhoud van het script controleren."
}
}

View File

@@ -1,25 +1,27 @@
{
"@@locale": "pt",
"@clearServerStatsContent": {
"placeholders": {
"serverName": {
"type": "String"
}
}
},
"@clearServerStatsTitle": {
"placeholders": {
"serverName": {
"type": "String"
}
}
},
"aboutThanks": "Agradecimentos a todos os participantes.",
"acceptBeta": "Aceitar atualizações da versão de teste",
"addSystemPrivateKeyTip": "Atualmente, não há nenhuma chave privada. Gostaria de adicionar a chave do sistema (~/.ssh/id_rsa)?",
"added2List": "Adicionado à lista de tarefas",
"addr": "Endereço",
"alreadyLastDir": "Já é o diretório mais alto",
"askAi": "Perguntar à IA",
"askAiApiKey": "Chave de API",
"askAiAwaitingResponse": "Aguardando resposta da IA...",
"askAiBaseUrl": "URL base",
"askAiCommandInserted": "Comando inserido no terminal",
"askAiConfigMissing": "Configure {fields} nas configurações.",
"askAiConfirmExecute": "Confirmar antes de executar",
"askAiConversation": "Conversa com a IA",
"askAiDisclaimer": "A IA pode errar. Use com cautela.",
"askAiFollowUpHint": "Faça uma pergunta adicional...",
"askAiInsertTerminal": "Inserir no terminal",
"askAiModel": "Modelo",
"askAiNoResponse": "Sem resposta",
"askAiRecommendedCommand": "Comando sugerido pela IA",
"askAiSelectedContent": "Conteúdo selecionado",
"askAiUsageHint": "Usado no terminal SSH",
"atLeastOneTab": "Pelo menos uma aba deve ser selecionada",
"authFailTip": "Autenticação falhou, por favor verifique se a senha/chave/host/usuário, etc., estão incorretos.",
"autoBackupConflict": "Apenas um backup automático pode ser ativado por vez",
@@ -169,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",
@@ -223,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.",
@@ -285,4 +294,4 @@
"write": "Escrita",
"writeScriptFailTip": "Falha ao escrever no script, possivelmente devido à falta de permissões ou o diretório não existe.",
"writeScriptTip": "Após conectar ao servidor, um script será escrito em `~/.config/server_box` \n | `/tmp/server_box` para monitorar o status do sistema. Você pode revisar o conteúdo do script."
}
}

View File

@@ -1,25 +1,27 @@
{
"@@locale": "ru",
"@clearServerStatsContent": {
"placeholders": {
"serverName": {
"type": "String"
}
}
},
"@clearServerStatsTitle": {
"placeholders": {
"serverName": {
"type": "String"
}
}
},
"aboutThanks": "Благодарности всем участникам.",
"acceptBeta": "Принять обновления тестовой версии",
"addSystemPrivateKeyTip": "В данный момент приватные ключи отсутствуют. Добавить системный приватный ключ (~/.ssh/id_rsa)?",
"added2List": "Добавлено в список задач",
"addr": "Адрес",
"alreadyLastDir": "Уже в корневом каталоге",
"askAi": "Спросить ИИ",
"askAiApiKey": "Ключ API",
"askAiAwaitingResponse": "Ожидание ответа ИИ...",
"askAiBaseUrl": "Базовый URL",
"askAiCommandInserted": "Команда вставлена в терминал",
"askAiConfigMissing": "Настройте {fields} в настройках.",
"askAiConfirmExecute": "Подтвердите перед выполнением",
"askAiConversation": "Разговор с ИИ",
"askAiDisclaimer": "ИИ может ошибаться. Используйте с осторожностью.",
"askAiFollowUpHint": "Задайте дополнительный вопрос...",
"askAiInsertTerminal": "Вставить в терминал",
"askAiModel": "Модель",
"askAiNoResponse": "Нет ответа",
"askAiRecommendedCommand": "Команда, предложенная ИИ",
"askAiSelectedContent": "Выбранное содержимое",
"askAiUsageHint": "Используется в SSH-терминале",
"atLeastOneTab": "Должна быть выбрана хотя бы одна вкладка",
"authFailTip": "Аутентификация не удалась, пожалуйста, проверьте, правильны ли пароль/ключ/хост/пользователь и т.д.",
"autoBackupConflict": "Может быть включено только одно автоматическое резервное копирование",
@@ -169,6 +171,7 @@
"port": "Порт",
"preferDiskAmount": "Приоритетное отображение объёма диска",
"privateKey": "Приватный ключ",
"privateKeyNotFoundFmt": "Закрытый ключ [{keyId}] не найден.",
"process": "Процесс",
"prune": "Обрезать",
"pushToken": "Токен уведомлений",
@@ -223,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.",
@@ -285,4 +294,4 @@
"write": "Запись",
"writeScriptFailTip": "Запись скрипта не удалась, возможно, из-за отсутствия прав или потому что, директории не существует.",
"writeScriptTip": "После подключения к серверу скрипт будет записан в `~/.config/server_box` \n | `/tmp/server_box` для мониторинга состояния системы. Вы можете проверить содержимое скрипта."
}
}

View File

@@ -1,25 +1,27 @@
{
"@@locale": "tr",
"@clearServerStatsContent": {
"placeholders": {
"serverName": {
"type": "String"
}
}
},
"@clearServerStatsTitle": {
"placeholders": {
"serverName": {
"type": "String"
}
}
},
"aboutThanks": "Aşağıdaki katılımcılara teşekkürler.",
"acceptBeta": "Beta sürüm güncellemelerini kabul et",
"addSystemPrivateKeyTip": "Şu anda özel anahtarlar mevcut değil, sistemle birlikte gelen anahtarı (~/.ssh/id_rsa) eklemek ister misiniz?",
"added2List": "Görev listesine eklendi",
"addr": "Adres",
"alreadyLastDir": "Zaten son dizindesiniz.",
"askAi": "Yapay zekaya sor",
"askAiApiKey": "API anahtarı",
"askAiAwaitingResponse": "Yapay zekâ yanıtı bekleniyor...",
"askAiBaseUrl": "Temel URL",
"askAiCommandInserted": "Komut terminale eklendi",
"askAiConfigMissing": "Lütfen Ayarlar'da {fields} öğesini yapılandırın.",
"askAiConfirmExecute": "Çalıştırmadan önce onayla",
"askAiConversation": "YZ sohbeti",
"askAiDisclaimer": "Yapay zeka hata yapabilir. Lütfen dikkatli kullanın.",
"askAiFollowUpHint": "Yeni bir soru sor...",
"askAiInsertTerminal": "Terminale ekle",
"askAiModel": "Model",
"askAiNoResponse": "Yanıt yok",
"askAiRecommendedCommand": "YZ önerilen komut",
"askAiSelectedContent": "Seçilen içerik",
"askAiUsageHint": "SSH Terminalinde kullanılır",
"atLeastOneTab": "En az bir sekme seçilmelidir",
"authFailTip": "Kimlik doğrulama başarısız oldu, lütfen kimlik bilgilerinin doğru olup olmadığını kontrol edin",
"autoBackupConflict": "Aynı anda yalnızca bir otomatik yedekleme açık olabilir.",
@@ -169,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",
@@ -223,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.",
@@ -285,4 +294,4 @@
"write": "Yaz",
"writeScriptFailTip": "Betik yazma başarısız oldu, muhtemelen izin eksikliği veya dizin mevcut değil.",
"writeScriptTip": "Sunucuya bağlandıktan sonra, sistem durumunu izlemek için `~/.config/server_box` \n | `/tmp/server_box` dizinine bir betik yazılacak. Betik içeriğini inceleyebilirsiniz."
}
}

View File

@@ -1,25 +1,27 @@
{
"@@locale": "uk",
"@clearServerStatsContent": {
"placeholders": {
"serverName": {
"type": "String"
}
}
},
"@clearServerStatsTitle": {
"placeholders": {
"serverName": {
"type": "String"
}
}
},
"aboutThanks": "Дякуємо наступним особам, які взяли участь.",
"acceptBeta": "Прийняти оновлення бета-версії",
"addSystemPrivateKeyTip": "Наразі приватних ключів нема, хочете додати той, що йде з системою (~/.ssh/id_rsa)?",
"added2List": "Додано до списку завдань",
"addr": "Адреса",
"alreadyLastDir": "Вже в останньому каталозі.",
"askAi": "Запитати ШІ",
"askAiApiKey": "Ключ API",
"askAiAwaitingResponse": "Очікування відповіді ШІ...",
"askAiBaseUrl": "Базова URL",
"askAiCommandInserted": "Команду вставлено в термінал",
"askAiConfigMissing": "Налаштуйте {fields} у налаштуваннях.",
"askAiConfirmExecute": "Підтвердити перед виконанням",
"askAiConversation": "Розмова з ШІ",
"askAiDisclaimer": "ШІ може помилятися. Користуйтеся обережно.",
"askAiFollowUpHint": "Поставте додаткове запитання...",
"askAiInsertTerminal": "Вставити в термінал",
"askAiModel": "Модель",
"askAiNoResponse": "Відповідь відсутня",
"askAiRecommendedCommand": "Команда, запропонована ШІ",
"askAiSelectedContent": "Вибраний вміст",
"askAiUsageHint": "Використовується в SSH-терміналі",
"atLeastOneTab": "Потрібно вибрати принаймні одну вкладку",
"authFailTip": "Авторизація не вдалася, будь ласка, перевірте правильність облікових даних",
"autoBackupConflict": "Тільки одне автоматичне резервне копіювання може бути активне одночасно.",
@@ -169,6 +171,7 @@
"port": "Порт",
"preferDiskAmount": "Пріоритетно показувати ємність диска",
"privateKey": "Приватний ключ",
"privateKeyNotFoundFmt": "Приватний ключ [{keyId}] не знайдено.",
"process": "Процес",
"prune": "Обрізати",
"pushToken": "Надіслати токен",
@@ -223,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.",
@@ -285,4 +294,4 @@
"write": "Записати",
"writeScriptFailTip": "Запис у скрипт не вдався, можливо, через брак дозволів або каталог не існує.",
"writeScriptTip": "Після підключення до сервера скрипт буде записано у `~/.config/server_box` \n | `/tmp/server_box` для моніторингу стану системи. Ви можете переглянути вміст скрипта."
}
}

View File

@@ -1,25 +1,27 @@
{
"@@locale": "zh",
"@clearServerStatsContent": {
"placeholders": {
"serverName": {
"type": "String"
}
}
},
"@clearServerStatsTitle": {
"placeholders": {
"serverName": {
"type": "String"
}
}
},
"aboutThanks": "感谢以下参与的各位。",
"acceptBeta": "接受测试版更新推送",
"addSystemPrivateKeyTip": "检测到暂无私钥,是否添加系统默认的私钥(~/.ssh/id_rsa",
"added2List": "已添加至任务列表",
"addr": "地址",
"alreadyLastDir": "已是顶级目录",
"askAi": "问 AI",
"askAiApiKey": "API 密钥",
"askAiAwaitingResponse": "等待 AI 响应...",
"askAiBaseUrl": "基础 URL",
"askAiCommandInserted": "命令已插入终端",
"askAiConfigMissing": "请前往设置配置 {fields}",
"askAiConfirmExecute": "执行前确认",
"askAiConversation": "AI 对话",
"askAiDisclaimer": "AI 可能会犯错,请谨慎使用。",
"askAiFollowUpHint": "继续提问...",
"askAiInsertTerminal": "插入终端",
"askAiModel": "模型",
"askAiNoResponse": "无回复内容",
"askAiRecommendedCommand": "AI 推荐命令",
"askAiSelectedContent": "选中的内容",
"askAiUsageHint": "用于 SSH 终端",
"atLeastOneTab": "至少需要选择一个标签",
"authFailTip": "认证失败,请检查连接信息是否正确",
"autoBackupConflict": "仅可启用一个自动备份任务",
@@ -169,6 +171,7 @@
"port": "端口",
"preferDiskAmount": "优先显示硬盘容量",
"privateKey": "私钥",
"privateKeyNotFoundFmt": "未找到私钥 [{keyId}]。",
"process": "进程",
"prune": "修剪",
"pushToken": "消息推送 Token",
@@ -223,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 配置文件。",
@@ -285,4 +294,4 @@
"write": "写",
"writeScriptFailTip": "写入脚本失败,可能是没有权限/目录不存在等",
"writeScriptTip": "在连接服务器后,会向 `~/.config/server_box` \n | `/tmp/server_box` 写入脚本来监测系统状态,你可以审查脚本内容。"
}
}

View File

@@ -1,25 +1,27 @@
{
"@@locale": "zh_TW",
"@clearServerStatsContent": {
"placeholders": {
"serverName": {
"type": "String"
}
}
},
"@clearServerStatsTitle": {
"placeholders": {
"serverName": {
"type": "String"
}
}
},
"aboutThanks": "感謝以下參與的各位。",
"acceptBeta": "接受測試版更新推送",
"addSystemPrivateKeyTip": "偵測到尚無私鑰,是否要加入系統預設的私鑰(~/.ssh/id_rsa",
"added2List": "已新增至任務清單",
"addr": "位址",
"alreadyLastDir": "已是頂層目錄",
"askAi": "詢問 AI",
"askAiApiKey": "API 金鑰",
"askAiAwaitingResponse": "等待 AI 回應...",
"askAiBaseUrl": "基礎 URL",
"askAiCommandInserted": "指令已插入終端機",
"askAiConfigMissing": "請前往設定配置 {fields}",
"askAiConfirmExecute": "執行前確認",
"askAiConversation": "AI 對話",
"askAiDisclaimer": "AI 可能會犯錯,請謹慎使用。",
"askAiFollowUpHint": "繼續提問...",
"askAiInsertTerminal": "插入終端機",
"askAiModel": "模型",
"askAiNoResponse": "無回覆內容",
"askAiRecommendedCommand": "AI 推薦指令",
"askAiSelectedContent": "選取的內容",
"askAiUsageHint": "於 SSH 終端機中使用",
"atLeastOneTab": "至少需要選擇一個標籤",
"authFailTip": "認證失敗,請檢查連線資訊是否正確",
"autoBackupConflict": "僅能啟用一項自動備份任務",
@@ -169,6 +171,7 @@
"port": "埠",
"preferDiskAmount": "優先顯示硬碟容量",
"privateKey": "私鑰",
"privateKeyNotFoundFmt": "未找到私鑰 [{keyId}]。",
"process": "處理程序",
"prune": "修剪",
"pushToken": "消息推送 Token",
@@ -223,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 設定檔案。",
@@ -285,4 +294,4 @@
"write": "寫入",
"writeScriptFailTip": "寫入腳本失敗,可能是沒有權限/目錄不存在等。",
"writeScriptTip": "連線到伺服器後,將會在 `~/.config/server_box` \n | `/tmp/server_box` 中寫入一個腳本來監測系統狀態。你可以審查腳本內容。"
}
}

View File

@@ -10,6 +10,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:logging/logging.dart';
import 'package:server_box/app.dart';
import 'package:server_box/core/utils/executable_manager.dart';
import 'package:server_box/data/model/app/menu/server_func.dart';
import 'package:server_box/data/model/app/server_detail_card.dart';
import 'package:server_box/data/res/build_data.dart';
@@ -57,6 +58,9 @@ Future<void> _initData() async {
await PrefStore.shared.init(); // Call this before accessing any store
await Stores.init();
// Initialize executable manager
await ExecutableManager.initialize();
// It may effect the following logic, so await it.
// DO DB migration before load any provider.
await _doDbMigrate();

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,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)),
),
],
);

View File

@@ -265,6 +265,54 @@ extension _Actions on _ServerEditPageState {
}
}
// ProxyCommand configuration
ProxyCommandConfig? proxyCommand;
if (!Platform.isIOS && _proxyCommandEnabled.value) {
final command = _proxyCommandController.text.trim();
if (command.isEmpty) {
context.showSnackBar('ProxyCommand is enabled but command is empty');
return;
}
// Check if command contains required placeholders
if (!command.contains('%h')) {
context.showSnackBar('ProxyCommand must contain %h (hostname) placeholder');
return;
}
List<String> tokens;
try {
tokens = ProxyCommandExecutor.tokenizeCommand(command);
} on ProxyCommandException catch (e) {
context.showSnackBar(e.message);
return;
}
if (tokens.isEmpty) {
context.showSnackBar('ProxyCommand must not be empty');
return;
}
// Determine if this requires an executable
final executableToken = tokens.first;
var normalized = p.basename(executableToken).toLowerCase();
if (normalized.endsWith('.exe')) {
normalized = normalized.substring(0, normalized.length - 4);
}
const builtinExecutables = {'ssh', 'nc', 'socat'};
final requiresExecutable = !builtinExecutables.contains(normalized);
proxyCommand = ProxyCommandConfig(
command: command,
timeout: Duration(seconds: _proxyCommandTimeout.value),
retryOnFailure: true,
requiresExecutable: requiresExecutable,
executableName: requiresExecutable ? executableToken : null,
);
} else if (Platform.isIOS && _proxyCommandEnabled.value) {
context.showSnackBar('ProxyCommand is not supported on iOS');
return;
}
final spi = Spi(
name: _nameController.text.isEmpty ? _ipController.text : _nameController.text,
ip: _ipController.text,
@@ -284,6 +332,7 @@ extension _Actions on _ServerEditPageState {
id: widget.args?.spi.id ?? ShortId.generate(),
customSystemType: _systemType.value,
disabledCmdTypes: _disabledCmdTypes.value.isEmpty ? null : _disabledCmdTypes.value.toList(),
proxyCommand: proxyCommand,
);
if (this.spi == null) {
@@ -450,5 +499,34 @@ extension _Utils on _ServerEditPageState {
final allAvailableCmdTypes = ShellCmdType.all.map((e) => e.displayName);
disabledCmdTypes.removeWhere((e) => !allAvailableCmdTypes.contains(e));
_disabledCmdTypes.value = disabledCmdTypes;
// Load ProxyCommand configuration
final proxyCommand = spi.proxyCommand;
if (proxyCommand != null && !Platform.isIOS) {
_proxyCommandEnabled.value = true;
_proxyCommandController.text = proxyCommand.command;
_proxyCommandTimeout.value = proxyCommand.timeout.inSeconds;
// Try to match with a preset
final presets = ProxyCommandExecutor.getPresets();
for (final entry in presets.entries) {
if (entry.value.command == proxyCommand.command) {
_proxyCommandPreset.value = entry.key;
break;
}
}
} else {
_proxyCommandEnabled.value = false;
_proxyCommandController.text = '';
_proxyCommandTimeout.value = 30;
_proxyCommandPreset.value = null;
}
if (Platform.isIOS) {
_proxyCommandEnabled.value = false;
_proxyCommandController.text = '';
_proxyCommandTimeout.value = 30;
_proxyCommandPreset.value = null;
}
}
}

View File

@@ -7,13 +7,16 @@ import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:icons_plus/icons_plus.dart';
import 'package:path/path.dart' as p;
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/core/route.dart';
import 'package:server_box/core/utils/proxy_command_executor.dart';
import 'package:server_box/core/utils/server_dedup.dart';
import 'package:server_box/core/utils/ssh_config.dart';
import 'package:server_box/data/model/app/scripts/cmd_types.dart';
import 'package:server_box/data/model/server/custom.dart';
import 'package:server_box/data/model/server/discovery_result.dart';
import 'package:server_box/data/model/server/proxy_command_config.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/system.dart';
import 'package:server_box/data/model/server/wol_cfg.dart';
@@ -74,6 +77,12 @@ class _ServerEditPageState extends ConsumerState<ServerEditPage> with AfterLayou
final _systemType = ValueNotifier<SystemType?>(null);
final _disabledCmdTypes = <String>{}.vn;
// ProxyCommand fields
final _proxyCommandEnabled = ValueNotifier(false);
final _proxyCommandController = TextEditingController();
final _proxyCommandPreset = nvn<String>();
final _proxyCommandTimeout = ValueNotifier(30);
@override
void dispose() {
super.dispose();
@@ -107,6 +116,11 @@ class _ServerEditPageState extends ConsumerState<ServerEditPage> with AfterLayou
_tags.dispose();
_systemType.dispose();
_disabledCmdTypes.dispose();
_proxyCommandEnabled.dispose();
_proxyCommandController.dispose();
_proxyCommandPreset.dispose();
_proxyCommandTimeout.dispose();
}
@override
@@ -200,6 +214,7 @@ class _ServerEditPageState extends ConsumerState<ServerEditPage> with AfterLayou
_buildAuth(),
_buildSystemType(),
_buildJumpServer(),
_buildProxyCommand(),
_buildMore(),
];
return AutoMultiList(children: children);

View File

@@ -449,6 +449,123 @@ extension _Widgets on _ServerEditPageState {
);
}
Widget _buildProxyCommand() {
if (Platform.isIOS) {
return ListTile(
title: const Text('ProxyCommand'),
subtitle: const Text('ProxyCommand is not available on iOS'),
trailing: const Icon(Icons.block, color: Colors.grey),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
title: Text('ProxyCommand'),
subtitle: Text('Use a proxy command for SSH connection'),
trailing: _proxyCommandEnabled.listenVal(
(enabled) => Switch(
value: enabled,
onChanged: (value) {
_proxyCommandEnabled.value = value;
if (value && _proxyCommandController.text.isEmpty) {
// Set default preset when enabled
_proxyCommandPreset.value = 'cloudflare_access';
_proxyCommandController.text = 'cloudflared access ssh --hostname %h';
}
},
),
),
),
_proxyCommandEnabled.listenVal((enabled) {
if (!enabled) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Preset selection
Text('Presets:', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
_proxyCommandPreset.listenVal((preset) {
final presets = ProxyCommandExecutor.getPresets();
return Wrap(
spacing: 8,
runSpacing: 8,
children: presets.entries.map((entry) {
final isSelected = preset == entry.key;
return FilterChip(
label: Text(_getPresetDisplayName(entry.key)),
selected: isSelected,
onSelected: (selected) {
if (selected) {
_proxyCommandPreset.value = entry.key;
_proxyCommandController.text = entry.value.command;
}
},
);
}).toList(),
);
}),
const SizedBox(height: 16),
// Custom command input
Input(
controller: _proxyCommandController,
type: TextInputType.text,
label: 'Proxy Command',
icon: Icons.settings_ethernet,
hint: 'e.g., cloudflared access ssh --hostname %h',
suggestion: false,
),
const SizedBox(height: 8),
// Help text
Text(
'Available placeholders:\n'
'• %h - hostname\n'
'• %p - port\n'
'• %r - username',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
const SizedBox(height: 16),
// Timeout setting
_proxyCommandTimeout.listenVal((timeout) {
return ListTile(
title: Text('Connection Timeout'),
subtitle: Text('$timeout seconds'),
trailing: DropdownButton<int>(
value: timeout,
items: [10, 30, 60, 120].map((seconds) {
return DropdownMenuItem(
value: seconds,
child: Text('${seconds}s'),
);
}).toList(),
onChanged: (value) {
if (value != null) {
_proxyCommandTimeout.value = value;
}
},
),
);
}),
],
),
);
}),
],
);
}
Widget _buildDelBtn() {
return IconButton(
onPressed: () {
@@ -472,4 +589,21 @@ extension _Widgets on _ServerEditPageState {
icon: const Icon(Icons.delete),
);
}
String _getPresetDisplayName(String presetKey) {
switch (presetKey) {
case 'cloudflare_access':
return 'Cloudflare Access';
case 'ssh_via_bastion':
return 'SSH via Bastion';
case 'nc_netcat':
return 'Netcat';
case 'socat':
return 'Socat';
default:
return presetKey.split('_').map((word) =>
word[0].toUpperCase() + word.substring(1)
).join(' ');
}
}
}

View File

@@ -0,0 +1,95 @@
part of '../entry.dart';
extension _AI on _AppSettingsPageState {
Widget _buildAskAiConfig() {
final l10n = context.l10n;
return ExpandTile(
leading: const Icon(LineAwesome.robot_solid, size: _kIconSize),
title: TipText(l10n.askAi, l10n.askAiUsageHint),
children: [
_setting.askAiBaseUrl.listenable().listenVal((val) {
final display = val.isEmpty ? libL10n.empty : val;
return ListTile(
leading: const Icon(MingCute.link_2_line),
title: Text(l10n.askAiBaseUrl),
subtitle: Text(display, style: UIs.textGrey, maxLines: 2, overflow: TextOverflow.ellipsis),
onTap: () => _showAskAiFieldDialog(
prop: _setting.askAiBaseUrl,
title: l10n.askAiBaseUrl,
hint: 'https://api.openai.com',
),
);
}),
_setting.askAiModel.listenable().listenVal((val) {
final display = val.isEmpty ? libL10n.empty : val;
return ListTile(
leading: const Icon(Icons.view_module),
title: Text(l10n.askAiModel),
subtitle: Text(display, style: UIs.textGrey),
onTap: () => _showAskAiFieldDialog(
prop: _setting.askAiModel,
title: l10n.askAiModel,
hint: 'gpt-4o-mini',
),
);
}),
_setting.askAiApiKey.listenable().listenVal((val) {
final hasKey = val.isNotEmpty;
return ListTile(
leading: const Icon(MingCute.key_2_line),
title: Text(l10n.askAiApiKey),
subtitle: Text(hasKey ? '••••••••' : libL10n.empty, style: UIs.textGrey),
onTap: () => _showAskAiFieldDialog(
prop: _setting.askAiApiKey,
title: l10n.askAiApiKey,
hint: 'sk-...',
obscure: true,
),
);
}),
],
).cardx;
}
Future<void> _showAskAiFieldDialog({
required HiveProp<String> prop,
required String title,
required String hint,
bool obscure = false,
}) async {
return withTextFieldController((ctrl) async {
final fetched = prop.fetch();
if (fetched != null && fetched.isNotEmpty) ctrl.text = fetched;
void onSave() {
prop.put(ctrl.text.trim());
context.pop();
}
await context.showRoundDialog(
title: title,
child: Input(
controller: ctrl,
autoFocus: true,
label: title,
hint: hint,
icon: obscure ? MingCute.key_2_line : Icons.edit,
obscureText: obscure,
suggestion: !obscure,
onSubmitted: (_) => onSave(),
),
actions: [
TextButton(
onPressed: () {
prop.delete();
context.pop();
},
child: Text(libL10n.clear),
),
TextButton(onPressed: onSave, child: Text(libL10n.ok)),
],
);
});
}
}

View File

@@ -92,37 +92,37 @@ extension _App on _AppSettingsPageState {
trailing: _setting.colorSeed.listenable().listenVal((_) {
return ClipOval(child: Container(color: UIs.primaryColor, height: 27, width: 27));
}),
onTap: () async {
final ctrl = TextEditingController(text: UIs.primaryColor.toHex);
await context.showRoundDialog(
title: libL10n.primaryColorSeed,
child: StatefulBuilder(
builder: (context, setState) {
final children = <Widget>[
/// Plugin [dynamic_color] is not supported on iOS
if (!isIOS)
ListTile(
title: Text(l10n.followSystem),
trailing: StoreSwitch(
prop: _setting.useSystemPrimaryColor,
callback: (_) => setState(() {}),
onTap: () {
withTextFieldController((ctrl) async {
await context.showRoundDialog(
title: libL10n.primaryColorSeed,
child: StatefulBuilder(
builder: (context, setState) {
final children = <Widget>[
/// Plugin [dynamic_color] is not supported on iOS
if (!isIOS)
ListTile(
title: Text(l10n.followSystem),
trailing: StoreSwitch(
prop: _setting.useSystemPrimaryColor,
callback: (_) => setState(() {}),
),
),
),
];
if (!_setting.useSystemPrimaryColor.fetch()) {
children.add(
ColorPicker(
color: Color(_setting.colorSeed.fetch()),
onColorChanged: (c) => ctrl.text = c.toHex,
),
);
}
return Column(mainAxisSize: MainAxisSize.min, children: children);
},
),
actions: Btn.ok(onTap: () => _onSaveColor(ctrl.text)).toList,
);
ctrl.dispose();
];
if (!_setting.useSystemPrimaryColor.fetch()) {
children.add(
ColorPicker(
color: Color(_setting.colorSeed.fetch()),
onColorChanged: (c) => ctrl.text = c.toHex,
),
);
}
return Column(mainAxisSize: MainAxisSize.min, children: children);
},
),
actions: Btn.ok(onTap: () => _onSaveColor(ctrl.text)).toList,
);
});
},
);
}
@@ -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

@@ -44,28 +44,28 @@ extension _SFTP on _AppSettingsPageState {
leading: const Icon(MingCute.edit_fill),
title: TipText(libL10n.editor, l10n.sftpEditorTip),
trailing: Text(val.isEmpty ? l10n.inner : val, style: UIs.text15),
onTap: () async {
final ctrl = TextEditingController(text: val);
void onSave() {
final s = ctrl.text.trim();
_setting.sftpEditor.put(s);
context.pop();
}
onTap: () {
withTextFieldController((ctrl) async {
void onSave() {
final s = ctrl.text.trim();
_setting.sftpEditor.put(s);
context.pop();
}
await context.showRoundDialog<bool>(
title: libL10n.select,
child: Input(
controller: ctrl,
autoFocus: true,
label: libL10n.editor,
hint: '\$EDITOR / vim / nano ...',
icon: Icons.edit,
suggestion: false,
onSubmitted: (_) => onSave(),
),
actions: Btn.ok(onTap: onSave).toList,
);
ctrl.dispose();
await context.showRoundDialog<bool>(
title: libL10n.select,
child: Input(
controller: ctrl,
autoFocus: true,
label: libL10n.editor,
hint: '\$EDITOR / vim / nano ...',
icon: Icons.edit,
suggestion: false,
onSubmitted: (_) => onSave(),
),
actions: Btn.ok(onTap: onSave).toList,
);
});
},
);
});

View File

@@ -116,27 +116,28 @@ extension _SSH on _AppSettingsPageState {
leading: const Icon(Icons.terminal),
title: TipText(l10n.terminal, l10n.desktopTerminalTip),
trailing: Text(val, style: UIs.text15, maxLines: 1, overflow: TextOverflow.ellipsis),
onTap: () async {
final ctrl = TextEditingController(text: val);
void onSave() {
_setting.desktopTerminal.put(ctrl.text.trim());
context.pop();
}
onTap: () {
withTextFieldController((ctrl) async {
ctrl.text = val;
void onSave() {
_setting.desktopTerminal.put(ctrl.text.trim());
context.pop();
}
await context.showRoundDialog<bool>(
title: libL10n.select,
child: Input(
controller: ctrl,
autoFocus: true,
label: l10n.terminal,
hint: 'x-terminal-emulator / gnome-terminal',
icon: Icons.edit,
suggestion: false,
onSubmitted: (_) => onSave(),
),
actions: Btn.ok(onTap: onSave).toList,
);
ctrl.dispose();
await context.showRoundDialog<bool>(
title: libL10n.select,
child: Input(
controller: ctrl,
autoFocus: true,
label: l10n.terminal,
hint: 'x-terminal-emulator / gnome-terminal',
icon: Icons.edit,
suggestion: false,
onSubmitted: (_) => onSave(),
),
actions: Btn.ok(onTap: onSave).toList,
);
});
},
);
});

View File

@@ -35,6 +35,7 @@ part 'entries/full_screen.dart';
part 'entries/server.dart';
part 'entries/sftp.dart';
part 'entries/ssh.dart';
part 'entries/ai.dart';
const _kIconSize = 23.0;
@@ -120,7 +121,7 @@ final class _AppSettingsPageState extends ConsumerState<AppSettingsPage> {
Widget build(BuildContext context) {
return MultiList(
children: [
[const CenterGreyTitle('App'), _buildApp()],
[const CenterGreyTitle('App'), _buildApp(), const CenterGreyTitle('AI'), _buildAskAiConfig()],
[CenterGreyTitle(l10n.server), _buildServer()],
[const CenterGreyTitle('SSH'), _buildSSH(), const CenterGreyTitle('SFTP'), _buildSFTP()],
[CenterGreyTitle(l10n.container), _buildContainer(), CenterGreyTitle(libL10n.editor), _buildEditor()],

View File

@@ -0,0 +1,461 @@
part of 'page.dart';
extension _AskAi on SSHPageState {
List<ContextMenuButtonItem> _buildTerminalToolbar(
BuildContext context,
CustomTextEditState state,
List<ContextMenuButtonItem> defaultItems,
) {
final rawSelection = _termKey.currentState?.renderTerminal.selectedText;
final selection = rawSelection?.trim();
if (selection == null || selection.isEmpty) {
return defaultItems;
}
final items = List<ContextMenuButtonItem>.from(defaultItems);
items.add(
ContextMenuButtonItem(
label: context.l10n.askAi,
onPressed: () {
state.hideToolbar();
_showAskAiSheet(selection);
},
),
);
return items;
}
Future<void> _showAskAiSheet(String selection) async {
if (!mounted) return;
final localeHint = Localizations.maybeLocaleOf(context)?.toLanguageTag();
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (ctx) {
return _AskAiSheet(selection: selection, localeHint: localeHint, onCommandApply: _applyAiCommand);
},
);
}
void _applyAiCommand(String command) {
if (command.isEmpty) {
return;
}
_terminal.textInput(command);
(widget.args.focusNode?.requestFocus ?? _termKey.currentState?.requestKeyboard)?.call();
}
}
class _AskAiSheet extends ConsumerStatefulWidget {
const _AskAiSheet({required this.selection, required this.localeHint, required this.onCommandApply});
final String selection;
final String? localeHint;
final ValueChanged<String> onCommandApply;
@override
ConsumerState<_AskAiSheet> createState() => _AskAiSheetState();
}
enum _ChatEntryType { user, assistant, command }
class _ChatEntry {
const _ChatEntry._({required this.type, this.content, this.command});
const _ChatEntry.user(String content) : this._(type: _ChatEntryType.user, content: content);
const _ChatEntry.assistant(String content) : this._(type: _ChatEntryType.assistant, content: content);
const _ChatEntry.command(AskAiCommand command) : this._(type: _ChatEntryType.command, command: command);
final _ChatEntryType type;
final String? content;
final AskAiCommand? command;
}
class _AskAiSheetState extends ConsumerState<_AskAiSheet> {
StreamSubscription<AskAiEvent>? _subscription;
final _chatEntries = <_ChatEntry>[];
final _history = <AskAiMessage>[];
final _scrollController = ScrollController();
final _inputController = TextEditingController();
final _seenCommands = <String>{};
String? _streamingContent;
String? _error;
bool _isStreaming = false;
@override
void initState() {
super.initState();
_inputController.addListener(_handleInputChanged);
_startStream();
}
@override
void dispose() {
_subscription?.cancel();
_scrollController.dispose();
_inputController
..removeListener(_handleInputChanged)
..dispose();
super.dispose();
}
void _handleInputChanged() {
if (!mounted) return;
setState(() {});
}
void _startStream() {
_subscription?.cancel();
setState(() {
_isStreaming = true;
_error = null;
_streamingContent = '';
});
final messages = List<AskAiMessage>.from(_history);
_subscription = ref
.read(askAiRepositoryProvider)
.ask(selection: widget.selection, localeHint: widget.localeHint, conversation: messages)
.listen(
_handleEvent,
onError: (error, stack) {
if (!mounted) return;
setState(() {
_error = _describeError(error);
_isStreaming = false;
_streamingContent = null;
});
},
onDone: () {
if (!mounted) return;
setState(() {
_isStreaming = false;
});
},
);
}
void _handleEvent(AskAiEvent event) {
if (!mounted) return;
var shouldScroll = false;
setState(() {
if (event is AskAiContentDelta) {
_streamingContent = (_streamingContent ?? '') + event.delta;
shouldScroll = true;
} else if (event is AskAiToolSuggestion) {
final inserted = _seenCommands.add(event.command.command);
if (inserted) {
_chatEntries.add(_ChatEntry.command(event.command));
shouldScroll = true;
}
} else if (event is AskAiCompleted) {
final fullText = event.fullText.isNotEmpty ? event.fullText : (_streamingContent ?? '');
if (fullText.trim().isNotEmpty) {
final message = AskAiMessage(role: AskAiMessageRole.assistant, content: fullText);
_history.add(message);
_chatEntries.add(_ChatEntry.assistant(fullText));
}
for (final command in event.commands) {
final inserted = _seenCommands.add(command.command);
if (inserted) {
_chatEntries.add(_ChatEntry.command(command));
}
}
_streamingContent = null;
_isStreaming = false;
shouldScroll = true;
} else if (event is AskAiStreamError) {
_error = _describeError(event.error);
_streamingContent = null;
_isStreaming = false;
}
});
if (shouldScroll) {
_scheduleAutoScroll();
}
}
void _scheduleAutoScroll() {
if (!_scrollController.hasClients) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_scrollController.hasClients) return;
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
);
});
}
String _describeError(Object error) {
final l10n = context.l10n;
if (error is AskAiConfigException) {
if (error.missingFields.isEmpty) {
if (error.hasInvalidBaseUrl) {
return 'Invalid Ask AI base URL: ${error.invalidBaseUrl}';
}
return error.toString();
}
final locale = Localizations.maybeLocaleOf(context);
final separator = switch (locale?.languageCode) {
'zh' => '',
'ja' => '',
_ => ', ',
};
final formattedFields = error.missingFields
.map(
(field) => switch (field) {
AskAiConfigField.baseUrl => l10n.askAiBaseUrl,
AskAiConfigField.apiKey => l10n.askAiApiKey,
AskAiConfigField.model => l10n.askAiModel,
},
)
.join(separator);
final message = l10n.askAiConfigMissing(formattedFields);
if (error.hasInvalidBaseUrl) {
return '$message (invalid URL: ${error.invalidBaseUrl})';
}
return message;
}
if (error is AskAiNetworkException) {
return error.message;
}
return error.toString();
}
Future<void> _handleApplyCommand(BuildContext context, AskAiCommand command) async {
final confirmed = await context.showRoundDialog<bool>(
title: context.l10n.askAiConfirmExecute,
child: SelectableText(command.command, style: const TextStyle(fontFamily: 'monospace')),
actions: [
TextButton(onPressed: context.pop, child: Text(libL10n.cancel)),
TextButton(onPressed: () => context.pop(true), child: Text(libL10n.ok)),
],
);
if (confirmed == true) {
widget.onCommandApply(command.command);
if (!mounted) return;
context.showSnackBar(context.l10n.askAiCommandInserted);
}
}
Future<void> _copyCommand(BuildContext context, AskAiCommand command) async {
await Clipboard.setData(ClipboardData(text: command.command));
if (!mounted) return;
context.showSnackBar(libL10n.success);
}
void _sendMessage() {
if (_isStreaming) return;
final text = _inputController.text.trim();
if (text.isEmpty) return;
setState(() {
final message = AskAiMessage(role: AskAiMessageRole.user, content: text);
_history.add(message);
_chatEntries.add(_ChatEntry.user(text));
_inputController.clear();
});
_startStream();
_scheduleAutoScroll();
}
List<Widget> _buildConversationWidgets(BuildContext context, ThemeData theme) {
final widgets = <Widget>[];
for (final entry in _chatEntries) {
widgets.add(_buildChatItem(context, theme, entry));
widgets.add(const SizedBox(height: 12));
}
if (_streamingContent != null) {
widgets.add(_buildAssistantBubble(theme, content: _streamingContent!, streaming: true));
widgets.add(const SizedBox(height: 12));
} else if (_chatEntries.isEmpty && _error == null) {
widgets.add(_buildAssistantBubble(theme, content: '', streaming: true));
widgets.add(const SizedBox(height: 12));
}
if (widgets.isNotEmpty) {
widgets.removeLast();
}
return widgets;
}
Widget _buildChatItem(BuildContext context, ThemeData theme, _ChatEntry entry) {
switch (entry.type) {
case _ChatEntryType.user:
return Align(
alignment: Alignment.centerRight,
child: CardX(
child: Padding(padding: const EdgeInsets.all(12), child: SelectableText(entry.content ?? '')),
),
);
case _ChatEntryType.assistant:
return _buildAssistantBubble(theme, content: entry.content ?? '');
case _ChatEntryType.command:
final command = entry.command!;
return _buildCommandBubble(context, theme, command);
}
}
Widget _buildAssistantBubble(ThemeData theme, {required String content, bool streaming = false}) {
final trimmed = content.trim();
final l10n = context.l10n;
final child = trimmed.isEmpty
? Text(
streaming ? l10n.askAiAwaitingResponse : l10n.askAiNoResponse,
style: theme.textTheme.bodySmall,
)
: SimpleMarkdown(data: content);
return Align(
alignment: Alignment.centerLeft,
child: CardX(
child: Padding(padding: const EdgeInsets.all(12), child: child),
),
);
}
Widget _buildCommandBubble(BuildContext context, ThemeData theme, AskAiCommand command) {
final l10n = context.l10n;
return Align(
alignment: Alignment.centerLeft,
child: CardX(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.askAiRecommendedCommand, style: theme.textTheme.labelMedium),
const SizedBox(height: 8),
SelectableText(command.command, style: const TextStyle(fontFamily: 'monospace')),
if (command.description.isNotEmpty) ...[
const SizedBox(height: 6),
Text(command.description, style: theme.textTheme.bodySmall),
],
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: () => _copyCommand(context, command),
icon: const Icon(Icons.copy, size: 18),
label: Text(libL10n.copy),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: () => _handleApplyCommand(context, command),
icon: const Icon(Icons.terminal, size: 18),
label: Text(l10n.askAiInsertTerminal),
),
],
),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bottomPadding = MediaQuery.viewInsetsOf(context).bottom;
return FractionallySizedBox(
heightFactor: 0.85,
child: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Row(
children: [
Text(context.l10n.askAi, style: theme.textTheme.titleLarge),
const SizedBox(width: 8),
if (_isStreaming)
const SizedBox(height: 16, width: 16, child: CircularProgressIndicator(strokeWidth: 2)),
const Spacer(),
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop()),
],
),
),
Expanded(
child: Scrollbar(
controller: _scrollController,
child: ListView(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
children: [
Text(context.l10n.askAiSelectedContent, style: theme.textTheme.titleMedium),
const SizedBox(height: 6),
CardX(
child: Padding(
padding: const EdgeInsets.all(12),
child: SelectableText(
widget.selection,
style: const TextStyle(fontFamily: 'monospace'),
),
),
),
const SizedBox(height: 16),
Text(context.l10n.askAiConversation, style: theme.textTheme.titleMedium),
const SizedBox(height: 6),
..._buildConversationWidgets(context, theme),
if (_error != null) ...[
const SizedBox(height: 16),
CardX(
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
),
),
],
if (_isStreaming) ...[const SizedBox(height: 16), const LinearProgressIndicator()],
const SizedBox(height: 16),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text(
context.l10n.askAiDisclaimer,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, 16 + bottomPadding),
child: Row(
children: [
Expanded(
child: Input(
controller: _inputController,
minLines: 1,
maxLines: 4,
hint: context.l10n.askAiFollowUpHint,
action: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
),
),
const SizedBox(width: 12),
Btn.icon(
onTap: _isStreaming || _inputController.text.trim().isEmpty ? null : _sendMessage,
icon: const Icon(Icons.send, size: 18),
),
],
).cardx,
),
],
),
),
);
}
}

View File

@@ -113,34 +113,82 @@ extension _Init on SSHPageState {
}
void _setupDiscontinuityTimer() {
_discontinuityTimer = Timer.periodic(const Duration(seconds: 5), (_) async {
var throwTimeout = true;
Future.delayed(const Duration(seconds: 3), () {
if (throwTimeout) {
_catchTimeout();
}
});
await _client?.ping();
throwTimeout = false;
});
}
void _catchTimeout() {
_discontinuityTimer?.cancel();
if (!mounted) return;
_missedKeepAliveCount = 0;
_discontinuityTimer = Timer.periodic(
SSHPageState._connectionCheckInterval,
(_) => _checkConnectionHealth(),
);
}
Future<void> _checkConnectionHealth() async {
if (!mounted || _client == null || _isCheckingConnection) return;
_isCheckingConnection = true;
try {
await _client!.ping().timeout(SSHPageState._connectionCheckTimeout);
_missedKeepAliveCount = 0;
if (_reportedDisconnected) {
_reportedDisconnected = false;
TermSessionManager.updateStatus(_sessionId, TermSessionStatus.connected);
}
} on TimeoutException catch (error) {
_handleConnectionCheckFailure(error);
} on Object catch (error, stackTrace) {
_handleConnectionCheckFailure(error, stackTrace);
} finally {
_isCheckingConnection = false;
}
}
void _handleConnectionCheckFailure(Object error, [StackTrace? stackTrace]) {
Loggers.root.warning('SSH keep-alive failed', error, stackTrace);
_missedKeepAliveCount += 1;
if (_missedKeepAliveCount < SSHPageState._maxKeepAliveFailures) {
return;
}
_missedKeepAliveCount = 0;
_onConnectionLossSuspected();
}
void _onConnectionLossSuspected() {
if (!mounted || _disconnectDialogOpen) return;
_disconnectDialogOpen = true;
_reportedDisconnected = true;
_discontinuityTimer?.cancel();
_writeLn('\n\nConnection lost\r\n');
TermSessionManager.updateStatus(_sessionId, TermSessionStatus.disconnected);
context.showRoundDialog(
unawaited(_showDisconnectDialog());
}
Future<void> _showDisconnectDialog() async {
final shouldLeave = await context.showRoundDialog<bool>(
title: libL10n.attention,
child: Text('${l10n.disconnected}\n${l10n.goBackQ}'),
barrierDismiss: false,
actions: Btn.ok(
onTap: () {
contextSafe?.pop(); // Can't use tear-drop here
contextSafe?.pop(); // Pop the SSHPage
},
).toList,
actions: [
TextButton(onPressed: () => context.pop(false), child: Text(libL10n.cancel)),
TextButton(onPressed: () => context.pop(true), child: Text(libL10n.ok)),
],
);
if (!mounted) return;
_disconnectDialogOpen = false;
if (shouldLeave == true) {
contextSafe?.pop(); // Pop the SSHPage
return;
}
_reportedDisconnected = false;
TermSessionManager.updateStatus(_sessionId, TermSessionStatus.connected);
_setupDiscontinuityTimer();
}
void _writeLn(String p0) {

View File

@@ -13,9 +13,11 @@ import 'package:server_box/core/chan.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/core/utils/server.dart';
import 'package:server_box/core/utils/ssh_auth.dart';
import 'package:server_box/data/model/ai/ask_ai_models.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/snippet.dart';
import 'package:server_box/data/model/ssh/virtual_key.dart';
import 'package:server_box/data/provider/ai/ask_ai.dart';
import 'package:server_box/data/provider/server/single.dart';
import 'package:server_box/data/provider/snippet.dart';
import 'package:server_box/data/provider/virtual_keyboard.dart';
@@ -23,11 +25,11 @@ import 'package:server_box/data/res/store.dart';
import 'package:server_box/data/res/terminal.dart';
import 'package:server_box/data/ssh/session_manager.dart';
import 'package:server_box/view/page/storage/sftp.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:xterm/core.dart';
import 'package:xterm/ui.dart' hide TerminalThemes;
part 'ask_ai.dart';
part 'init.dart';
part 'keyboard.dart';
part 'virt_key.dart';
@@ -83,6 +85,13 @@ class SSHPageState extends ConsumerState<SSHPage>
SSHClient? _client;
SSHSession? _session;
Timer? _discontinuityTimer;
static const _connectionCheckInterval = Duration(seconds: 60);
static const _connectionCheckTimeout = Duration(seconds: 30);
static const _maxKeepAliveFailures = 3;
int _missedKeepAliveCount = 0;
bool _isCheckingConnection = false;
bool _disconnectDialogOpen = false;
bool _reportedDisconnected = false;
/// Used for (de)activate the wake lock and forground service
static var _sshConnCount = 0;
@@ -247,6 +256,7 @@ class SSHPageState extends ConsumerState<SSHPage>
viewOffset: Offset(2 * _horizonPadding, CustomAppBar.sysStatusBarHeight),
hideScrollBar: false,
focusNode: widget.args.focusNode,
toolbarBuilder: _buildTerminalToolbar,
),
),
);

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';
@@ -46,7 +47,7 @@ class _SftpPageState extends ConsumerState<SftpPage> with AfterLayoutMixin {
late final SftpBrowserStatus _status;
late final SSHClient _client;
final _sortOption = _SortOption().vn;
@override
void initState() {
super.initState();
@@ -286,6 +287,10 @@ extension _Actions on _SftpPageState {
return;
}
if (!await ensureHostKeyAcceptedForSftp(context, widget.args.spi)) {
return;
}
final remotePath = _getRemotePath(name);
final localPath = _getLocalPath(remotePath);
final completer = Completer();
@@ -298,7 +303,10 @@ extension _Actions on _SftpPageState {
context,
args: EditorPageArgs(
path: localPath,
onSave: (_) {
onSave: (_) async {
if (!await ensureHostKeyAcceptedForSftp(context, req.spi)) {
return;
}
ref
.read(sftpProvider.notifier)
.add(SftpReq(req.spi, remotePath, localPath, SftpReqType.upload));
@@ -322,6 +330,10 @@ extension _Actions on _SftpPageState {
context.pop();
final remotePath = _getRemotePath(name);
if (!await ensureHostKeyAcceptedForSftp(context, widget.args.spi)) {
return;
}
ref
.read(sftpProvider.notifier)
.add(SftpReq(widget.args.spi, remotePath, _getLocalPath(remotePath), SftpReqType.download));
@@ -652,6 +664,9 @@ extension _Actions on _SftpPageState {
final fileName = path.split(Platform.pathSeparator).lastOrNull;
final remotePath = '$remoteDir/$fileName';
Loggers.app.info('SFTP upload local: $path, remote: $remotePath');
if (!await ensureHostKeyAcceptedForSftp(context, widget.args.spi)) {
return;
}
ref
.read(sftpProvider.notifier)
.add(SftpReq(widget.args.spi, remotePath, path, SftpReqType.upload));

View File

@@ -471,7 +471,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1262;
CURRENT_PROJECT_VERSION = 1270;
DEVELOPMENT_TEAM = BA88US33G6;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Server Box";
@@ -481,7 +481,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.15;
MARKETING_VERSION = 1.0.1262;
MARKETING_VERSION = 1.0.1270;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "Server Box";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -608,7 +608,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1262;
CURRENT_PROJECT_VERSION = 1270;
DEVELOPMENT_TEAM = BA88US33G6;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Server Box";
@@ -618,7 +618,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.15;
MARKETING_VERSION = 1.0.1262;
MARKETING_VERSION = 1.0.1270;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "Server Box";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -638,7 +638,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "3rd Party Mac Developer Application";
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1262;
CURRENT_PROJECT_VERSION = 1270;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=macosx*]" = BA88US33G6;
INFOPLIST_FILE = Runner/Info.plist;
@@ -649,7 +649,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.15;
MARKETING_VERSION = 1.0.1262;
MARKETING_VERSION = 1.0.1270;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "Server Box";
PROVISIONING_PROFILE_SPECIFIER = "";

View File

@@ -375,8 +375,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "v1.0.285"
resolved-ref: "18fb1ad15ee6d2c8c5ec67722bf8b90fe0f4746d"
ref: "v1.0.293"
resolved-ref: "3eedfd55916eede70aeb28605469a43623a9791b"
url: "https://github.com/lollipopkit/dartssh2"
source: git
version: "2.12.0"
@@ -505,8 +505,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "v1.0.355"
resolved-ref: "73d5f2603859a9f70459d798ed2d267b1d9a86e5"
ref: "v1.0.358"
resolved-ref: c8e55d054875bb3ccdab9894a01fe82d173dc54e
url: "https://github.com/lppcg/fl_lib"
source: git
version: "0.0.1"
@@ -1007,10 +1007,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.16.0"
version: "1.17.0"
mime:
dependency: transitive
description:
@@ -1581,26 +1581,26 @@ packages:
dependency: "direct dev"
description:
name: test
sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb"
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
url: "https://pub.dev"
source: hosted
version: "1.26.2"
version: "1.26.3"
test_api:
dependency: transitive
description:
name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.6"
version: "0.7.7"
test_core:
dependency: transitive
description:
name: test_core
sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a"
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
url: "https://pub.dev"
source: hosted
version: "0.6.11"
version: "0.6.12"
timing:
dependency: transitive
description:
@@ -1862,8 +1862,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "v4.0.4"
resolved-ref: "5747837cdb7b113ef733ce0104e4f2bfa1eb4a36"
ref: "v4.0.13"
resolved-ref: "6343b0e5f744d2c11090d34690ad5049ebbc599b"
url: "https://github.com/lollipopkit/xterm.dart"
source: git
version: "4.0.0"

View File

@@ -1,7 +1,7 @@
name: server_box
description: server status & toolbox app.
publish_to: "none"
version: 1.0.1262+1262
version: 1.0.1270+1270
environment:
sdk: ">=3.9.0"
@@ -41,7 +41,7 @@ dependencies:
dartssh2:
git:
url: https://github.com/lollipopkit/dartssh2
ref: v1.0.285
ref: v1.0.293
circle_chart:
git:
url: https://github.com/lollipopkit/circle_chart
@@ -49,7 +49,7 @@ dependencies:
xterm:
git:
url: https://github.com/lollipopkit/xterm.dart
ref: v4.0.4
ref: v4.0.13
computer:
git:
url: https://github.com/lollipopkit/dart_computer
@@ -65,7 +65,7 @@ dependencies:
fl_lib:
git:
url: https://github.com/lppcg/fl_lib
ref: v1.0.355
ref: v1.0.358
dependency_overrides:
# webdav_client_plus:

View File

@@ -0,0 +1,165 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:server_box/core/utils/proxy_command_executor.dart';
import 'package:server_box/data/model/server/proxy_command_config.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
void main() {
group('ProxyCommandConfig Tests', () {
test('should create ProxyCommandConfig with required fields', () {
const config = ProxyCommandConfig(
command: 'cloudflared access ssh --hostname %h',
timeout: Duration(seconds: 30),
requiresExecutable: true,
executableName: 'cloudflared',
);
expect(config.command, equals('cloudflared access ssh --hostname %h'));
expect(config.timeout.inSeconds, equals(30));
expect(config.requiresExecutable, isTrue);
expect(config.executableName, equals('cloudflared'));
});
test('should get final command with placeholders replaced', () {
const config = ProxyCommandConfig(
command: 'cloudflared access ssh --hostname %h --port %p',
timeout: Duration(seconds: 30),
);
final finalCommand = config.getFinalCommand(
hostname: 'example.com',
port: 22,
user: 'testuser',
);
expect(finalCommand, equals('cloudflared access ssh --hostname example.com --port 22'));
});
test('should handle all placeholders correctly', () {
const config = ProxyCommandConfig(
command: 'ssh -W %h:%p -l %r bastion.example.com',
timeout: Duration(seconds: 10),
);
final finalCommand = config.getFinalCommand(
hostname: 'target.example.com',
port: 2222,
user: 'admin',
);
expect(finalCommand, equals('ssh -W target.example.com:2222 -l admin bastion.example.com'));
});
test('should validate presets from map', () {
final presets = proxyCommandPresets;
final cloudflareConfig = presets['cloudflare_access'];
expect(cloudflareConfig, isNotNull);
expect(cloudflareConfig!.command, equals('cloudflared access ssh --hostname %h'));
expect(cloudflareConfig.requiresExecutable, isTrue);
expect(cloudflareConfig.executableName, equals('cloudflared'));
final sshBastionConfig = presets['ssh_via_bastion'];
expect(sshBastionConfig, isNotNull);
expect(sshBastionConfig!.command, equals('ssh -W %h:%p bastion.example.com'));
expect(sshBastionConfig.requiresExecutable, isFalse);
final ncConfig = presets['nc_netcat'];
expect(ncConfig, isNotNull);
expect(ncConfig!.command, equals('nc %h %p'));
expect(ncConfig.requiresExecutable, isFalse);
final socatConfig = presets['socat'];
expect(socatConfig, isNotNull);
expect(socatConfig!.command, equals('socat - PROXY:%h:%p,proxyport=8080'));
expect(socatConfig.requiresExecutable, isFalse);
});
});
group('Spi with ProxyCommand Tests', () {
test('should create Spi with ProxyCommand configuration', () {
final spi = Spi(
name: 'Test Server',
ip: 'example.com',
port: 22,
user: 'testuser',
pwd: 'testpass',
proxyCommand: const ProxyCommandConfig(
command: 'cloudflared access ssh --hostname %h',
timeout: Duration(seconds: 30),
requiresExecutable: true,
executableName: 'cloudflared',
),
);
expect(spi.name, equals('Test Server'));
expect(spi.proxyCommand, isNotNull);
expect(spi.proxyCommand!.command, equals('cloudflared access ssh --hostname %h'));
expect(spi.proxyCommand!.requiresExecutable, isTrue);
});
test('should handle Spi without ProxyCommand', () {
final spi = Spi(
name: 'Test Server',
ip: 'example.com',
port: 22,
user: 'testuser',
pwd: 'testpass',
);
expect(spi.proxyCommand, isNull);
});
test('should serialize and deserialize Spi with ProxyCommand', () {
final originalSpi = Spi(
name: 'Test Server',
ip: 'example.com',
port: 22,
user: 'testuser',
pwd: 'testpass',
proxyCommand: const ProxyCommandConfig(
command: 'cloudflared access ssh --hostname %h',
timeout: Duration(seconds: 30),
requiresExecutable: true,
executableName: 'cloudflared',
),
);
final json = originalSpi.toJson();
final deserializedSpi = Spi.fromJson(json);
expect(deserializedSpi.name, equals(originalSpi.name));
expect(deserializedSpi.ip, equals(originalSpi.ip));
expect(deserializedSpi.proxyCommand, isNotNull);
expect(deserializedSpi.proxyCommand!.command, equals(originalSpi.proxyCommand!.command));
expect(deserializedSpi.proxyCommand!.requiresExecutable, equals(originalSpi.proxyCommand!.requiresExecutable));
expect(deserializedSpi.proxyCommand!.executableName, equals(originalSpi.proxyCommand!.executableName));
});
});
group('ProxyCommandExecutor Tokenization', () {
test('tokenizeCommand handles quoted paths', () {
final tokens = ProxyCommandExecutor.tokenizeCommand(
'ssh -i "/Users/John Doe/.ssh/id_rsa" -W %h:%p bastion.example.com',
);
expect(
tokens,
equals([
'ssh',
'-i',
'/Users/John Doe/.ssh/id_rsa',
'-W',
'%h:%p',
'bastion.example.com',
]),
);
});
test('tokenizeCommand throws on unmatched quote', () {
expect(
() => ProxyCommandExecutor.tokenizeCommand('ssh -i "/Users/John Doe/.ssh/id_rsa'),
throwsA(isA<ProxyCommandException>()),
);
});
});
}

View File

@@ -286,6 +286,25 @@ Host jumpserver
// ProxyJump is ignored in current implementation
});
test('parseConfig handles ProxyCommand with ssh -W jump host', () async {
await configFile.writeAsString('''
Host internal
HostName 172.16.0.50
User admin
ProxyCommand ssh -W %h:%p user@bastion.example.com
''');
final servers = await SSHConfig.parseConfig(configFile.path);
expect(servers, hasLength(1));
final server = servers.first;
expect(server.name, 'internal');
expect(server.ip, '172.16.0.50');
expect(server.user, 'admin');
// Jump host extracted from ProxyCommand token containing user@host
expect(server.jumpId, 'user@bastion.example.com');
});
test('parseConfig returns empty list for non-existent file', () async {
final servers = await SSHConfig.parseConfig('/non/existent/path');
expect(servers, isEmpty);
@@ -352,4 +371,4 @@ Host internal-server
expect(dev.keyId, isNull);
});
});
}
}