Compare commits

..

4 Commits

Author SHA1 Message Date
lollipopkit🏳️‍⚧️
d5e22cbc65 bump: v1297 2026-01-19 09:40:31 +08:00
GT610
7926a4d4a6 fix(ssh page): Fix the issue with calculating the height of the virtual keyboard (#1011) 2026-01-17 21:15:05 +08:00
GT610
39a3e0800b opt: Improve container parsing and error handling (#1001)
* fix(ssh): Modify the return type of execWithPwd to include the output content

Adjust the return type of the `execWithPwd` method to `(int?, String)` so that it can simultaneously return the exit code and output content

Fix the issue in ContainerNotifier where the return result of execWithPwd is not handled correctly

Ensure that server operations (shutdown/restart/suspend) are correctly pending until the command execution is completed

* refactor(container): Change single error handling to multiple error lists

Support the simultaneous display of multiple container operation errors, enhancing error handling capabilities

* fix(container): Adjust the layout width and optimize the handling of text overflow

Adjust the width calculation for the container page layout, changing from subtracting a fixed value to subtracting a smaller value to improve the layout

Add overflow ellipsis processing to the text to prevent anomalies when the text is too long

* Revert "refactor(container): Change single error handling to multiple error lists"

This reverts commit 72aaa173f5.

* feat(container): Add Podman Docker emulation detection function

Add detection for Podman Docker emulation in the container module. When detected, a prompt message will be displayed and users will be advised to switch to Podman settings.

Updated the multilingual translation files to support the new features.

* fix: Fix error handling in SSH client and container operations

Fix the issue where the SSH client does not handle stderr when executing commands

Error handling for an empty client in the container addition operation

Fix the issue where null may be returned during server page operations

* fix(container): Check if client is empty before running the command

When the client is null, directly return an error to avoid null pointer exception

* fix: Revert `stderr` ignore

* fix(container): Detect Podman simulation in advance and optimize error handling

Move the Podman simulation detection to the initial parsing stage to avoid redundant checks

Remove duplicated error handling code and simplify the logic

* fix(container): Fix the error handling logic during container command execution

Increase the inspection of error outputs, including handling situations such as sudo password prompts and Podman not being installed

* refactor(macOS): Remove unused path_provider_foundation plugin
2026-01-17 14:40:44 +08:00
lollipopkit🏳️‍⚧️
cd3c094af0 new: release ipa/app in Actions (#1005)
* new: release ipa/app in Actions
Fixes #770

* opt.

* opt.

* fix

* opt.
2026-01-15 12:10:03 +08:00
55 changed files with 482 additions and 823 deletions

View File

@@ -9,10 +9,9 @@ on:
permissions:
contents: write
# Set by fl_build
# env:
# APP_NAME: ServerBox
# BUILD_NUMBER: ${{ github.ref_name }}
env:
APP_NAME: ServerBox
RELEASE_TAG: ${{ github.ref_name }}
jobs:
releaseAndroid:
@@ -21,11 +20,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: recursive
- name: Install Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.38.0"
flutter-version: "3.38.7"
- uses: actions/setup-java@v4
with:
distribution: "zulu"
@@ -37,17 +38,28 @@ jobs:
- name: Build
run: dart run fl_build -p android
- name: Rename for fdroid
shell: bash
run: |
mv build/app/outputs/flutter-apk/${{ env.APP_NAME }}_${{ env.BUILD_NUMBER }}_arm64.apk build/app/outputs/flutter-apk/${{ env.APP_NAME }}_v1.0.${{ env.BUILD_NUMBER }}_arm64.apk
mv build/app/outputs/flutter-apk/${{ env.APP_NAME }}_${{ env.BUILD_NUMBER }}_arm.apk build/app/outputs/flutter-apk/${{ env.APP_NAME }}_v1.0.${{ env.BUILD_NUMBER }}_arm.apk
mv build/app/outputs/flutter-apk/${{ env.APP_NAME }}_${{ env.BUILD_NUMBER }}_amd64.apk build/app/outputs/flutter-apk/${{ env.APP_NAME }}_v1.0.${{ env.BUILD_NUMBER }}_amd64.apk
APK_DIR="build/app/outputs/flutter-apk"
shopt -s nullglob
for arch in arm64 arm amd64; do
matches=("$APK_DIR"/"${APP_NAME}"_*_"${arch}".apk)
if [ ${#matches[@]} -ne 1 ]; then
echo "Error: expected 1 APK for ${arch}, found ${#matches[@]}"
echo "APK_DIR: $APK_DIR"
ls -la "$APK_DIR" || true
exit 1
fi
mv "${matches[0]}" "$APK_DIR/${APP_NAME}_${RELEASE_TAG}_${arch}.apk"
done
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: |
build/app/outputs/flutter-apk/${{ env.APP_NAME }}_v1.0.${{ env.BUILD_NUMBER }}_arm64.apk
build/app/outputs/flutter-apk/${{ env.APP_NAME }}_v1.0.${{ env.BUILD_NUMBER }}_arm.apk
build/app/outputs/flutter-apk/${{ env.APP_NAME }}_v1.0.${{ env.BUILD_NUMBER }}_amd64.apk
build/app/outputs/flutter-apk/${{ env.APP_NAME }}_${{ env.RELEASE_TAG }}_arm64.apk
build/app/outputs/flutter-apk/${{ env.APP_NAME }}_${{ env.RELEASE_TAG }}_arm.apk
build/app/outputs/flutter-apk/${{ env.APP_NAME }}_${{ env.RELEASE_TAG }}_amd64.apk
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -57,6 +69,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: recursive
- name: Install Flutter
uses: subosito/flutter-action@v2
- name: Install dependencies
@@ -69,11 +83,22 @@ jobs:
- name: Build
run: |
dart run fl_build -p linux
- name: Rename for release
shell: bash
run: |
shopt -s nullglob
matches=("${APP_NAME}"_*_amd64.AppImage)
if [ ${#matches[@]} -ne 1 ]; then
echo "Error: expected 1 AppImage, found ${#matches[@]}"
ls -la || true
exit 1
fi
mv "${matches[0]}" "${APP_NAME}_${RELEASE_TAG}_amd64.AppImage"
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: |
${{ env.APP_NAME }}_${{ env.BUILD_NUMBER }}_amd64.AppImage
${{ env.APP_NAME }}_${{ env.RELEASE_TAG }}_amd64.AppImage
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -83,32 +108,96 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: recursive
- name: Install Flutter
uses: subosito/flutter-action@v2
- name: Build
run: dart run fl_build -p windows
- name: Rename for release
shell: bash
run: |
shopt -s nullglob
matches=("${APP_NAME}"_*_windows_amd64.zip)
if [ ${#matches[@]} -ne 1 ]; then
echo "Error: expected 1 zip, found ${#matches[@]}"
ls -la || true
exit 1
fi
mv "${matches[0]}" "${APP_NAME}_${RELEASE_TAG}_windows_amd64.zip"
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: |
${{ env.APP_NAME }}_${{ env.BUILD_NUMBER }}_windows_amd64.zip
${{ env.APP_NAME }}_${{ env.RELEASE_TAG }}_windows_amd64.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# releaseApple:
# name: Release ios and macos
# runs-on: macos-latest
# steps:
# - name: Checkout
# uses: actions/checkout@v6
# - name: Install Flutter
# uses: subosito/flutter-action@v2
# - name: Build
# run: dart run fl_build -p ios
# - name: Create Release
# uses: softprops/action-gh-release@v2
# with:
# files: |
# ${{ env.APP_NAME }}_universal.ipa
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
releaseIOS:
name: Release iOS
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: recursive
- name: Install Flutter
uses: subosito/flutter-action@v2
- name: Build
run: |
dart run fl_build -p ios -- --no-codesign
shopt -s nullglob
IPA_FILES=(build/ios/ipa/*.ipa)
if [ ${#IPA_FILES[@]} -ne 1 ]; then
echo "Error: expected 1 IPA, found ${#IPA_FILES[@]}"
ls -la build/ios/ipa || true
exit 1
fi
IPA_FILE="${IPA_FILES[0]}"
echo "Found IPA: $IPA_FILE"
cp "$IPA_FILE" "${APP_NAME}_${RELEASE_TAG}_ios.ipa"
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: |
${{ env.APP_NAME }}_${{ env.RELEASE_TAG }}_ios.ipa
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
releaseMacOS:
name: Release macOS
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: recursive
- name: Install Flutter
uses: subosito/flutter-action@v2
- name: Build
run: |
dart run fl_build -p macos -- --no-codesign
- name: Package
run: |
RELEASE_DIR="$GITHUB_WORKSPACE/build/macos/Build/Products/Release"
APP_DIR="$RELEASE_DIR/$APP_NAME.app"
OUT_ZIP="$GITHUB_WORKSPACE/${APP_NAME}_${RELEASE_TAG}_macos.zip"
if [ ! -d "$RELEASE_DIR" ]; then
echo "Error: macOS release directory not found: $RELEASE_DIR"
exit 1
fi
if [ ! -d "$APP_DIR" ]; then
echo "Error: macOS app bundle not found: $APP_DIR"
exit 1
fi
cd "$RELEASE_DIR"
zip -ry "$OUT_ZIP" "$APP_NAME.app"
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: |
${{ env.APP_NAME }}_${{ env.RELEASE_TAG }}_macos.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -748,7 +748,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 1291;
CURRENT_PROJECT_VERSION = 1297;
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
@@ -758,7 +758,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1291;
MARKETING_VERSION = 1.0.1297;
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 = 1291;
CURRENT_PROJECT_VERSION = 1297;
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
@@ -894,7 +894,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1291;
MARKETING_VERSION = 1.0.1297;
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 = 1291;
CURRENT_PROJECT_VERSION = 1297;
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
@@ -922,7 +922,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1291;
MARKETING_VERSION = 1.0.1297;
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 = 1291;
CURRENT_PROJECT_VERSION = 1297;
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.1291;
MARKETING_VERSION = 1.0.1297;
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 = 1291;
CURRENT_PROJECT_VERSION = 1297;
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.1291;
MARKETING_VERSION = 1.0.1297;
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 = 1291;
CURRENT_PROJECT_VERSION = 1297;
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.1291;
MARKETING_VERSION = 1.0.1297;
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 = 1291;
CURRENT_PROJECT_VERSION = 1297;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_PREVIEWS = YES;
@@ -1066,7 +1066,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1291;
MARKETING_VERSION = 1.0.1297;
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 = 1291;
CURRENT_PROJECT_VERSION = 1297;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_PREVIEWS = YES;
@@ -1107,7 +1107,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1291;
MARKETING_VERSION = 1.0.1297;
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 = 1291;
CURRENT_PROJECT_VERSION = 1297;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_PREVIEWS = YES;
@@ -1145,7 +1145,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1291;
MARKETING_VERSION = 1.0.1297;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
PRODUCT_NAME = ServerBox;

View File

@@ -112,7 +112,7 @@ extension SSHClientX on SSHClient {
return (session, result.takeBytes().string);
}
Future<int?> execWithPwd(
Future<(int?, String)> execWithPwd(
String script, {
String? entry,
BuildContext? context,
@@ -121,7 +121,7 @@ extension SSHClientX on SSHClient {
required String id,
}) async {
var isRequestingPwd = false;
final (session, _) = await exec(
final (session, output) = await exec(
(sess) {
sess.stdin.add('$script\n'.uint8List);
sess.stdin.close();
@@ -147,7 +147,7 @@ extension SSHClientX on SSHClient {
onStdout: onStdout,
entry: entry,
);
return session.exitCode;
return (session.exitCode, output);
}
Future<String> execForOutput(

View File

@@ -38,77 +38,6 @@ String getPrivateKey(String id) {
return pki.key;
}
List<Spi> resolveMergedJumpChain(
Spi target, {
List<Spi>? jumpChain,
}) {
final injectedSpiMap = <String, Spi>{};
if (jumpChain != null) {
for (final s in jumpChain) {
injectedSpiMap[s.id] = s;
if (s.oldId.isNotEmpty) {
injectedSpiMap[s.oldId] = s;
}
}
}
Spi resolveSpi(String id) {
final injected = injectedSpiMap[id];
if (injected != null) return injected;
if (jumpChain != null) {
throw SSHErr(type: SSHErrType.connect, message: 'Jump server not found in provided chain: $id');
}
final fromStore = Stores.server.box.get(id);
if (fromStore == null) {
throw SSHErr(type: SSHErrType.connect, message: 'Jump server not found: $id');
}
return fromStore;
}
return _resolveMergedJumpChainInternal(target, resolveSpi: resolveSpi);
}
List<Spi> _resolveMergedJumpChainInternal(
Spi target, {
required Spi Function(String id) resolveSpi,
}) {
final roots = target.jumpChainIds ?? (target.jumpId == null ? const <String>[] : [target.jumpId!]);
if (roots.isEmpty) return const <Spi>[];
final seen = <String>{};
final stack = <String>{};
final out = <Spi>[];
String normId(Spi spi) => spi.id.isNotEmpty ? spi.id : spi.oldId;
void dfs(String id) {
final hop = resolveSpi(id);
final norm = normId(hop);
if (stack.contains(norm)) {
throw SSHErr(type: SSHErrType.connect, message: 'Jump loop detected at $norm');
}
if (seen.contains(norm)) return;
stack.add(norm);
final deps = hop.jumpChainIds ?? (hop.jumpId == null ? const <String>[] : [hop.jumpId!]);
for (final dep in deps) {
dfs(dep);
}
stack.remove(norm);
if (seen.add(norm)) {
out.add(hop);
}
}
for (final r in roots) {
dfs(r);
}
return out;
}
Future<SSHClient> genClient(
Spi spi, {
void Function(GenSSHClientStatus)? onStatus,
@@ -116,59 +45,21 @@ Future<SSHClient> genClient(
/// Only pass this param if using multi-threading and key login
String? privateKey,
/// Pre-resolved jump chain (in `spi.jumpId` order: immediate -> farthest).
///
/// This is mainly used when `Stores` is unavailable (e.g. in an isolate).
List<Spi>? jumpChain,
/// Private keys for [jumpChain], aligned by index.
///
/// If a jump server uses key auth (`keyId != null`), you must provide the
/// decrypted key pem here (or `genClient` will try to read from `Stores`).
List<String?>? jumpPrivateKeys,
/// Only pass this param if using multi-threading and key login
String? jumpPrivateKey,
Duration timeout = const Duration(seconds: 5),
/// [Spi] of the jump server
///
/// Must pass this param if using multi-threading and key login
Spi? jumpSpi,
/// Handle keyboard-interactive authentication
SSHUserInfoRequestHandler? onKeyboardInteractive,
Map<String, String>? knownHostFingerprints,
void Function(String storageKey, String fingerprintHex)? onHostKeyAccepted,
Future<bool> Function(HostKeyPromptInfo info)? onHostKeyPrompt,
}) async {
return _genClientInternal(
spi,
onStatus: onStatus,
privateKey: privateKey,
jumpChain: jumpChain,
jumpPrivateKeys: jumpPrivateKeys,
timeout: timeout,
onKeyboardInteractive: onKeyboardInteractive,
knownHostFingerprints: knownHostFingerprints,
onHostKeyAccepted: onHostKeyAccepted,
onHostKeyPrompt: onHostKeyPrompt,
visited: <String>{},
);
}
Future<SSHClient> _genClientInternal(
Spi spi, {
void Function(GenSSHClientStatus)? onStatus,
String? privateKey,
List<Spi>? jumpChain,
List<String?>? jumpPrivateKeys,
Duration timeout = const Duration(seconds: 5),
SSHUserInfoRequestHandler? onKeyboardInteractive,
Map<String, String>? knownHostFingerprints,
void Function(String storageKey, String fingerprintHex)? onHostKeyAccepted,
Future<bool> Function(HostKeyPromptInfo info)? onHostKeyPrompt,
required Set<String> visited,
SSHSocket? socketOverride,
bool followJumpConfig = true,
}) async {
final identifier = _hostIdentifier(spi);
if (!visited.add(identifier)) {
throw SSHErr(type: SSHErrType.connect, message: 'Jump loop detected at ${spi.name} ($identifier)');
}
onStatus?.call(GenSSHClientStatus.socket);
final hostKeyCache = Map<String, String>.from(knownHostFingerprints ?? _loadKnownHostFingerprints());
@@ -177,126 +68,37 @@ Future<SSHClient> _genClientInternal(
String? alterUser;
final (socket, hopClients) = await () async {
if (socketOverride != null) return (socketOverride, <SSHClient>[]);
final socket = await () async {
// Proxy
final jumpSpi_ = () {
// Multi-thread or key login
if (jumpSpi != null) return jumpSpi;
// Main thread
if (spi.jumpId != null) return Stores.server.box.get(spi.jumpId);
}();
if (jumpSpi_ != null) {
final jumpClient = await genClient(
jumpSpi_,
privateKey: jumpPrivateKey,
timeout: timeout,
knownHostFingerprints: hostKeyCache,
onHostKeyAccepted: hostKeyPersist,
onHostKeyPrompt: onHostKeyPrompt,
);
if (followJumpConfig) {
final injectedSpiMap = <String, Spi>{};
final injectedKeyMap = <String, String?>{};
if (jumpChain != null) {
for (var i = 0; i < jumpChain.length; i++) {
final s = jumpChain[i];
injectedSpiMap[s.id] = s;
if (s.oldId.isNotEmpty) injectedSpiMap[s.oldId] = s;
if (jumpPrivateKeys != null && i < jumpPrivateKeys.length) {
injectedKeyMap[s.id] = jumpPrivateKeys[i];
if (s.oldId.isNotEmpty) injectedKeyMap[s.oldId] = jumpPrivateKeys[i];
}
}
}
Spi resolveSpi(String id) {
final injected = injectedSpiMap[id];
if (injected != null) return injected;
if (jumpChain != null) {
throw SSHErr(type: SSHErrType.connect, message: 'Jump server not found in provided chain: $id');
}
final fromStore = Stores.server.box.get(id);
if (fromStore == null) {
throw SSHErr(type: SSHErrType.connect, message: 'Jump server not found: $id');
}
return fromStore;
}
String? resolveHopPrivateKey(Spi hop) {
final keyId = hop.keyId;
if (keyId == null) return null;
final injected = injectedKeyMap[hop.id] ?? injectedKeyMap[hop.oldId];
return injected ?? getPrivateKey(keyId);
}
final hops = _resolveMergedJumpChainInternal(spi, resolveSpi: resolveSpi);
if (hops.isNotEmpty) {
// Build multi-hop forward chain with dedup/merge.
final createdClients = <SSHClient>[];
SSHClient? currentClient;
try {
final firstHop = hops.first;
final firstKey = resolveHopPrivateKey(firstHop);
if (firstHop.keyId != null && firstKey == null) {
throw SSHErr(type: SSHErrType.noPrivateKey, message: l10n.privateKeyNotFoundFmt(firstHop.keyId ?? ''));
}
currentClient = await _genClientInternal(
firstHop,
privateKey: firstKey,
jumpChain: jumpChain,
jumpPrivateKeys: jumpPrivateKeys,
timeout: timeout,
onKeyboardInteractive: onKeyboardInteractive,
knownHostFingerprints: hostKeyCache,
onHostKeyAccepted: hostKeyPersist,
onHostKeyPrompt: hostKeyPrompt,
visited: visited,
followJumpConfig: false,
);
createdClients.add(currentClient);
for (var i = 1; i < hops.length; i++) {
final hop = hops[i];
final forwarded = await currentClient!.forwardLocal(hop.ip, hop.port);
final hopKey = resolveHopPrivateKey(hop);
if (hop.keyId != null && hopKey == null) {
throw SSHErr(type: SSHErrType.noPrivateKey, message: l10n.privateKeyNotFoundFmt(hop.keyId ?? ''));
}
currentClient = await _genClientInternal(
hop,
privateKey: hopKey,
jumpChain: jumpChain,
jumpPrivateKeys: jumpPrivateKeys,
timeout: timeout,
onKeyboardInteractive: onKeyboardInteractive,
knownHostFingerprints: hostKeyCache,
onHostKeyAccepted: hostKeyPersist,
onHostKeyPrompt: hostKeyPrompt,
visited: visited,
socketOverride: forwarded,
followJumpConfig: false,
);
createdClients.add(currentClient);
}
final forwardedSocket = await currentClient!.forwardLocal(spi.ip, spi.port);
return (forwardedSocket, createdClients);
} catch (e) {
// Close all created clients on error to avoid leaks
for (final client in createdClients) {
try {
client.close();
} catch (_) {
// Ignore close errors during cleanup
}
}
rethrow;
}
// Note: On success, all intermediate clients must remain open
// because the returned socket tunnels through them.
}
return await jumpClient.forwardLocal(spi.ip, spi.port);
}
// Direct
try {
return (await SSHSocket.connect(spi.ip, spi.port, timeout: timeout), <SSHClient>[]);
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), <SSHClient>[]);
return await SSHSocket.connect(res.$1, res.$3, timeout: timeout);
} catch (e) {
Loggers.app.warning('genClient alterUrl', e);
rethrow;
@@ -311,52 +113,32 @@ Future<SSHClient> _genClientInternal(
prompt: hostKeyPrompt,
);
Future<SSHClient> buildClient(SSHSocket socket) async {
final keyId = spi.keyId;
if (keyId == null) {
onStatus?.call(GenSSHClientStatus.pwd);
return SSHClient(
socket,
username: alterUser ?? spi.user,
onPasswordRequest: () => spi.pwd,
onUserInfoRequest: onKeyboardInteractive,
onVerifyHostKey: hostKeyVerifier.call,
// printDebug: debugPrint,
// printTrace: debugPrint,
);
}
privateKey ??= getPrivateKey(keyId);
onStatus?.call(GenSSHClientStatus.key);
final keyId = spi.keyId;
if (keyId == null) {
onStatus?.call(GenSSHClientStatus.pwd);
return SSHClient(
socket,
username: spi.user,
// Must use [compute] here, instead of [Computer.shared.start]
identities: await compute(loadIndentity, privateKey!),
username: alterUser ?? spi.user,
onPasswordRequest: () => spi.pwd,
onUserInfoRequest: onKeyboardInteractive,
onVerifyHostKey: hostKeyVerifier.call,
// printDebug: debugPrint,
// printTrace: debugPrint,
);
}
privateKey ??= getPrivateKey(keyId);
final client = await buildClient(socket);
// Tie hop clients' lifetime to the final client: close all hop clients
// when the target client disconnects to avoid leaking SSH connections.
if (hopClients.isNotEmpty) {
client.done.whenComplete(() {
for (final hopClient in hopClients) {
try {
hopClient.close();
} catch (_) {
// Ignore close errors during cleanup
}
}
});
}
return client;
onStatus?.call(GenSSHClientStatus.key);
return SSHClient(
socket,
username: spi.user,
// Must use [compute] here, instead of [Computer.shared.start]
identities: await compute(loadIndentity, privateKey),
onUserInfoRequest: onKeyboardInteractive,
onVerifyHostKey: hostKeyVerifier.call,
// printDebug: debugPrint,
// printTrace: debugPrint,
);
}
typedef _HostKeyPersistCallback = void Function(String storageKey, String fingerprintHex);
@@ -518,53 +300,20 @@ Future<void> ensureKnownHostKey(
Duration timeout = const Duration(seconds: 5),
SSHUserInfoRequestHandler? onKeyboardInteractive,
}) async {
var cache = _loadKnownHostFingerprints();
final hops = resolveMergedJumpChain(spi);
// Check each hop's host key, routing through preceding hops
for (var i = 0; i < hops.length; i++) {
final hop = hops[i];
// Preceding hops needed to reach this hop
final precedingHops = i > 0 ? hops.sublist(0, i) : null;
final precedingKeys = precedingHops?.map((h) =>
h.keyId != null ? getPrivateKey(h.keyId!) : null
).toList();
cache = await _ensureKnownHostKeyForSingle(
hop,
cache: cache,
timeout: timeout,
onKeyboardInteractive: onKeyboardInteractive,
jumpChain: precedingHops,
jumpPrivateKeys: precedingKeys,
);
final cache = _loadKnownHostFingerprints();
if (_hasKnownHostFingerprintForSpi(spi, cache)) {
return;
}
// Check the target's host key, routing through all hops
final allKeys = hops.isNotEmpty
? hops.map((h) => h.keyId != null ? getPrivateKey(h.keyId!) : null).toList()
: null;
await _ensureKnownHostKeyForSingle(
spi,
cache: cache,
timeout: timeout,
onKeyboardInteractive: onKeyboardInteractive,
jumpChain: hops.isNotEmpty ? hops : null,
jumpPrivateKeys: allKeys,
);
}
Future<Map<String, String>> _ensureKnownHostKeyForSingle(
Spi spi, {
required Map<String, String> cache,
Duration timeout = const Duration(seconds: 5),
SSHUserInfoRequestHandler? onKeyboardInteractive,
List<Spi>? jumpChain,
List<String?>? jumpPrivateKeys,
}) async {
if (_hasKnownHostFingerprintForSpi(spi, cache)) {
return cache;
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(
@@ -572,8 +321,6 @@ Future<Map<String, String>> _ensureKnownHostKeyForSingle(
timeout: timeout,
onKeyboardInteractive: onKeyboardInteractive,
knownHostFingerprints: cache,
jumpChain: jumpChain,
jumpPrivateKeys: jumpPrivateKeys,
);
try {
@@ -581,9 +328,6 @@ Future<Map<String, String>> _ensureKnownHostKeyForSingle(
} finally {
client.close();
}
cache.addAll(_loadKnownHostFingerprints());
return cache;
}
bool _hasKnownHostFingerprintForSpi(Spi spi, Map<String, String> cache) {

View File

@@ -26,6 +26,7 @@ enum ContainerErrType {
parsePs,
parseImages,
parseStats,
podmanDetected,
}
class ContainerErr extends Err<ContainerErrType> {

View File

@@ -1,7 +1,6 @@
import 'dart:convert';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/foundation.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';
@@ -36,15 +35,8 @@ abstract class Spi with _$Spi {
String? alterUrl,
@Default(true) bool autoConnect,
/// [id] of the jump server (legacy, single hop)
///
/// Migrated to [jumpChainIds].
/// [id] of the jump server
String? jumpId,
/// Jump chain hop ids (nearest -> farthest)
///
/// Preferred over [jumpId].
@JsonKey(includeIfNull: false) List<String>? jumpChainIds,
ServerCustom? custom,
WakeOnLanCfg? wolCfg,
@@ -87,10 +79,7 @@ extension Spix on Spi {
String? migrateId() {
if (id.isNotEmpty) return null;
ServerStore.instance.delete(oldId);
final newSpi = copyWith(
id: ShortId.generate(),
jumpChainIds: jumpChainIds ?? (jumpId == null ? null : [jumpId!]),
);
final newSpi = copyWith(id: ShortId.generate());
newSpi.save();
return newSpi.id;
}
@@ -105,8 +94,7 @@ extension Spix on Spi {
port == other.port &&
pwd == other.pwd &&
keyId == other.keyId &&
jumpId == other.jumpId &&
listEquals(jumpChainIds, other.jumpChainIds);
jumpId == other.jumpId;
}
/// Returns true if the connection should be re-established.
@@ -149,7 +137,7 @@ extension Spix on Spi {
tags: ['tag1', 'tag2'],
alterUrl: 'user@ip:port',
autoConnect: true,
jumpChainIds: ['jump_server_id'],
jumpId: 'jump_server_id',
custom: ServerCustom(
pveAddr: 'http://localhost:8006',
pveIgnoreCert: false,

View File

@@ -16,13 +16,8 @@ T _$identity<T>(T value) => value;
mixin _$Spi {
String get name; String get ip; int get port; String get user; String? get pwd;/// [id] of private key
@JsonKey(name: 'pubKeyId') String? get keyId; List<String>? get tags; String? get alterUrl; bool get autoConnect;/// [id] of the jump server (legacy, single hop)
///
/// Migrated to [jumpChainIds].
String? get jumpId;/// Jump chain hop ids (nearest -> farthest)
///
/// Preferred over [jumpId].
@JsonKey(includeIfNull: false) List<String>? get jumpChainIds; ServerCustom? get custom; WakeOnLanCfg? get wolCfg;/// It only applies to SSH terminal.
@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;
@@ -38,12 +33,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)&&const DeepCollectionEquality().equals(other.jumpChainIds, jumpChainIds)&&(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));
}
@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,const DeepCollectionEquality().hash(jumpChainIds),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));
@@ -54,7 +49,7 @@ 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,@JsonKey(includeIfNull: false) List<String>? jumpChainIds, 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,@JsonKey(includeIfNull: false) SystemType? customSystemType,@JsonKey(includeIfNull: false) List<String>? disabledCmdTypes
});
@@ -71,7 +66,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? jumpChainIds = 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,}) {
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
@@ -83,8 +78,7 @@ as String?,tags: freezed == tags ? _self.tags : tags // ignore: cast_nullable_to
as List<String>?,alterUrl: freezed == alterUrl ? _self.alterUrl : alterUrl // ignore: cast_nullable_to_non_nullable
as String?,autoConnect: null == autoConnect ? _self.autoConnect : autoConnect // ignore: cast_nullable_to_non_nullable
as bool,jumpId: freezed == jumpId ? _self.jumpId : jumpId // ignore: cast_nullable_to_non_nullable
as String?,jumpChainIds: freezed == jumpChainIds ? _self.jumpChainIds : jumpChainIds // ignore: cast_nullable_to_non_nullable
as List<String>?,custom: freezed == custom ? _self.custom : custom // ignore: cast_nullable_to_non_nullable
as String?,custom: freezed == custom ? _self.custom : custom // ignore: cast_nullable_to_non_nullable
as ServerCustom?,wolCfg: freezed == wolCfg ? _self.wolCfg : wolCfg // ignore: cast_nullable_to_non_nullable
as WakeOnLanCfg?,envs: freezed == envs ? _self.envs : envs // ignore: cast_nullable_to_non_nullable
as Map<String, String>?,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
@@ -175,10 +169,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, @JsonKey(includeIfNull: false) List<String>? jumpChainIds, 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, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List<String>? disabledCmdTypes)? $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.jumpChainIds,_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);case _:
return orElse();
}
@@ -196,10 +190,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, @JsonKey(includeIfNull: false) List<String>? jumpChainIds, 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, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List<String>? disabledCmdTypes) $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.jumpChainIds,_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);case _:
throw StateError('Unexpected subclass');
}
@@ -216,10 +210,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, @JsonKey(includeIfNull: false) List<String>? jumpChainIds, 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, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List<String>? disabledCmdTypes)? $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.jumpChainIds,_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);case _:
return null;
}
@@ -231,7 +225,7 @@ return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,
@JsonSerializable(includeIfNull: false)
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, @JsonKey(includeIfNull: false) final List<String>? jumpChainIds, 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,_jumpChainIds = jumpChainIds,_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 = '', @JsonKey(includeIfNull: false) this.customSystemType, @JsonKey(includeIfNull: false) final List<String>? disabledCmdTypes}): _tags = tags,_envs = envs,_disabledCmdTypes = disabledCmdTypes,super._();
factory _Spi.fromJson(Map<String, dynamic> json) => _$SpiFromJson(json);
@override final String name;
@@ -252,25 +246,8 @@ class _Spi extends Spi {
@override final String? alterUrl;
@override@JsonKey() final bool autoConnect;
/// [id] of the jump server (legacy, single hop)
///
/// Migrated to [jumpChainIds].
/// [id] of the jump server
@override final String? jumpId;
/// Jump chain hop ids (nearest -> farthest)
///
/// Preferred over [jumpId].
final List<String>? _jumpChainIds;
/// Jump chain hop ids (nearest -> farthest)
///
/// Preferred over [jumpId].
@override@JsonKey(includeIfNull: false) List<String>? get jumpChainIds {
final value = _jumpChainIds;
if (value == null) return null;
if (_jumpChainIds is EqualUnmodifiableListView) return _jumpChainIds;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(value);
}
@override final ServerCustom? custom;
@override final WakeOnLanCfg? wolCfg;
/// It only applies to SSH terminal.
@@ -312,12 +289,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)&&const DeepCollectionEquality().equals(other._jumpChainIds, _jumpChainIds)&&(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));
}
@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,const DeepCollectionEquality().hash(_jumpChainIds),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));
@@ -328,7 +305,7 @@ 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,@JsonKey(includeIfNull: false) List<String>? jumpChainIds, 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,@JsonKey(includeIfNull: false) SystemType? customSystemType,@JsonKey(includeIfNull: false) List<String>? disabledCmdTypes
});
@@ -345,7 +322,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? jumpChainIds = 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,}) {
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
@@ -357,8 +334,7 @@ as String?,tags: freezed == tags ? _self._tags : tags // ignore: cast_nullable_t
as List<String>?,alterUrl: freezed == alterUrl ? _self.alterUrl : alterUrl // ignore: cast_nullable_to_non_nullable
as String?,autoConnect: null == autoConnect ? _self.autoConnect : autoConnect // ignore: cast_nullable_to_non_nullable
as bool,jumpId: freezed == jumpId ? _self.jumpId : jumpId // ignore: cast_nullable_to_non_nullable
as String?,jumpChainIds: freezed == jumpChainIds ? _self._jumpChainIds : jumpChainIds // ignore: cast_nullable_to_non_nullable
as List<String>?,custom: freezed == custom ? _self.custom : custom // ignore: cast_nullable_to_non_nullable
as String?,custom: freezed == custom ? _self.custom : custom // ignore: cast_nullable_to_non_nullable
as ServerCustom?,wolCfg: freezed == wolCfg ? _self.wolCfg : wolCfg // ignore: cast_nullable_to_non_nullable
as WakeOnLanCfg?,envs: freezed == envs ? _self._envs : envs // ignore: cast_nullable_to_non_nullable
as Map<String, String>?,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable

View File

@@ -17,9 +17,6 @@ _Spi _$SpiFromJson(Map<String, dynamic> json) => _Spi(
alterUrl: json['alterUrl'] as String?,
autoConnect: json['autoConnect'] as bool? ?? true,
jumpId: json['jumpId'] as String?,
jumpChainIds: (json['jumpChainIds'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
custom: json['custom'] == null
? null
: ServerCustom.fromJson(json['custom'] as Map<String, dynamic>),
@@ -50,7 +47,6 @@ Map<String, dynamic> _$SpiToJson(_Spi instance) => <String, dynamic>{
'alterUrl': ?instance.alterUrl,
'autoConnect': instance.autoConnect,
'jumpId': ?instance.jumpId,
'jumpChainIds': ?instance.jumpChainIds,
'custom': ?instance.custom,
'wolCfg': ?instance.wolCfg,
'envs': ?instance.envs,

View File

@@ -6,8 +6,8 @@ class SftpReq {
final String localPath;
final SftpReqType type;
String? privateKey;
List<Spi>? jumpChain;
List<String?>? jumpPrivateKeys;
Spi? jumpSpi;
String? jumpPrivateKey;
Map<String, String>? knownHostFingerprints;
SftpReq(this.spi, this.remotePath, this.localPath, this.type) {
@@ -15,17 +15,9 @@ class SftpReq {
if (keyId != null) {
privateKey = getPrivateKey(keyId);
}
if (spi.jumpChainIds != null || spi.jumpId != null) {
// Use resolveMergedJumpChain to recursively expand nested hop chains
final chain = resolveMergedJumpChain(spi);
final keys = <String?>[];
for (final hop in chain) {
keys.add(hop.keyId != null ? getPrivateKey(hop.keyId!) : null);
}
// Always set when a jump is configured so the isolate won't fallback to Stores.
jumpChain = chain;
jumpPrivateKeys = keys;
if (spi.jumpId != null) {
jumpSpi = Stores.server.box.get(spi.jumpId);
jumpPrivateKey = Stores.key.fetchOne(jumpSpi?.keyId)?.key;
}
try {
knownHostFingerprints = Map<String, String>.from(Stores.setting.sshKnownHostFingerprints.get());
@@ -98,4 +90,4 @@ class SftpReqStatus {
}
}
enum SftpWorkerStatus { preparing, sshConnected, loading, finished }
enum SftpWorkerStatus { preparing, sshConnectted, loading, finished }

View File

@@ -63,11 +63,11 @@ Future<void> _download(SftpReq req, SendPort mainSendPort, SendErrorFunction sen
final client = await genClient(
req.spi,
privateKey: req.privateKey,
jumpChain: req.jumpChain,
jumpPrivateKeys: req.jumpPrivateKeys,
jumpSpi: req.jumpSpi,
jumpPrivateKey: req.jumpPrivateKey,
knownHostFingerprints: req.knownHostFingerprints,
);
mainSendPort.send(SftpWorkerStatus.sshConnected);
mainSendPort.send(SftpWorkerStatus.sshConnectted);
/// Create the directory if not exists
final dirPath = req.localPath.substring(0, req.localPath.lastIndexOf(Pfs.seperator));
@@ -120,11 +120,11 @@ Future<void> _upload(SftpReq req, SendPort mainSendPort, SendErrorFunction sendE
final client = await genClient(
req.spi,
privateKey: req.privateKey,
jumpChain: req.jumpChain,
jumpPrivateKeys: req.jumpPrivateKeys,
jumpSpi: req.jumpSpi,
jumpPrivateKey: req.jumpPrivateKey,
knownHostFingerprints: req.knownHostFingerprints,
);
mainSendPort.send(SftpWorkerStatus.sshConnected);
mainSendPort.send(SftpWorkerStatus.sshConnectted);
final local = File(req.localPath);
if (!await local.exists()) {

View File

@@ -6,6 +6,7 @@ import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/core/extension/ssh_client.dart';
import 'package:server_box/data/model/app/error.dart';
import 'package:server_box/data/model/app/scripts/script_consts.dart';
@@ -18,6 +19,7 @@ part 'container.freezed.dart';
part 'container.g.dart';
final _dockerNotFound = RegExp(r"command not found|Unknown command|Command '\w+' not found");
final _podmanEmulationMsg = 'Emulate Docker CLI using podman';
@freezed
abstract class ContainerState with _$ContainerState {
@@ -84,21 +86,51 @@ class ContainerNotifier extends _$ContainerNotifier {
}
final includeStats = Stores.setting.containerParseStat.fetch();
var raw = '';
final cmd = _wrap(ContainerCmdType.execAll(state.type, sudo: sudo, includeStats: includeStats));
final code = await client?.execWithPwd(
cmd,
context: context,
onStdout: (data, _) => raw = '$raw$data',
id: hostId,
);
int? code;
String raw = '';
final errs = <String>[];
if (client != null) {
(code, raw) = await client!.execWithPwd(cmd, context: context, id: hostId);
} else {
state = state.copyWith(
isBusy: false,
error: ContainerErr(type: ContainerErrType.noClient),
);
return;
}
if (!ref.mounted) return;
state = state.copyWith(isBusy: false);
if (!context.mounted) return;
/// Code 127 means command not found
if (code == 127 || raw.contains(_dockerNotFound)) {
if (code == 127 || raw.contains(_dockerNotFound) || errs.join().contains(_dockerNotFound)) {
state = state.copyWith(error: ContainerErr(type: ContainerErrType.notInstalled));
return;
}
/// Pre-parse Podman detection
if (raw.contains(_podmanEmulationMsg)) {
state = state.copyWith(
error: ContainerErr(
type: ContainerErrType.podmanDetected,
message: l10n.podmanDockerEmulationDetected,
),
);
return;
}
/// Filter out sudo password prompt from output
if (errs.any((e) => e.contains('[sudo] password'))) {
raw = raw.split('\n').where((line) => !line.contains('[sudo] password')).join('\n');
}
/// Detect Podman not installed when using Podman mode
if (state.type == ContainerType.podman &&
(errs.any((e) => e.contains('podman: not found')) ||
raw.contains('podman: not found'))) {
state = state.copyWith(error: ContainerErr(type: ContainerErrType.notInstalled));
return;
}
@@ -122,9 +154,11 @@ class ContainerNotifier extends _$ContainerNotifier {
final version = json.decode(verRaw)['Client']['Version'];
state = state.copyWith(version: version, error: null);
} catch (e, trace) {
state = state.copyWith(
error: ContainerErr(type: ContainerErrType.invalidVersion, message: '$e'),
);
if (state.error == null) {
state = state.copyWith(
error: ContainerErr(type: ContainerErrType.invalidVersion, message: '$e'),
);
}
Loggers.app.warning('Container version failed', e, trace);
}
@@ -140,9 +174,11 @@ class ContainerNotifier extends _$ContainerNotifier {
final items = lines.map((e) => ContainerPs.fromRaw(e, state.type)).toList();
state = state.copyWith(items: items);
} catch (e, trace) {
state = state.copyWith(
error: ContainerErr(type: ContainerErrType.parsePs, message: '$e'),
);
if (state.error == null) {
state = state.copyWith(
error: ContainerErr(type: ContainerErrType.parsePs, message: '$e'),
);
}
Loggers.app.warning('Container ps failed', e, trace);
}
@@ -162,9 +198,11 @@ class ContainerNotifier extends _$ContainerNotifier {
}
state = state.copyWith(images: images);
} catch (e, trace) {
state = state.copyWith(
error: ContainerErr(type: ContainerErrType.parseImages, message: '$e'),
);
if (state.error == null) {
state = state.copyWith(
error: ContainerErr(type: ContainerErrType.parseImages, message: '$e'),
);
}
Loggers.app.warning('Container images failed', e, trace);
}
@@ -189,9 +227,11 @@ class ContainerNotifier extends _$ContainerNotifier {
item.parseStats(statsLine, state.version);
}
} catch (e, trace) {
state = state.copyWith(
error: ContainerErr(type: ContainerErrType.parseStats, message: '$e'),
);
if (state.error == null) {
state = state.copyWith(
error: ContainerErr(type: ContainerErrType.parseStats, message: '$e'),
);
}
Loggers.app.warning('Parse docker stats: $statsRaw', e, trace);
}
}
@@ -227,6 +267,10 @@ class ContainerNotifier extends _$ContainerNotifier {
}
Future<ContainerErr?> run(String cmd, {bool autoRefresh = true}) async {
if (client == null) {
return ContainerErr(type: ContainerErrType.noClient);
}
cmd = switch (state.type) {
ContainerType.docker => 'docker $cmd',
ContainerType.podman => 'podman $cmd',
@@ -234,7 +278,7 @@ class ContainerNotifier extends _$ContainerNotifier {
state = state.copyWith(runLog: '');
final errs = <String>[];
final code = await client?.execWithPwd(
final (code, _) = await client?.execWithPwd(
_wrap((await sudoCompleter.future) ? 'sudo -S $cmd' : cmd),
context: context,
onStdout: (data, _) {
@@ -242,7 +286,7 @@ class ContainerNotifier extends _$ContainerNotifier {
},
onStderr: (data, _) => errs.add(data),
id: hostId,
);
) ?? (null, null);
state = state.copyWith(runLog: null);
if (code != 0) {

View File

@@ -41,7 +41,7 @@ final class ServersNotifierProvider
}
}
String _$serversNotifierHash() => r'277d1b219235f14bcc1b82a1e16260c2f28decdb';
String _$serversNotifierHash() => r'dc5da44f9bd8d8dcfba3e6e932cca3e2f379e582';
abstract class _$ServersNotifier extends $Notifier<ServersState> {
ServersState build();

View File

@@ -135,7 +135,7 @@ class ServerNotifier extends _$ServerNotifier {
final time2 = DateTime.now();
final spentTime = time2.difference(time1).inMilliseconds;
if ((spi.jumpChainIds?.isNotEmpty != true) && spi.jumpId == null) {
if (spi.jumpId == null) {
Loggers.app.info('Connected to ${spi.name} in $spentTime ms.');
} else {
Loggers.app.info('Jump to ${spi.name} in $spentTime ms.');

View File

@@ -58,7 +58,7 @@ final class ServerNotifierProvider
}
}
String _$serverNotifierHash() => r'52e806bcc32a7818d1ec2b07a3c683b06885c9f8';
String _$serverNotifierHash() => r'04b1beef4d96242fd10d5b523c6f5f17eb774bae';
final class ServerNotifierFamily extends $Family
with

View File

@@ -3,6 +3,6 @@
abstract class BuildData {
static const String name = "ServerBox";
static const int build = 1291;
static const int build = 1297;
static const int script = 70;
}

View File

@@ -89,12 +89,15 @@ class ServerStore extends HiveStore {
// Replace ids in jump server settings.
final spi = get<Spi>(newId);
if (spi != null) {
final jumpChainIds = spi.jumpChainIds ?? (spi.jumpId == null ? null : [spi.jumpId!]);
if (jumpChainIds == null || jumpChainIds.isEmpty) continue;
final newChain = jumpChainIds.map((e) => idMap[e] ?? e).toList();
final newSpi = spi.copyWith(jumpId: null, jumpChainIds: newChain);
update(spi, newSpi);
final jumpId = spi.jumpId; // This could be an oldId.
// Check if this jumpId corresponds to a server that was also migrated.
if (jumpId != null && idMap.containsKey(jumpId)) {
final newJumpId = idMap[jumpId];
if (spi.jumpId != newJumpId) {
final newSpi = spi.copyWith(jumpId: newJumpId);
update(spi, newSpi);
}
}
}
// Replace ids in [Snippet]

View File

@@ -1933,6 +1933,12 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Logs'**
String get logs;
/// No description provided for @podmanDockerEmulationDetected.
///
/// In en, this message translates to:
/// **'Podman Docker emulation detected. Please switch to Podman in settings.'**
String get podmanDockerEmulationDetected;
}
class _AppLocalizationsDelegate

View File

@@ -1031,4 +1031,8 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get logs => 'Protokolle';
@override
String get podmanDockerEmulationDetected =>
'Podman Docker-Emulation erkannt. Bitte wechseln Sie in den Einstellungen zu Podman.';
}

View File

@@ -1022,4 +1022,8 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get logs => 'Logs';
@override
String get podmanDockerEmulationDetected =>
'Podman Docker emulation detected. Please switch to Podman in settings.';
}

View File

@@ -1033,4 +1033,8 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get logs => 'Registros';
@override
String get podmanDockerEmulationDetected =>
'Detectada emulación de Podman Docker. Por favor, cambie a Podman en la configuración.';
}

View File

@@ -1036,4 +1036,8 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get logs => 'Journaux';
@override
String get podmanDockerEmulationDetected =>
'Émulation Podman Docker détectée. Veuillez passer à Podman dans les paramètres.';
}

View File

@@ -1022,4 +1022,8 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get logs => 'Log';
@override
String get podmanDockerEmulationDetected =>
'Emulasi Podman Docker terdeteksi. Silakan beralih ke Podman di pengaturan.';
}

View File

@@ -992,4 +992,8 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get logs => 'ログ';
@override
String get podmanDockerEmulationDetected =>
'Podman Docker エミュレーションが検出されました。設定で Podman に切り替えてください。';
}

View File

@@ -1029,4 +1029,8 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get logs => 'Logboeken';
@override
String get podmanDockerEmulationDetected =>
'Podman Docker-emulatie gedetecteerd. Schakel over naar Podman in de instellingen.';
}

View File

@@ -1024,4 +1024,8 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get logs => 'Logs';
@override
String get podmanDockerEmulationDetected =>
'Emulação Podman Docker detectada. Por favor, alterne para Podman nas configurações.';
}

View File

@@ -1028,4 +1028,8 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get logs => 'Журналы';
@override
String get podmanDockerEmulationDetected =>
'Обнаружена эмуляция Podman Docker. Пожалуйста, переключитесь на Podman в настройках.';
}

View File

@@ -1023,4 +1023,8 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get logs => 'Günlükler';
@override
String get podmanDockerEmulationDetected =>
'Podman Docker emülasyonu tespit edildi. Lütfen ayarlarda Podman\'a geçin.';
}

View File

@@ -1028,4 +1028,8 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get logs => 'Журнали';
@override
String get podmanDockerEmulationDetected =>
'Виявлено емуляцію Podman Docker. Будь ласка, переключіться на Podman у налаштуваннях.';
}

View File

@@ -977,6 +977,10 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get logs => '日志';
@override
String get podmanDockerEmulationDetected =>
'检测到 Podman Docker 仿真。请在设置中切换到 Podman。';
}
/// The translations for Chinese, as used in Taiwan (`zh_TW`).
@@ -1931,4 +1935,8 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get logs => '日誌';
@override
String get podmanDockerEmulationDetected =>
'檢測到 Podman Docker 仿真。請在設定中切換到 Podman。';
}

View File

@@ -107,7 +107,6 @@ class SpiAdapter extends TypeAdapter<Spi> {
alterUrl: fields[7] as String?,
autoConnect: fields[8] == null ? true : fields[8] as bool,
jumpId: fields[9] as String?,
jumpChainIds: (fields[16] as List?)?.cast<String>(),
custom: fields[10] as ServerCustom?,
wolCfg: fields[11] as WakeOnLanCfg?,
envs: (fields[12] as Map?)?.cast<String, String>(),
@@ -120,7 +119,7 @@ class SpiAdapter extends TypeAdapter<Spi> {
@override
void write(BinaryWriter writer, Spi obj) {
writer
..writeByte(17)
..writeByte(16)
..writeByte(0)
..write(obj.name)
..writeByte(1)
@@ -152,9 +151,7 @@ class SpiAdapter extends TypeAdapter<Spi> {
..writeByte(14)
..write(obj.customSystemType)
..writeByte(15)
..write(obj.disabledCmdTypes)
..writeByte(16)
..write(obj.jumpChainIds);
..write(obj.disabledCmdTypes);
}
@override

View File

@@ -27,7 +27,7 @@ types:
index: 4
Spi:
typeId: 3
nextIndex: 17
nextIndex: 16
fields:
name:
index: 0
@@ -61,8 +61,6 @@ types:
index: 14
disabledCmdTypes:
index: 15
jumpChainIds:
index: 16
VirtKey:
typeId: 4
nextIndex: 45

View File

@@ -294,5 +294,6 @@
"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.",
"logs": "Protokolle"
"logs": "Protokolle",
"podmanDockerEmulationDetected": "Podman Docker-Emulation erkannt. Bitte wechseln Sie in den Einstellungen zu Podman."
}

View File

@@ -304,5 +304,6 @@
"menuGitHubRepository": "GitHub Repository",
"menuWiki": "Wiki",
"menuHelp": "Help",
"logs": "Logs"
"logs": "Logs",
"podmanDockerEmulationDetected": "Podman Docker emulation detected. Please switch to Podman in settings."
}

View File

@@ -294,5 +294,6 @@
"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.",
"logs": "Registros"
"logs": "Registros",
"podmanDockerEmulationDetected": "Detectada emulación de Podman Docker. Por favor, cambie a Podman en la configuración."
}

View File

@@ -294,5 +294,6 @@
"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.",
"logs": "Journaux"
"logs": "Journaux",
"podmanDockerEmulationDetected": "Émulation Podman Docker détectée. Veuillez passer à Podman dans les paramètres."
}

View File

@@ -294,5 +294,6 @@
"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.",
"logs": "Log"
"logs": "Log",
"podmanDockerEmulationDetected": "Emulasi Podman Docker terdeteksi. Silakan beralih ke Podman di pengaturan."
}

View File

@@ -294,5 +294,6 @@
"write": "書き込み",
"writeScriptFailTip": "スクリプトの書き込みに失敗しました。権限がないかディレクトリが存在しない可能性があります。",
"writeScriptTip": "サーバーへの接続後、システムステータスを監視するスクリプトが `~/.config/server_box` \n | `/tmp/server_box` に書き込まれます。スクリプトの内容を確認できます。",
"logs": "ログ"
"logs": "ログ",
"podmanDockerEmulationDetected": "Podman Docker エミュレーションが検出されました。設定で Podman に切り替えてください。"
}

View File

@@ -294,5 +294,6 @@
"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.",
"logs": "Logboeken"
"logs": "Logboeken",
"podmanDockerEmulationDetected": "Podman Docker-emulatie gedetecteerd. Schakel over naar Podman in de instellingen."
}

View File

@@ -294,5 +294,6 @@
"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.",
"logs": "Logs"
"logs": "Logs",
"podmanDockerEmulationDetected": "Emulação Podman Docker detectada. Por favor, alterne para Podman nas configurações."
}

View File

@@ -294,5 +294,6 @@
"write": "Запись",
"writeScriptFailTip": "Запись скрипта не удалась, возможно, из-за отсутствия прав или потому что, директории не существует.",
"writeScriptTip": "После подключения к серверу скрипт будет записан в `~/.config/server_box` \n | `/tmp/server_box` для мониторинга состояния системы. Вы можете проверить содержимое скрипта.",
"logs": "Журналы"
"logs": "Журналы",
"podmanDockerEmulationDetected": "Обнаружена эмуляция Podman Docker. Пожалуйста, переключитесь на Podman в настройках."
}

View File

@@ -294,5 +294,6 @@
"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.",
"logs": "Günlükler"
"logs": "Günlükler",
"podmanDockerEmulationDetected": "Podman Docker emülasyonu tespit edildi. Lütfen ayarlarda Podman'a geçin."
}

View File

@@ -294,5 +294,6 @@
"write": "Записати",
"writeScriptFailTip": "Запис у скрипт не вдався, можливо, через брак дозволів або каталог не існує.",
"writeScriptTip": "Після підключення до сервера скрипт буде записано у `~/.config/server_box` \n | `/tmp/server_box` для моніторингу стану системи. Ви можете переглянути вміст скрипта.",
"logs": "Журнали"
"logs": "Журнали",
"podmanDockerEmulationDetected": "Виявлено емуляцію Podman Docker. Будь ласка, переключіться на Podman у налаштуваннях."
}

View File

@@ -301,5 +301,6 @@
"menuGitHubRepository": "GitHub 仓库",
"menuWiki": "Wiki",
"menuHelp": "帮助",
"logs": "日志"
"logs": "日志",
"podmanDockerEmulationDetected": "检测到 Podman Docker 仿真。请在设置中切换到 Podman。"
}

View File

@@ -294,5 +294,6 @@
"write": "寫入",
"writeScriptFailTip": "寫入腳本失敗,可能是沒有權限/目錄不存在等。",
"writeScriptTip": "連線到伺服器後,將會在 `~/.config/server_box` \n | `/tmp/server_box` 中寫入一個腳本來監測系統狀態。你可以審查腳本內容。",
"logs": "日誌"
"logs": "日誌",
"podmanDockerEmulationDetected": "檢測到 Podman Docker 仿真。請在設定中切換到 Podman。"
}

View File

@@ -234,7 +234,7 @@ class _ContainerPageState extends ConsumerState<ContainerPage> {
if (item.cpu == null || item.mem == null) return UIs.placeholder;
return LayoutBuilder(
builder: (_, cons) {
final width = cons.maxWidth / 2 - 41;
final width = cons.maxWidth / 2 - 6.5;
return Column(
children: [
UIs.height13,
@@ -264,10 +264,17 @@ class _ContainerPageState extends ConsumerState<ContainerPage> {
child: Column(
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: Colors.grey),
UIs.width7,
Text(value ?? l10n.unknown, style: UIs.text11Grey),
Expanded(
child: Text(
value ?? l10n.unknown,
style: UIs.text11Grey,
overflow: TextOverflow.ellipsis,
),
),
],
),
],

View File

@@ -222,30 +222,6 @@ extension _Actions on _ServerEditPageState {
return;
}
final oldSpi = this.spi;
if (oldSpi != null) {
final originalJumpChain = oldSpi.jumpChainIds ?? (oldSpi.jumpId == null ? const <String>[] : [oldSpi.jumpId!]);
final currentJumpChain = _jumpChain.value;
final jumpChainChanged = () {
if (originalJumpChain.isEmpty && currentJumpChain.isEmpty) return false;
if (originalJumpChain.length != currentJumpChain.length) return true;
for (var i = 0; i < originalJumpChain.length; i++) {
if (originalJumpChain[i] != currentJumpChain[i]) return true;
}
return false;
}();
if (jumpChainChanged) {
final ok = await context.showRoundDialog<bool>(
title: libL10n.attention,
child: Text(libL10n.askContinue('${l10n.jumpServer} ${libL10n.setting}')),
actions: Btnx.cancelOk,
);
if (ok != true) return;
}
}
if (_keyIdx.value == null && _passwordController.text.isEmpty) {
final ok = await context.showRoundDialog<bool>(
title: libL10n.attention,
@@ -301,8 +277,7 @@ extension _Actions on _ServerEditPageState {
tags: _tags.value.isEmpty ? null : _tags.value.toList(),
alterUrl: _altUrlController.text.selfNotEmptyOrNull,
autoConnect: _autoConnect.value,
jumpId: null,
jumpChainIds: _jumpChain.value.isEmpty ? null : _jumpChain.value,
jumpId: _jumpServer.value,
custom: custom,
wolCfg: wol,
envs: _env.value.isEmpty ? null : _env.value,
@@ -446,7 +421,7 @@ extension _Utils on _ServerEditPageState {
_altUrlController.text = spi.alterUrl ?? '';
_autoConnect.value = spi.autoConnect;
_jumpChain.value = spi.jumpChainIds ?? (spi.jumpId == null ? const <String>[] : [spi.jumpId!]);
_jumpServer.value = spi.jumpId;
final custom = spi.custom;
if (custom != null) {

View File

@@ -25,7 +25,6 @@ import 'package:server_box/view/page/private_key/edit.dart';
import 'package:server_box/view/page/server/discovery/discovery.dart';
part 'actions.dart';
part 'jump_chain.dart';
part 'widget.dart';
class ServerEditPage extends ConsumerStatefulWidget {
@@ -67,7 +66,7 @@ class _ServerEditPageState extends ConsumerState<ServerEditPage> with AfterLayou
/// -1: non selected, null: password, others: index of private key
final _keyIdx = ValueNotifier<int?>(null);
final _autoConnect = ValueNotifier(true);
final _jumpChain = <String>[].vn;
final _jumpServer = nvn<String?>();
final _pveIgnoreCert = ValueNotifier(false);
final _env = <String, String>{}.vn;
final _customCmds = <String, String>{}.vn;
@@ -101,7 +100,7 @@ class _ServerEditPageState extends ConsumerState<ServerEditPage> with AfterLayou
_keyIdx.dispose();
_autoConnect.dispose();
_jumpChain.dispose();
_jumpServer.dispose();
_pveIgnoreCert.dispose();
_env.dispose();
_customCmds.dispose();
@@ -200,6 +199,7 @@ class _ServerEditPageState extends ConsumerState<ServerEditPage> with AfterLayou
),
_buildAuth(),
_buildSystemType(),
_buildJumpServer(),
_buildMore(),
];
return AutoMultiList(children: children);

View File

@@ -1,176 +0,0 @@
part of 'edit.dart';
extension _JumpChain on _ServerEditPageState {
Widget _buildJumpChain() {
final serversState = ref.watch(serversProvider);
final servers = serversState.servers;
final selfId = spi?.id;
if (selfId == null) {
return ListTile(
leading: const Icon(Icons.map),
title: Text(l10n.jumpServer),
subtitle: Text(libL10n.empty, style: UIs.textGrey),
).cardx;
}
String serverNameOrId(String id) {
return servers[id]?.name ?? id;
}
List<String> flattenHopIds(String id, {required Set<String> visited}) {
if (!visited.add(id)) return const <String>[];
final spi = servers[id];
if (spi == null) return const <String>[];
final hops = spi.jumpChainIds;
if (hops == null || hops.isEmpty) return const <String>[];
final flat = <String>[];
for (final hopId in hops) {
flat.add(hopId);
flat.addAll(flattenHopIds(hopId, visited: visited));
}
return flat;
}
bool containsCycleWithCandidate(String candidateId) {
final queue = [..._jumpChain.value, candidateId];
final directVisited = <String>{selfId};
for (final hopId in queue) {
if (hopId == selfId) return true;
if (!directVisited.add(hopId)) return true;
}
for (final hopId in queue) {
final extra = flattenHopIds(hopId, visited: <String>{selfId});
for (final id in extra) {
if (id == selfId) return true;
}
}
return false;
}
String? buildTextNearToFar() {
if (_jumpChain.value.isEmpty) return null;
final flat = <String>[];
final visited = <String>{selfId};
for (final hopId in _jumpChain.value) {
flat.add(hopId);
flat.addAll(flattenHopIds(hopId, visited: visited));
}
final names = flat.map(serverNameOrId).toList();
if (names.isEmpty) return null;
return names.join('');
}
String? buildTextFarToNear() {
final text = buildTextNearToFar();
if (text == null) return null;
return text.split('').reversed.join('');
}
return _jumpChain.listenVal((_) {
final nearToFar2 = buildTextNearToFar();
final farToNear2 = buildTextFarToNear();
return ListTile(
leading: const Icon(Icons.map),
title: Text(l10n.jumpServer),
subtitle: (nearToFar2 == null)
? Text(libL10n.empty, style: UIs.textGrey)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('${l10n.route}: $nearToFar2', style: UIs.textGrey),
Text('${libL10n.path}: $farToNear2', style: UIs.textGrey),
],
),
trailing: const Icon(Icons.keyboard_arrow_right),
onTap: () async {
if (serversState.serverOrder.isEmpty) {
context.showSnackBar(libL10n.empty);
return;
}
final candidates = serversState.serverOrder.where((e) => e != selfId).toList();
if (candidates.isEmpty) {
context.showSnackBar(libL10n.empty);
return;
}
// Add a hop
final nextHop = await context.showPickSingleDialog<String>(
title: '${l10n.jumpServer} (+1)',
items: candidates.where((id) => !containsCycleWithCandidate(id)).toList(),
display: serverNameOrId,
clearable: true,
);
if (nextHop == null) return;
_jumpChain.value = [..._jumpChain.value, nextHop];
// If user wants to manage order/remove, offer a simple editor dialog
await context.showRoundDialog<void>(
title: l10n.jumpServer,
child: SizedBox(
width: 320,
child: _jumpChain.listenVal((hops) {
return ListView.builder(
shrinkWrap: true,
itemCount: hops.length,
itemBuilder: (context, index) {
final id = hops[index];
return ListTile(
title: Text(serverNameOrId(id)),
subtitle: Text(id, style: UIs.textGrey),
trailing: Wrap(
spacing: 4,
children: [
IconButton(
icon: const Icon(Icons.arrow_upward, size: 18),
onPressed: index == 0
? null
: () {
final list = [..._jumpChain.value];
final tmp = list[index - 1];
list[index - 1] = list[index];
list[index] = tmp;
_jumpChain.value = list;
},
),
IconButton(
icon: const Icon(Icons.arrow_downward, size: 18),
onPressed: index == hops.length - 1
? null
: () {
final list = [..._jumpChain.value];
final tmp = list[index + 1];
list[index + 1] = list[index];
list[index] = tmp;
_jumpChain.value = list;
},
),
IconButton(
icon: const Icon(Icons.delete, size: 18),
onPressed: () {
final list = [..._jumpChain.value]..removeAt(index);
_jumpChain.value = list;
},
),
],
),
);
},
);
}),
),
actions: Btnx.oks,
);
},
).cardx;
});
}
}

View File

@@ -132,7 +132,6 @@ extension _Widgets on _ServerEditPageState {
return ExpandTile(
title: Text(l10n.more),
children: [
_buildJumpChain(),
Input(
controller: _logoUrlCtrl,
type: TextInputType.url,
@@ -348,6 +347,48 @@ extension _Widgets on _ServerEditPageState {
);
}
Widget _buildJumpServer() {
const padding = EdgeInsets.only(left: 13, right: 13, bottom: 7);
final srvs = ref
.watch(serversProvider)
.servers
.values
.where((e) => e.jumpId == null)
.where((e) => e.id != spi?.id)
.toList();
final choice = _jumpServer.listenVal((val) {
final srv = srvs.firstWhereOrNull((e) => e.id == _jumpServer.value);
return Choice<Spi>(
multiple: false,
clearable: true,
value: srv != null ? [srv] : [],
builder: (state, _) => Wrap(
children: List<Widget>.generate(srvs.length, (index) {
final item = srvs[index];
return ChoiceChipX<Spi>(
label: item.name,
state: state,
value: item,
onSelected: (srv, on) {
if (on) {
_jumpServer.value = srv.id;
} else {
_jumpServer.value = null;
}
},
);
}),
),
);
});
return ExpandTile(
leading: const Icon(Icons.map),
initiallyExpanded: _jumpServer.value != null,
childrenPadding: padding,
title: Text(l10n.jumpServer),
children: [choice],
).cardx;
}
Widget _buildWriteScriptTip() {
return Btn.tile(

View File

@@ -49,11 +49,12 @@ extension _Operation on _ServerPageState {
await context.showRoundDialog(title: libL10n.attention, child: Text(l10n.suspendTip));
Stores.setting.showSuspendTip.put(false);
}
srv.client?.execWithPwd(
await srv.client?.execWithPwd(
ShellFunc.suspend.exec(srv.spi.id, systemType: srv.status.system, customDir: null),
context: context,
id: srv.id,
);
) ??
(null, '');
},
typ: l10n.suspend,
name: srv.spi.name,
@@ -62,11 +63,13 @@ extension _Operation on _ServerPageState {
void _onTapShutdown(ServerState srv) {
_askFor(
func: () => srv.client?.execWithPwd(
ShellFunc.shutdown.exec(srv.spi.id, systemType: srv.status.system, customDir: null),
context: context,
id: srv.id,
),
func: () async {
await srv.client?.execWithPwd(
ShellFunc.shutdown.exec(srv.spi.id, systemType: srv.status.system, customDir: null),
context: context,
id: srv.id,
);
},
typ: l10n.shutdown,
name: srv.spi.name,
);
@@ -74,11 +77,14 @@ extension _Operation on _ServerPageState {
void _onTapReboot(ServerState srv) {
_askFor(
func: () => srv.client?.execWithPwd(
ShellFunc.reboot.exec(srv.spi.id, systemType: srv.status.system, customDir: null),
context: context,
id: srv.id,
),
func: () async {
await srv.client?.execWithPwd(
ShellFunc.reboot.exec(srv.spi.id, systemType: srv.status.system, customDir: null),
context: context,
id: srv.id,
) ??
(null, '');
},
typ: l10n.reboot,
name: srv.spi.name,
);

View File

@@ -355,7 +355,7 @@ class SSHPageState extends ConsumerState<SSHPage>
onTapUp: (_) => _virtKeyLongPressTimer?.cancel(),
child: SizedBox(
width: virtKeyWidth,
height: _virtKeysHeight / _virtKeysList.length,
height: _horizonVirtKeys ? _virtKeysHeight : _virtKeysHeight / _virtKeysList.length,
child: Center(child: child),
),
);

View File

@@ -53,7 +53,7 @@ class _SftpMissionPageState extends ConsumerState<SftpMissionPage> {
return switch (status.status) {
const (SftpWorkerStatus.finished) => _buildFinished(status),
const (SftpWorkerStatus.loading) => _buildLoading(status),
const (SftpWorkerStatus.sshConnected) => _buildConnected(status),
const (SftpWorkerStatus.sshConnectted) => _buildConnected(status),
const (SftpWorkerStatus.preparing) => _buildPreparing(status),
_ => _buildDefault(status),
};

View File

@@ -471,7 +471,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1291;
CURRENT_PROJECT_VERSION = 1297;
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.1291;
MARKETING_VERSION = 1.0.1297;
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 = 1291;
CURRENT_PROJECT_VERSION = 1297;
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.1291;
MARKETING_VERSION = 1.0.1297;
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 = 1291;
CURRENT_PROJECT_VERSION = 1297;
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.1291;
MARKETING_VERSION = 1.0.1297;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "Server Box";
PROVISIONING_PROFILE_SPECIFIER = "";

View File

@@ -1,7 +1,7 @@
name: server_box
description: server status & toolbox app.
publish_to: "none"
version: 1.0.1291+1291
version: 1.0.1297+1297
environment:
sdk: ">=3.9.0"

View File

@@ -1,93 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:server_box/core/utils/server.dart';
import 'package:server_box/data/model/app/error.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
void main() {
group('Jump server', () {
test('resolveMergedJumpChain throws when injected chain misses jump server', () {
const spi = Spi(
name: 'target',
ip: '10.0.0.10',
port: 22,
user: 'root',
id: 't',
jumpId: 'missing',
);
expect(
() => resolveMergedJumpChain(spi, jumpChain: const <Spi>[]),
throwsA(
isA<SSHErr>().having(
(e) => e.type,
'type',
SSHErrType.connect,
),
),
);
});
test('resolveMergedJumpChain merges and dedups', () {
const c = Spi(name: 'c', ip: '10.0.0.30', port: 22, user: 'root', id: 'c');
const d = Spi(name: 'd', ip: '10.0.0.40', port: 22, user: 'root', id: 'd');
const b = Spi(
name: 'b',
ip: '10.0.0.20',
port: 22,
user: 'root',
id: 'b',
jumpChainIds: ['c', 'd'],
);
const target = Spi(
name: 'target',
ip: '10.0.0.10',
port: 22,
user: 'root',
id: 't',
jumpChainIds: ['b', 'c'],
);
final chain = resolveMergedJumpChain(target, jumpChain: const <Spi>[b, c, d]);
expect(chain.map((e) => e.id).toList(), ['c', 'd', 'b']);
});
test('resolveMergedJumpChain detects jump loop', () {
const b = Spi(
name: 'b',
ip: '10.0.0.20',
port: 22,
user: 'root',
id: 'b',
jumpChainIds: ['c'],
);
const c = Spi(
name: 'c',
ip: '10.0.0.30',
port: 22,
user: 'root',
id: 'c',
jumpChainIds: ['b'],
);
const target = Spi(
name: 'target',
ip: '10.0.0.10',
port: 22,
user: 'root',
id: 't',
jumpChainIds: ['b'],
);
expect(
() => resolveMergedJumpChain(target, jumpChain: const <Spi>[b, c]),
throwsA(
isA<SSHErr>().having(
(e) => e.type,
'type',
SSHErrType.connect,
),
),
);
});
});
}