mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2026-02-15 04:34:34 +01:00
Compare commits
15 Commits
v1.0.1262
...
lollipopki
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4c0fdf6ff | ||
|
|
84921de7a7 | ||
|
|
12c8543352 | ||
|
|
92a4601335 | ||
|
|
b6ab8f1db5 | ||
|
|
ffda27d057 | ||
|
|
c548b4ef48 | ||
|
|
70040c5840 | ||
|
|
5272324be6 | ||
|
|
8cbb48ed67 | ||
|
|
03720fa322 | ||
|
|
0b51719070 | ||
|
|
a84231393d | ||
|
|
d6c2cafce7 | ||
|
|
729b76177e |
14
.github/workflows/analysis.yml
vendored
14
.github/workflows/analysis.yml
vendored
@@ -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
143
LICENSE
@@ -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/>.
|
||||
@@ -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`
|
||||
|
||||
@@ -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 & 所有贡献者`
|
||||
|
||||
7
fastlane/metadata/android/ru/full_description.txt
Normal file
7
fastlane/metadata/android/ru/full_description.txt
Normal 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, 日本語
|
||||
1
fastlane/metadata/android/ru/short_description.txt
Normal file
1
fastlane/metadata/android/ru/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
Приложение для мониторинга серверов и набор инструментов управления ими
|
||||
@@ -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;
|
||||
|
||||
13
lib/app.dart
13
lib/app.dart
@@ -3,6 +3,7 @@ import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:fl_lib/generated/l10n/lib_l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:icons_plus/icons_plus.dart';
|
||||
import 'package:server_box/core/app_navigator.dart';
|
||||
import 'package:server_box/core/extension/context/locale.dart';
|
||||
import 'package:server_box/data/res/build_data.dart';
|
||||
import 'package:server_box/data/res/store.dart';
|
||||
@@ -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);
|
||||
|
||||
8
lib/core/app_navigator.dart
Normal file
8
lib/core/app_navigator.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Global navigator access used for cross-cutting flows (e.g. dialogs).
|
||||
abstract final class AppNavigator {
|
||||
static final key = GlobalKey<NavigatorState>();
|
||||
|
||||
static BuildContext? get context => key.currentContext;
|
||||
}
|
||||
303
lib/core/utils/executable_manager.dart
Normal file
303
lib/core/utils/executable_manager.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
26
lib/core/utils/host_key_helper.dart
Normal file
26
lib/core/utils/host_key_helper.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:server_box/core/utils/server.dart';
|
||||
import 'package:server_box/core/utils/ssh_auth.dart';
|
||||
import 'package:server_box/data/model/server/server_private_info.dart';
|
||||
import 'package:server_box/data/res/store.dart';
|
||||
|
||||
Future<bool> ensureHostKeyAcceptedForSftp(BuildContext context, Spi spi) async {
|
||||
final known = Stores.setting.sshKnownHostFingerprints.get();
|
||||
final hostId = spi.id.isNotEmpty ? spi.id : spi.oldId;
|
||||
final prefix = '$hostId::';
|
||||
if (known.keys.any((key) => key.startsWith(prefix))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final (result, error) = await context.showLoadingDialog<bool>(
|
||||
fn: () async {
|
||||
await ensureKnownHostKey(
|
||||
spi,
|
||||
onKeyboardInteractive: (_) => KeybordInteractive.defaultHandle(spi, ctx: context),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
return error == null && result == true;
|
||||
}
|
||||
300
lib/core/utils/proxy_command_executor.dart
Normal file
300
lib/core/utils/proxy_command_executor.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
125
lib/core/utils/proxy_socket.dart
Normal file
125
lib/core/utils/proxy_socket.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
74
lib/data/model/ai/ask_ai_models.dart
Normal file
74
lib/data/model/ai/ask_ai_models.dart
Normal 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;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
81
lib/data/model/server/proxy_command_config.dart
Normal file
81
lib/data/model/server/proxy_command_config.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
344
lib/data/model/server/proxy_command_config.freezed.dart
Normal file
344
lib/data/model/server/proxy_command_config.freezed.dart
Normal 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
|
||||
39
lib/data/model/server/proxy_command_config.g.dart
Normal file
39
lib/data/model/server/proxy_command_config.g.dart
Normal 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,
|
||||
};
|
||||
@@ -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>';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
343
lib/data/provider/ai/ask_ai.dart
Normal file
343
lib/data/provider/ai/ask_ai.dart
Normal 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)';
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -58,7 +58,7 @@ final class ContainerNotifierProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$containerNotifierHash() => r'fea65e66499234b0a59bffff8d69c4ab8c93b2fd';
|
||||
String _$containerNotifierHash() => r'e6ced8a914631253daabe0de452e0338078cd1d9';
|
||||
|
||||
final class ContainerNotifierFamily extends $Family
|
||||
with
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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?';
|
||||
|
||||
@@ -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?';
|
||||
|
||||
@@ -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?';
|
||||
|
||||
@@ -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 ?';
|
||||
|
||||
@@ -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?';
|
||||
|
||||
@@ -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設定ファイルを手動で選択しますか?';
|
||||
|
||||
|
||||
@@ -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?';
|
||||
|
||||
@@ -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?';
|
||||
|
||||
@@ -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?';
|
||||
|
||||
@@ -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?';
|
||||
|
||||
@@ -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?';
|
||||
|
||||
@@ -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 設定檔案?';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 l’affichage de la capacité du disque",
|
||||
"privateKey": "Clé privée",
|
||||
"privateKeyNotFoundFmt": "Clé privée [{keyId}] introuvable.",
|
||||
"process": "Processus",
|
||||
"prune": "Élaguer",
|
||||
"pushToken": "Jeton d'identification",
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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` に書き込まれます。スクリプトの内容を確認できます。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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` для мониторинга состояния системы. Вы можете проверить содержимое скрипта."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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` для моніторингу стану системи. Ви можете переглянути вміст скрипта."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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` 写入脚本来监测系统状态,你可以审查脚本内容。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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` 中寫入一個腳本來監測系統狀態。你可以審查腳本內容。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
95
lib/view/page/setting/entries/ai.dart
Normal file
95
lib/view/page/setting/entries/ai.dart
Normal 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)),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,10 +83,10 @@ class _HomeTabsConfigPageState extends ConsumerState<HomeTabsConfigPage> {
|
||||
onTap: isSelected && canRemove ? () => _removeTab(tab) : null,
|
||||
);
|
||||
|
||||
return Card(
|
||||
return Padding(
|
||||
key: ValueKey(tab.name),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: isSelected ? ReorderableDragStartListener(index: index, child: child) : child,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
|
||||
child: (isSelected ? ReorderableDragStartListener(index: index, child: child) : child).cardx,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -207,53 +207,6 @@ extension _Server on _AppSettingsPageState {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEditRawSettings() {
|
||||
return ListTile(
|
||||
title: const Text('(Dev) Edit raw json'),
|
||||
trailing: const Icon(Icons.keyboard_arrow_right),
|
||||
onTap: _editRawSettings,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _editRawSettings() async {
|
||||
final map = Stores.setting.getAllMap(includeInternalKeys: true);
|
||||
final keys = map.keys;
|
||||
|
||||
void onSave(EditorPageRet ret) {
|
||||
if (ret.typ != EditorPageRetType.text) {
|
||||
context.showRoundDialog(title: libL10n.fail, child: Text(l10n.invalid));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final newSettings = json.decode(ret.val) as Map<String, dynamic>;
|
||||
Stores.setting.box.putAll(newSettings);
|
||||
final newKeys = newSettings.keys;
|
||||
final removedKeys = keys.where((e) => !newKeys.contains(e));
|
||||
for (final key in removedKeys) {
|
||||
Stores.setting.box.delete(key);
|
||||
}
|
||||
} catch (e, trace) {
|
||||
context.showRoundDialog(title: libL10n.error, child: Text('${l10n.save}:\n$e'));
|
||||
Loggers.app.warning('Update json settings failed', e, trace);
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode [map] to String with indent `\t`
|
||||
final text = jsonIndentEncoder.convert(map);
|
||||
await EditorPage.route.go(
|
||||
context,
|
||||
args: EditorPageArgs(
|
||||
text: text,
|
||||
lang: ProgLang.json,
|
||||
title: libL10n.setting,
|
||||
onSave: onSave,
|
||||
closeAfterSave: SettingStore.instance.closeAfterSave.fetch(),
|
||||
softWrap: SettingStore.instance.editorSoftWrap.fetch(),
|
||||
enableHighlight: SettingStore.instance.editorHighlight.fetch(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCpuView() {
|
||||
return ExpandTile(
|
||||
leading: const Icon(OctIcons.cpu, size: _kIconSize),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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()],
|
||||
|
||||
461
lib/view/page/ssh/page/ask_ai.dart
Normal file
461
lib/view/page/ssh/page/ask_ai.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:server_box/core/extension/context/locale.dart';
|
||||
import 'package:server_box/core/utils/host_key_helper.dart';
|
||||
import 'package:server_box/data/model/app/path_with_prefix.dart';
|
||||
import 'package:server_box/data/model/server/server_private_info.dart';
|
||||
import 'package:server_box/data/model/sftp/worker.dart';
|
||||
@@ -370,6 +371,10 @@ extension _OnTapFile on _LocalFilePageState {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await ensureHostKeyAcceptedForSftp(context, spi)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(sftpProvider.notifier).add(SftpReq(spi, '$remotePath/$fileName', file.absolute.path, SftpReqType.upload));
|
||||
context.showSnackBar(l10n.added2List);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:icons_plus/icons_plus.dart';
|
||||
import 'package:server_box/core/extension/context/locale.dart';
|
||||
import 'package:server_box/core/extension/sftpfile.dart';
|
||||
import 'package:server_box/core/utils/comparator.dart';
|
||||
import 'package:server_box/core/utils/host_key_helper.dart';
|
||||
import 'package:server_box/data/model/server/server_private_info.dart';
|
||||
import 'package:server_box/data/model/sftp/browser_status.dart';
|
||||
import 'package:server_box/data/model/sftp/worker.dart';
|
||||
@@ -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));
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
28
pubspec.lock
28
pubspec.lock
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
165
test/proxy_command_test.dart
Normal file
165
test/proxy_command_test.dart
Normal 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>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user