Compare commits

..

33 Commits

Author SHA1 Message Date
lollipopkit🏳️‍⚧️
0a0928e2f6 fix: treat empty jumpChainIds as no jump 2026-01-17 23:55:41 +08:00
lollipopkit🏳️‍⚧️
61f161d8a6 opt. 2026-01-15 20:52:11 +08:00
lollipopkit🏳️‍⚧️
52c80795f4 opt. 2026-01-15 20:21:28 +08:00
lollipopkit🏳️‍⚧️
09f1ab2cf2 fix 2026-01-15 20:09:33 +08:00
lollipopkit🏳️‍⚧️
2eeb55c1d8 fix 2026-01-15 13:46:53 +08:00
lollipopkit🏳️‍⚧️
6738ac94f8 opt. 2026-01-15 13:15:31 +08:00
lollipopkit🏳️‍⚧️
827d40b8b5 opt. 2026-01-15 13:02:17 +08:00
lollipopkit🏳️‍⚧️
928f2becf1 fix 2026-01-15 12:41:10 +08:00
lollipopkit🏳️‍⚧️
7d30af44d6 fix 2026-01-15 10:10:21 +08:00
lollipopkit🏳️‍⚧️
35349a90eb opt.: deduplicate & merge 2026-01-15 10:10:14 +08:00
lollipopkit🏳️‍⚧️
8be9b9b10b impl: jump logic
Fixes #356
2026-01-15 09:42:34 +08:00
lollipopkit🏳️‍⚧️
c51cf62015 feat: jump server chain
Fixes #356
2026-01-14 22:36:47 +08:00
lollipopkit🏳️‍⚧️
8589b3b4d7 opt.: add a btn to minimize ai dialog (#1004)
* opt.: add a btn to minimize ai dialog
Fixes #1003

* opt.

* opt.
2026-01-14 15:15:33 +08:00
GT610
7693e30cbf opt: Better performance on server refreshing (#999)
* refactor(server): Replace Future.wait with an explicit list of futures to enhance readability

Refactor the nested map and async functions into explicit for loops and future lists to make the code logic clearer

* fix(server): Fixed the auto-refresh logic and concurrency control issues

- Add `_refreshCompleter` to prevent concurrent refreshes
- Fixed the issue where the status was not updated after the automatic refresh timer was canceled
- Remove the invalid check for `duration == 1`

* refactor(server): Optimize the server refresh logic by filtering out servers that do not need to be refreshed in advance

Move the server filtering logic outside the loop and use the `where` method to filter the servers that need to be refreshed, avoiding repeated condition checks within the loop. This improves code readability and reduces redundant condition checks.

* refactor: Optimize server refresh logic to enhance readability

Break down complex conditional checks into clearer steps, separating the logic for server refresh and rate limiter reset. Replace chained calls with explicit loops to make the code easier to maintain and understand.

* refactor(server): Remove `updateFuture` from `ServerState` and use the `_isRefreshing` flag instead

Simplify the server refresh logic, replace Future state tracking with a boolean flag, and avoid unnecessary state updates

* refactor(server_detail): Extract the setting items as local variables to improve performance

Extract the globally set items that are accessed repeatedly as local variables, reduce unnecessary state retrieval operations, and optimize page performance

* refactor: Rename `_displayCpuIndexSetting` to `_displayCpuIndex` for consistency

* refactor(server): Fix the issue of parallel blocking in server refresh

The original code uses Future.wait to wait for all refresh operations to complete, but in fact, there is no need to wait for the results of these operations. Instead, directly calling ignore() to ignore the results can avoid blocking caused by the slowest server

* fix: Adjust the order of logging and default value settings

Ensure to set the default value after recording the invalid duration warning

* refactor(server): Rename _refreshCompleter to _refreshInProgress to enhance readability

Change the variable name from `_refreshCompleter` to `_refreshInProgress`, so that it more accurately reflects the actual purpose of the variable, which is to indicate whether the refresh operation is in progress

* refactor(server): Remove unnecessary refresh progress status management

Simplify the server refresh logic, remove the unused _refreshInProgress state variable and related Completer handling, making the code more concise and straightforward

* chore: Update dependent package versions

Update the following dependent package versions:
- camera_web has been upgraded from 0.3.5 to 0.3.5+3
- ffi has been upgraded from 2.1.4 to 2.1.5
- hive_ce_flutter is upgraded from 2.3.3 to 2.3.4
- watcher is upgraded from 1.1.4 to 1.2.1

* opt.

---------

Co-authored-by: lollipopkit🏳️‍⚧️ <10864310+lollipopkit@users.noreply.github.com>
2026-01-14 13:47:06 +08:00
lollipopkit🏳️‍⚧️
874d28be12 bump: v1291 2026-01-14 13:32:30 +08:00
GT610
06070c29b9 fix(color-picking): Fix color picking failure and card overflow (#998) 2026-01-11 00:21:48 +08:00
GT610
bb0ada12e6 bump: Update Android build tools and Actions version (#997) 2026-01-10 20:03:37 +08:00
GT610
9ceeaf7cc4 feat(local file page): Display server names for server folders (#996)
* feat(local file page): Display server names for server folders

In the local file list, server folders will display their corresponding server names, enhancing the user experience.

* fix(storage page): Use ref.read instead of ref.watch to fetch the server list

Avoid unnecessary watch operations during construction, reducing potential performance overhead
2026-01-08 18:57:02 +08:00
GT610
29a57ad742 fix(container): Modify container execution commands to prioritize bash or ash (#995) 2026-01-08 09:11:23 +08:00
GT610
2c495a44c3 fix(log): Logging System Improvements and Error Handling Enhancements (#994)
* fix: Added logging to exception handling

Added detailed error logging to exception handling across multiple files, including exception information and stack traces, to facilitate troubleshooting.

* refactor(logging): Standardize logging output methods

Replace existing debugPrint and lprint with Loggers.app.warning to enhance logging consistency and maintainability.

* refactor: Remove redundant debug log prints

Clean up unnecessary log print statements in debug code

* feat(i18n): Added internationalization support for the logging feature
2026-01-07 15:09:22 +08:00
GT610
cc300c141a refactor(sftp): Replace hard-coded path separators with Pfs.seperator (#993)
Unify the use of Pfs.seperator for handling file path separators to enhance cross-platform compatibility.
2026-01-06 23:50:11 +08:00
GT610
26efb8e185 fix: Add input validation and bounds checking to parsing methods (#990)
* fix: Resolved boundary condition issues in string processing

Addressed null and length checks during string splitting across multiple model classes to prevent potential null pointer exceptions and array out-of-bounds errors

* fix: Throw exceptions instead of silently returning when package manager output formats are invalid

Modified the _pacman, _opkg, and _apk parsing methods to throw exceptions when input formats are invalid, rather than silently returning, to prevent potential error handling issues.
2026-01-06 23:47:49 +08:00
GT610
06ed38ff45 fix(container): Fix Podman 5.x Network Traffic Statistics Not Displaying (#991)
* fix(container): Added version parameter to accommodate Podman 5.x network statistics format

Modified the parseStats method to accept a version parameter, handling changes in Podman 5.x's network statistics data structure. When the version is 5.x, network traffic data is retrieved from the RxBytes/TxBytes fields of the Network interface.

* fix(container): Fixed Podman version detection logic to correctly retrieve network statistics

Addressed Podman version number parsing issues and improved version comparison logic to support all 4.x and below versions as well as 5.x and above versions

* fix(container): Resolved display formatting issues for network and disk I/O statistics

Handled default values when NetIO and BlockIO are null, and reformatted display strings to distinguish upstream/downstream traffic and read/write operations.

* fix: Why did I mess up the tag order?
2026-01-06 23:44:54 +08:00
GT610
7c35abe30e fix(cpu): Resolved boundary condition issues when calculating CPU utilization (#988)
Added checks for coreIdx out-of-bounds and totalDelta being zero to prevent array out-of-bounds and division-by-zero errors
2026-01-06 12:48:13 +08:00
lxdklp
78ef181d4a feat: support macOS menubar (#976)
* feat: macOS menubar

* feat: Dynamic NavigateMenuItems

* fix: simplify shortcut config

* fix: Simplify the code

* fix: More suitable tab name
2025-12-10 18:05:30 +08:00
lollipopkit🏳️‍⚧️
3f15caeaf2 new: add copy btn for ask ai (#975) 2025-12-07 17:51:07 +08:00
lollipopkit🏳️‍⚧️
6458e736fa fix: tag switcher ui (#974)
Fixes #964
2025-12-07 17:29:12 +08:00
lollipopkit🏳️‍⚧️
99fda8b747 opt. 2025-12-07 17:19:24 +08:00
lxdklp
c5cbb12ac3 feat: Automatic line wrapping of time (#973) 2025-12-07 17:14:45 +08:00
lollipopkit🏳️‍⚧️
038f0d4d77 chore: bump: v1276 2025-12-06 12:03:10 +08:00
lxdklp
141519d952 fix: SFTP err caused by known host key (#970)
Fixes #965
2025-11-25 10:35:14 +08:00
lxdklp
75d1a59e77 fix: Unable to obtain Windows server information (#963)
* fix: FormatException: Unexpected extension byte (at offset 8) error

* fix: PowerShell script error repair, Windows data parsing repair

* fix: Unable to obtain network card information

* fix: Unable to obtain system startup time

* fix conversation as resolved.
2025-11-22 19:17:40 +08:00
lollipopkit🏳️‍⚧️
ca4e65d7a5 chore: flutter 3.38 2025-11-13 15:24:22 +08:00
107 changed files with 2300 additions and 2646 deletions

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 1

View File

@@ -17,15 +17,15 @@ permissions:
jobs:
releaseAndroid:
name: Release android
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.35.3"
flutter-version: "3.38.0"
- uses: actions/setup-java@v4
with:
distribution: "zulu"
@@ -53,10 +53,10 @@ jobs:
releaseLinux:
name: Release linux
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Flutter
uses: subosito/flutter-action@v2
- name: Install dependencies
@@ -82,7 +82,7 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Flutter
uses: subosito/flutter-action@v2
- name: Build
@@ -100,7 +100,7 @@ jobs:
# runs-on: macos-latest
# steps:
# - name: Checkout
# uses: actions/checkout@v4
# uses: actions/checkout@v6
# - name: Install Flutter
# uses: subosito/flutter-action@v2
# - name: Build

View File

@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip

View File

@@ -19,7 +19,7 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version '8.6.0' apply false
id "com.android.application" version '8.9.1' apply false
id "org.jetbrains.kotlin.android" version "2.1.21" apply false
}

View File

@@ -88,19 +88,19 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a
camera_avfoundation: be3be85408cd4126f250386828e9b1dfa40ab436
camera_avfoundation: 5675ca25298b6f81fa0a325188e7df62cc217741
file_picker: fb04e739ae6239a76ce1f571863a196a922c87d4
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
icloud_storage: e55639f0c0d7cb2b0ba9c0b3d5968ccca9cd9aa2
local_auth_darwin: d2e8c53ef0c4f43c646462e3415432c4dab3ae19
local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
plain_notification_token: 047876b9d80a5b93565ddcc13a487a7e7b906f7d
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
watch_connectivity: 88e5bea25b473e66ef8d3f960954d154ed0356d6

View File

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

View File

@@ -25,6 +25,7 @@ class _MyAppState extends State<MyApp> {
@override
Widget build(BuildContext context) {
_setup(context);
return ListenableBuilder(
listenable: RNodes.app,
builder: (context, _) {
@@ -39,6 +40,7 @@ class _MyAppState extends State<MyApp> {
Widget _build(BuildContext context) {
final colorSeed = Color(Stores.setting.colorSeed.fetch());
UIs.colorSeed = colorSeed;
UIs.primaryColor = colorSeed;
@@ -61,14 +63,31 @@ class _MyAppState extends State<MyApp> {
Widget _buildDynamicColor(BuildContext context) {
return DynamicColorBuilder(
builder: (light, dark) {
final lightTheme = ThemeData(useMaterial3: true, colorScheme: light);
final darkTheme = ThemeData(useMaterial3: true, brightness: Brightness.dark, colorScheme: dark);
final lightSeed = light?.primary;
final darkSeed = dark?.primary;
final lightTheme = ThemeData(
useMaterial3: true,
colorSchemeSeed: lightSeed,
appBarTheme: AppBarTheme(scrolledUnderElevation: 0.0),
);
final darkTheme = ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorSchemeSeed: darkSeed,
appBarTheme: AppBarTheme(scrolledUnderElevation: 0.0),
);
if (context.isDark && dark != null) {
UIs.primaryColor = dark.primary;
UIs.colorSeed = dark.primary;
} else if (!context.isDark && light != null) {
UIs.primaryColor = light.primary;
UIs.colorSeed = light.primary;
} else {
final fallbackColor = Color(Stores.setting.colorSeed.fetch());
UIs.primaryColor = fallbackColor;
UIs.colorSeed = fallbackColor;
}
return _buildApp(context, light: lightTheme, dark: darkTheme);

View File

@@ -35,8 +35,8 @@ abstract final class MethodChans {
try {
Loggers.app.info('Updating Android sessions: $payload');
await _channel.invokeMethod('updateSessions', payload);
} catch (_) {
// ignore
} catch (e, s) {
Loggers.app.warning('Failed to update Android sessions', e, s);
}
}
@@ -46,7 +46,8 @@ abstract final class MethodChans {
try {
final res = await _channel.invokeMethod('isServiceRunning');
return res == true;
} catch (_) {
} catch (e, s) {
Loggers.app.warning('Failed to check if Android service is running', e, s);
return false;
}
}
@@ -57,7 +58,9 @@ abstract final class MethodChans {
try {
Loggers.app.info('Starting iOS Live Activity: $payload');
await _channel.invokeMethod('startLiveActivity', payload);
} catch (_) {}
} catch (e, s) {
Loggers.app.warning('Failed to start iOS Live Activity', e, s);
}
}
static Future<void> updateLiveActivity(String payload) async {
@@ -65,7 +68,9 @@ abstract final class MethodChans {
try {
Loggers.app.info('Updating iOS Live Activity: $payload');
await _channel.invokeMethod('updateLiveActivity', payload);
} catch (_) {}
} catch (e, s) {
Loggers.app.warning('Failed to update iOS Live Activity', e, s);
}
}
static Future<void> stopLiveActivity() async {
@@ -73,7 +78,9 @@ abstract final class MethodChans {
try {
Loggers.app.info('Stopping iOS Live Activity');
await _channel.invokeMethod('stopLiveActivity');
} catch (_) {}
} catch (e, s) {
Loggers.app.warning('Failed to stop iOS Live Activity', e, s);
}
}
/// Register a handler for native -> Flutter callbacks.

View File

@@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'package:dartssh2/dartssh2.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/widgets.dart';
import 'package:server_box/data/helper/ssh_decoder.dart';
import 'package:server_box/data/model/server/system.dart';
import 'package:server_box/data/res/misc.dart';
@@ -170,4 +171,98 @@ extension SSHClientX on SSHClient {
);
return ret.$2;
}
/// Runs a command and decodes output safely with encoding fallback
///
/// [systemType] - The system type (affects encoding choice)
/// Runs a command and safely decodes the result
Future<String> runSafe(
String command, {
SystemType? systemType,
String? context,
}) async {
// Let SSH errors propagate with their original type (e.g., SSHError subclasses)
final result = await run(command);
// Only catch decoding failures and add context
try {
return SSHDecoder.decode(
result,
isWindows: systemType == SystemType.windows,
context: context,
);
} on FormatException catch (e) {
throw Exception(
'Failed to decode command output${context != null ? " [$context]" : ""}: $e',
);
}
}
/// Executes a command with stdin and safely decodes stdout/stderr
Future<(String stdout, String stderr)> execSafe(
void Function(SSHSession session) callback, {
required String entry,
SystemType? systemType,
String? context,
}) async {
final stdoutBuilder = BytesBuilder(copy: false);
final stderrBuilder = BytesBuilder(copy: false);
final stdoutDone = Completer<void>();
final stderrDone = Completer<void>();
final session = await execute(entry);
session.stdout.listen(
(e) {
stdoutBuilder.add(e);
},
onDone: stdoutDone.complete,
onError: stdoutDone.completeError,
);
session.stderr.listen(
(e) {
stderrBuilder.add(e);
},
onDone: stderrDone.complete,
onError: stderrDone.completeError,
);
callback(session);
await stdoutDone.future;
await stderrDone.future;
final stdoutBytes = stdoutBuilder.takeBytes();
final stderrBytes = stderrBuilder.takeBytes();
// Only catch decoding failures, let other errors propagate
String stdout;
try {
stdout = SSHDecoder.decode(
stdoutBytes,
isWindows: systemType == SystemType.windows,
context: context != null ? '$context (stdout)' : 'stdout',
);
} on FormatException catch (e) {
throw Exception(
'Failed to decode stdout${context != null ? " [$context]" : ""}: $e',
);
}
String stderr;
try {
stderr = SSHDecoder.decode(
stderrBytes,
isWindows: systemType == SystemType.windows,
context: context != null ? '$context (stderr)' : 'stderr',
);
} on FormatException catch (e) {
throw Exception(
'Failed to decode stderr${context != null ? " [$context]" : ""}: $e',
);
}
return (stdout, stderr);
}
}

View File

@@ -74,7 +74,8 @@ class SshDiscoveryService {
// Some tools return non-zero but still have useful output
if (out.trim().isNotEmpty) return out;
return null;
} catch (_) {
} catch (e, s) {
Loggers.app.warning('Failed to run command: $exe ${args.join(' ')}', e, s);
return null;
}
}
@@ -109,7 +110,7 @@ class SshDiscoveryService {
}
}
if (matchCount == 0) {
lprint(
Loggers.app.warning(
'[ssh_discovery] Warning: No ARP entries parsed on macOS. Output may be unexpected or localized. Output sample: ${s.length > 100 ? '${s.substring(0, 100)}...' : s}',
);
}
@@ -176,8 +177,7 @@ class SshDiscoveryService {
r'inet\s+(\d+\.\d+\.\d+\.\d+)\s+netmask\s+0x([0-9a-fA-F]+)(?:\s+broadcast\s+(\d+\.\d+\.\d+\.\d+))?',
).firstMatch(line);
if (ipm == null) {
// Log unexpected format but continue processing other lines
lprint('[ssh_discovery] Warning: Unexpected ifconfig line format: $line');
Loggers.app.warning('[ssh_discovery] Warning: Unexpected ifconfig line format: $line');
continue;
}
final ip = InternetAddress(ipm.group(1)!);
@@ -190,7 +190,7 @@ class SshDiscoveryService {
final brd = InternetAddress(ipm.group(3) ?? _broadcastAddress(ip, mask).address);
res.add(_Cidr(ip, prefix, mask, net, brd));
} catch (e) {
lprint('[ssh_discovery] Error parsing ifconfig output: $e, line: $line');
Loggers.app.warning('[ssh_discovery] Error parsing ifconfig output: $e, line: $line');
continue;
}
}
@@ -249,7 +249,9 @@ class SshDiscoveryService {
}
}
}
} catch (_) {}
} catch (e, s) {
Loggers.app.warning('Failed to discover mDNS SSH candidates on macOS', e, s);
}
} else if (_isLinux) {
final s = await _run('/usr/bin/avahi-browse', ['-rat', '_ssh._tcp']);
if (s != null) {
@@ -335,7 +337,8 @@ class _Scanner {
);
final banner = await c.future.timeout(timeout, onTimeout: () => null);
return _ScanResult(ip, banner);
} catch (_) {
} catch (e, s) {
Loggers.app.warning('Failed to probe SSH at ${ip.address}', e, s);
return null;
} finally {
sub?.cancel();

View File

@@ -26,7 +26,8 @@ final class BakSyncer extends SyncIface {
return MergeableUtils.fromJsonString(content, pwd).$1;
}
return MergeableUtils.fromJsonString(content).$1;
} catch (_) {
} catch (e, s) {
Loggers.app.warning('Failed to parse backup file with password, trying without password', e, s);
// Fallback: try without password if detection failed
return MergeableUtils.fromJsonString(content).$1;
}

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:dartssh2/dartssh2.dart';
import 'package:fl_lib/fl_lib.dart';
@@ -8,7 +7,6 @@ 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';
@@ -40,6 +38,77 @@ 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,
@@ -47,14 +116,17 @@ Future<SSHClient> genClient(
/// Only pass this param if using multi-threading and key login
String? privateKey,
/// Only pass this param if using multi-threading and key login
String? jumpPrivateKey,
Duration timeout = const Duration(seconds: 5),
/// [Spi] of the jump server
/// Pre-resolved jump chain (in `spi.jumpId` order: immediate -> farthest).
///
/// Must pass this param if using multi-threading and key login
Spi? jumpSpi,
/// 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,
Duration timeout = const Duration(seconds: 5),
/// Handle keyboard-interactive authentication
SSHUserInfoRequestHandler? onKeyboardInteractive,
@@ -62,6 +134,41 @@ Future<SSHClient> genClient(
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());
@@ -70,104 +177,133 @@ Future<SSHClient> genClient(
String? alterUser;
// Check for Jump Server first - this needs special handling
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) {
// 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,
);
final (socket, hopClients) = await () async {
if (socketOverride != null) return (socketOverride, <SSHClient>[]);
final forwardChannel = await jumpClient.forwardLocal(spi.ip, spi.port);
if (followJumpConfig) {
final injectedSpiMap = <String, Spi>{};
final injectedKeyMap = <String, String?>{};
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,
);
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];
}
}
}
privateKey ??= getPrivateKey(keyId);
onStatus?.call(GenSSHClientStatus.key);
return SSHClient(
forwardChannel,
username: spi.user,
identities: await compute(loadIndentity, privateKey),
onUserInfoRequest: onKeyboardInteractive,
onVerifyHostKey: hostKeyVerifier.call,
);
}
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;
// 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,
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,
);
} 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');
}
createdClients.add(currentClient);
// 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;
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;
}
}();
} catch (e) {
Loggers.app.warning('Failed to establish connection', e);
rethrow;
// Note: On success, all intermediate clients must remain open
// because the returned socket tunnels through them.
}
}
// Direct
try {
return (await SSHSocket.connect(spi.ip, spi.port, timeout: timeout), <SSHClient>[]);
} 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>[]);
} catch (e) {
Loggers.app.warning('genClient alterUrl', e);
rethrow;
}
}
}();
final hostKeyVerifier = _HostKeyVerifier(
spi: spi,
cache: hostKeyCache,
@@ -175,27 +311,52 @@ Future<SSHClient> genClient(
prompt: hostKeyPrompt,
);
final keyId = spi.keyId;
if (keyId == null) {
onStatus?.call(GenSSHClientStatus.pwd);
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);
return SSHClient(
socket,
username: alterUser ?? spi.user,
onPasswordRequest: () => spi.pwd,
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,
);
}
privateKey ??= getPrivateKey(keyId);
onStatus?.call(GenSSHClientStatus.key);
return SSHClient(
socket,
username: spi.user,
identities: await compute(loadIndentity, privateKey),
onUserInfoRequest: onKeyboardInteractive,
onVerifyHostKey: hostKeyVerifier.call,
);
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;
}
typedef _HostKeyPersistCallback = void Function(String storageKey, String fingerprintHex);
@@ -357,20 +518,53 @@ Future<void> ensureKnownHostKey(
Duration timeout = const Duration(seconds: 5),
SSHUserInfoRequestHandler? onKeyboardInteractive,
}) async {
final cache = _loadKnownHostFingerprints();
if (_hasKnownHostFingerprintForSpi(spi, cache)) {
return;
}
var cache = _loadKnownHostFingerprints();
final jumpSpi = spi.jumpId != null ? Stores.server.box.get(spi.jumpId) : null;
if (jumpSpi != null && !_hasKnownHostFingerprintForSpi(jumpSpi, cache)) {
await ensureKnownHostKey(
jumpSpi,
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,
);
cache.addAll(_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 client = await genClient(
@@ -378,6 +572,8 @@ Future<void> ensureKnownHostKey(
timeout: timeout,
onKeyboardInteractive: onKeyboardInteractive,
knownHostFingerprints: cache,
jumpChain: jumpChain,
jumpPrivateKeys: jumpPrivateKeys,
);
try {
@@ -385,6 +581,9 @@ Future<void> ensureKnownHostKey(
} finally {
client.close();
}
cache.addAll(_loadKnownHostFingerprints());
return cache;
}
bool _hasKnownHostFingerprintForSpi(Spi spi, Map<String, String> cache) {

View File

@@ -149,28 +149,13 @@ abstract final class SSHConfig {
/// Extract jump host from ProxyJump or ProxyCommand
static String? _extractJumpHost(String value) {
// 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;
}
if (value.isEmpty) return null;
// For ProxyJump, the format is usually: user@host:port
// For ProxyCommand, it's more complex and might need custom parsing
if (value.contains('@')) {
final parts = value.split(' ');
return parts.isNotEmpty ? parts[0] : null;
}
// ProxyJump may also be provided as just a hostname (no user@)
// In that case we don't have enough information to build an oldId-style reference,
// so we ignore it here and let the user configure a jump server manually.
return null;
}

View File

@@ -0,0 +1,66 @@
import 'dart:convert';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter_gbk2utf8/flutter_gbk2utf8.dart';
/// Utility class for decoding SSH command output with encoding fallback
class SSHDecoder {
/// Decodes bytes to string with multiple encoding fallback strategies
///
/// Tries in order:
/// 1. UTF-8 (with allowMalformed for lenient parsing)
/// - Windows PowerShell scripts now set UTF-8 output encoding by default
/// 2. GBK (for Windows Chinese systems)
/// - In some cases, Windows will still revert to GBK.
/// - Only attempted if UTF-8 produces replacement characters (<28>)
static String decode(
List<int> bytes, {
bool isWindows = false,
String? context,
}) {
if (bytes.isEmpty) return '';
// Try UTF-8 first with allowMalformed
try {
final result = utf8.decode(bytes, allowMalformed: true);
// Check if there are replacement characters indicating decode failure
// For non-Windows systems, always use UTF-8 result
if (!result.contains('<EFBFBD>') || !isWindows) {
return result;
}
// For Windows with replacement chars, log and try GBK fallback
if (isWindows && result.contains('<EFBFBD>')) {
final contextInfo = context != null ? ' [$context]' : '';
Loggers.app.info('UTF-8 decode has replacement chars$contextInfo, trying GBK fallback');
}
} catch (e) {
final contextInfo = context != null ? ' [$context]' : '';
Loggers.app.warning('UTF-8 decode failed$contextInfo: $e');
}
// For Windows or when UTF-8 has replacement chars, try GBK
try {
return gbk.decode(bytes);
} catch (e) {
final contextInfo = context != null ? ' [$context]' : '';
Loggers.app.warning('GBK decode failed$contextInfo: $e');
// Return empty string if all decoding attempts fail
return '';
}
}
/// Encodes string to bytes for SSH command input
///
/// Uses GBK for Windows, UTF-8 for others
static List<int> encode(String text, {bool isWindows = false}) {
if (isWindows) {
try {
return gbk.encode(text);
} catch (e) {
Loggers.app.warning('GBK encode failed: $e, falling back to UTF-8');
return utf8.encode(text);
}
}
return utf8.encode(text);
}
}

View File

@@ -1,5 +1,6 @@
import 'package:dartssh2/dartssh2.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/core/extension/ssh_client.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/system.dart';
@@ -23,7 +24,10 @@ class SystemDetector {
try {
// Try to detect Unix/Linux/BSD systems first (more reliable and doesn't create files)
final unixResult = await client.run('uname -a 2>/dev/null').string;
final unixResult = await client.runSafe(
'uname -a 2>/dev/null',
context: 'uname detection for ${spi.oldId}',
);
if (unixResult.contains('Linux')) {
detectedSystemType = SystemType.linux;
dprint('Detected Linux system type for ${spi.oldId}');
@@ -35,15 +39,19 @@ class SystemDetector {
}
// If uname fails, try to detect Windows systems
final powershellResult = await client.run('ver 2>nul').string;
final powershellResult = await client.runSafe(
'ver 2>nul',
systemType: SystemType.windows,
context: 'ver detection for ${spi.oldId}',
);
if (powershellResult.isNotEmpty &&
(powershellResult.contains('Windows') || powershellResult.contains('NT'))) {
detectedSystemType = SystemType.windows;
dprint('Detected Windows system type for ${spi.oldId}');
return detectedSystemType;
}
} catch (e) {
Loggers.app.warning('System detection failed for ${spi.oldId}: $e');
} catch (e, stackTrace) {
Loggers.app.warning('System detection failed for ${spi.oldId}: $e\n$stackTrace');
}
// Default fallback

View File

@@ -74,8 +74,8 @@ class BackupService {
await _confirmAndRestore(context, backup);
return;
}
} catch (e) {
// Saved password failed, will prompt for manual input
} catch (e, s) {
Loggers.app.warning('Failed to restore with saved password, will prompt for manual input', e, s);
}
}

View File

@@ -0,0 +1,119 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/data/model/app/tab.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/data/res/url.dart';
import 'package:server_box/generated/l10n/l10n.dart';
import 'package:server_box/view/page/setting/entry.dart';
import 'package:url_launcher/url_launcher.dart';
/// macOS Menu Bar
class MacOSMenuBarManager {
static List<PlatformMenu> buildMenuBar(BuildContext context, Function(int) onTabChanged) {
final l10n = context.l10n;
final homeTabs = Stores.setting.homeTabs.fetch();
return [
PlatformMenu(
label: 'Server Box',
menus: [
PlatformMenuItem(
label: libL10n.about,
onSelected: () => _showAboutDialog(context),
),
PlatformMenuItem(
label: l10n.menuSettings,
shortcut: const SingleActivator(LogicalKeyboardKey.comma, meta: true),
onSelected: () => _openSettings(context),
),
PlatformMenuItem(
label: l10n.menuQuit,
shortcut: const SingleActivator(LogicalKeyboardKey.keyQ, meta: true),
onSelected: () => SystemNavigator.pop(),
),
],
),
PlatformMenu(
label: l10n.menuNavigate,
menus: _buildNavigateMenuItems(l10n, homeTabs, onTabChanged),
),
PlatformMenu(
label: l10n.menuInfo,
menus: [
PlatformMenuItem(
label: l10n.menuGitHubRepository,
onSelected: () => _openURL(Urls.thisRepo),
),
PlatformMenuItem(
label: l10n.menuWiki,
onSelected: () => _openURL(Urls.appWiki),
),
PlatformMenuItem(
label: l10n.menuHelp,
onSelected: () => _openURL(Urls.appHelp),
),
],
),
];
}
static List<PlatformMenuItem> _buildNavigateMenuItems(
AppLocalizations l10n,
List<AppTab> homeTabs,
Function(int) onTabChanged,
) {
final menuItems = <PlatformMenuItem>[];
final tabLabels = {
AppTab.server: l10n.server,
AppTab.ssh: 'SSH',
AppTab.file: libL10n.file,
AppTab.snippet: l10n.snippet,
};
for (var i = 0; i < homeTabs.length; i++) {
final tab = homeTabs[i];
final label = tabLabels[tab];
if (label == null) continue;
final shortcutKey = _getShortcutKeyForIndex(i);
menuItems.add(PlatformMenuItem(
label: label,
shortcut: shortcutKey != null
? SingleActivator(shortcutKey, meta: true)
: null,
onSelected: () => onTabChanged(i),
));
}
return menuItems;
}
static LogicalKeyboardKey? _getShortcutKeyForIndex(int index) {
const keys = [
LogicalKeyboardKey.digit1,
LogicalKeyboardKey.digit2,
LogicalKeyboardKey.digit3,
LogicalKeyboardKey.digit4,
LogicalKeyboardKey.digit5,
LogicalKeyboardKey.digit6,
LogicalKeyboardKey.digit7,
LogicalKeyboardKey.digit8,
LogicalKeyboardKey.digit9,
];
return index < keys.length ? keys[index] : null;
}
static Future<void> _showAboutDialog(BuildContext context) async {
const channel = MethodChannel('about');
await channel.invokeMethod('showAboutPanel');
}
static void _openSettings(BuildContext context) {
SettingsPage.route.go(context);
}
static Future<void> _openURL(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
}
}
}

View File

@@ -166,25 +166,34 @@ enum WindowsStatusCmdType implements ShellCmdType {
echo('echo ${SystemType.windowsSign}'),
time('[DateTimeOffset]::UtcNow.ToUnixTimeSeconds()'),
/// Get network interface statistics using Windows Performance Counters
/// Get network interface statistics using WMI
///
/// Uses Get-Counter to collect network I/O metrics from all network interfaces:
/// - Collects bytes received and sent per second for all network interfaces
/// Uses WMI Win32_PerfRawData_Tcpip_NetworkInterface for cross-language compatibility:
/// - Takes 2 samples with 1 second interval to calculate rates
/// - Outputs results in JSON format for easy parsing
/// - Counter paths use double backslashes to escape PowerShell string literals
net(
r'Get-Counter -Counter '
r'"\\NetworkInterface(*)\\Bytes Received/sec", '
r'"\\NetworkInterface(*)\\Bytes Sent/sec" '
r'-SampleInterval 1 -MaxSamples 2 | ConvertTo-Json',
r'$s1 = @(Get-WmiObject Win32_PerfRawData_Tcpip_NetworkInterface | '
r'Select-Object Name, BytesReceivedPersec, BytesSentPersec, Timestamp_Sys100NS); '
r'Start-Sleep -Seconds 1; '
r'$s2 = @(Get-WmiObject Win32_PerfRawData_Tcpip_NetworkInterface | '
r'Select-Object Name, BytesReceivedPersec, BytesSentPersec, Timestamp_Sys100NS); '
r'@($s1, $s2) | ConvertTo-Json -Depth 5',
),
sys('(Get-ComputerInfo).OsName'),
cpu(
'Get-WmiObject -Class Win32_Processor | '
'Select-Object Name, LoadPercentage | ConvertTo-Json',
'Select-Object Name, LoadPercentage, NumberOfCores, NumberOfLogicalProcessors | ConvertTo-Json',
),
/// Get system uptime by calculating time since last boot
///
/// Calculates uptime directly in PowerShell to avoid date format parsing issues:
/// - Gets LastBootUpTime from Win32_OperatingSystem
/// - Calculates difference from current time
/// - Returns pre-formatted string: "X days, H:MM" or "H:MM" (if less than 1 day)
/// - Uses ToString('00') for zero-padding to avoid quote escaping issues
uptime(
r"""$up = (Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime; if ($up.Days -gt 0) { "$($up.Days) days, $($up.Hours):$($up.Minutes.ToString('00'))" } else { "$($up.Hours):$($up.Minutes.ToString('00'))" }""",
),
uptime('(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime'),
conn('(netstat -an | findstr ESTABLISHED | Measure-Object -Line).Count'),
disk(
'Get-WmiObject -Class Win32_LogicalDisk | '
@@ -213,19 +222,19 @@ enum WindowsStatusCmdType implements ShellCmdType {
),
host(r'Write-Output $env:COMPUTERNAME'),
/// Get disk I/O statistics using Windows Performance Counters
/// Get disk I/O statistics using WMI
///
/// Uses Get-Counter to collect disk I/O metrics from all physical disks:
/// Uses WMI Win32_PerfRawData_PerfDisk_PhysicalDisk:
/// - Monitors read and write bytes per second for all physical disks
/// - Takes 2 samples with 1 second interval to calculate I/O rates
/// - Physical disk counters provide hardware-level I/O statistics
/// - Outputs results in JSON format for parsing
/// - Counter names use wildcard (*) to capture all disk instances
/// - Takes 2 samples with 1 second interval to calculate rates
/// - DiskReadBytesPersec and DiskWriteBytesPersec are cumulative counters
diskio(
r'Get-Counter -Counter '
r'"\\PhysicalDisk(*)\\Disk Read Bytes/sec", '
r'"\\PhysicalDisk(*)\\Disk Write Bytes/sec" '
r'-SampleInterval 1 -MaxSamples 2 | ConvertTo-Json',
r'$s1 = @(Get-WmiObject Win32_PerfRawData_PerfDisk_PhysicalDisk | '
r'Select-Object Name, DiskReadBytesPersec, DiskWriteBytesPersec, Timestamp_Sys100NS); '
r'Start-Sleep -Seconds 1; '
r'$s2 = @(Get-WmiObject Win32_PerfRawData_PerfDisk_PhysicalDisk | '
r'Select-Object Name, DiskReadBytesPersec, DiskWriteBytesPersec, Timestamp_Sys100NS); '
r'@($s1, $s2) | ConvertTo-Json -Depth 5',
),
battery(
'Get-WmiObject -Class Win32_Battery | '
@@ -287,7 +296,7 @@ enum WindowsStatusCmdType implements ShellCmdType {
String get separator => ScriptConstants.getCmdSeparator(name);
@override
String get divider => ScriptConstants.getCmdDivider(name);
String get divider => ScriptConstants.getWindowsCmdDivider(name);
@override
CmdTypeSys get sysType => CmdTypeSys.windows;

View File

@@ -29,6 +29,9 @@ class ScriptConstants {
/// Generate command-specific divider
static String getCmdDivider(String cmdName) => '\necho ${getCmdSeparator(cmdName)}\n\t';
/// Generate command-specific divider for Windows PowerShell
static String getWindowsCmdDivider(String cmdName) => '\n Write-Host "${getCmdSeparator(cmdName)}"\n ';
/// Parse script output into command-specific map
static Map<String, String> parseScriptOutput(String raw) {
final result = <String, String>{};
@@ -102,6 +105,7 @@ exec 2>/dev/null
# DO NOT delete this file while app is running
\$ErrorActionPreference = "SilentlyContinue"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
''';
}

View File

@@ -20,7 +20,7 @@ sealed class ContainerPs {
factory ContainerPs.fromRaw(String s, ContainerType typ) => typ.ps(s);
void parseStats(String s);
void parseStats(String s, [String? version]);
}
final class PodmanPs implements ContainerPs {
@@ -55,7 +55,7 @@ final class PodmanPs implements ContainerPs {
ContainerStatus get status => ContainerStatus.fromPodmanExited(exited);
@override
void parseStats(String s) {
void parseStats(String s, [String? version]) {
final stats = json.decode(s);
final cpuD = (stats['CPU'] as double? ?? 0).toStringAsFixed(1);
final cpuAvgD = (stats['AvgCPU'] as double? ?? 0).toStringAsFixed(1);
@@ -63,12 +63,32 @@ final class PodmanPs implements ContainerPs {
final memLimit = (stats['MemLimit'] as int? ?? 0).bytes2Str;
final memUsage = (stats['MemUsage'] as int? ?? 0).bytes2Str;
mem = '$memUsage / $memLimit';
final netIn = (stats['NetInput'] as int? ?? 0).bytes2Str;
final netOut = (stats['NetOutput'] as int? ?? 0).bytes2Str;
net = '$netIn / ↑ $netOut';
int netIn = 0;
int netOut = 0;
final majorVersion = version?.split('.').firstOrNull;
final majorVersionNum = majorVersion != null ? int.tryParse(majorVersion) : null;
// Podman 4.x and earlier use top-level NetInput/NetOutput fields.
// Podman 5.x changed network backend (Netavark) and uses nested
// Network.{iface}.RxBytes/TxBytes structure instead.
if (majorVersionNum == null || majorVersionNum <= 4) {
netIn = stats['NetInput'] as int? ?? 0;
netOut = stats['NetOutput'] as int? ?? 0;
} else if (majorVersionNum >= 5) {
final network = stats['Network'] as Map<String, dynamic>?;
if (network != null) {
for (final interface in network.values) {
netIn += interface['RxBytes'] as int? ?? 0;
netOut += interface['TxBytes'] as int? ?? 0;
}
}
}
net = '${netIn.bytes2Str} / ↑ ${netOut.bytes2Str}';
final diskIn = (stats['BlockInput'] as int? ?? 0).bytes2Str;
final diskOut = (stats['BlockOutput'] as int? ?? 0).bytes2Str;
disk = '${l10n.read} $diskOut / ${l10n.write} $diskIn';
disk = '${l10n.read} $diskIn / ${l10n.write} $diskOut';
}
factory PodmanPs.fromRawJson(String str) => PodmanPs.fromJson(json.decode(str));
@@ -125,12 +145,18 @@ final class DockerPs implements ContainerPs {
ContainerStatus get status => ContainerStatus.fromDockerState(state);
@override
void parseStats(String s) {
void parseStats(String s, [String? version]) {
final stats = json.decode(s);
cpu = stats['CPUPerc'];
mem = stats['MemUsage'];
net = stats['NetIO'];
disk = stats['BlockIO'];
final netIO = stats['NetIO'] as String? ?? '0B / 0B';
final netParts = netIO.split(' / ');
net = '${netParts.firstOrNull ?? '0B'} / ↑ ${netParts.length > 1 ? netParts[1] : '0B'}';
final blockIO = stats['BlockIO'] as String? ?? '0B / 0B';
final blockParts = blockIO.split(' / ');
disk = '${l10n.read} ${blockParts.firstOrNull ?? '0B'} / ${l10n.write} ${blockParts.length > 1 ? blockParts[1] : '0B'}';
}
/// CONTAINER ID NAMES IMAGE STATUS

View File

@@ -62,6 +62,7 @@ class UpgradePkgInfo {
void _parsePacman(String raw) {
final parts = raw.split(' ');
if (parts.length < 4) throw Exception('Invalid pacman output format');
package = parts[0];
nowVersion = parts[1];
newVersion = parts[3];
@@ -70,6 +71,7 @@ class UpgradePkgInfo {
void _parseOpkg(String raw) {
final parts = raw.split(' - ');
if (parts.length < 3) throw Exception('Invalid opkg output format');
package = parts[0];
nowVersion = parts[1];
newVersion = parts[2];
@@ -80,6 +82,7 @@ class UpgradePkgInfo {
void _parseApk(String raw) {
final parts = raw.split(' ');
final len = parts.length;
if (len < 2) throw Exception('Invalid apk output format');
newVersion = parts[len - 1];
nowVersion = parts[0];
newVersion = newVersion.substring(0, newVersion.length - 1);

View File

@@ -6,7 +6,7 @@ import 'package:server_box/data/res/status.dart';
/// Capacity of the FIFO queue
const _kCap = 30;
class Cpus extends TimeSeq<List<SingleCpuCore>> {
class Cpus extends TimeSeq<SingleCpuCore> {
Cpus(super.init1, super.init2);
final Map<String, int> brand = {};
@@ -14,21 +14,30 @@ class Cpus extends TimeSeq<List<SingleCpuCore>> {
@override
void onUpdate() {
_coresCount = now.length;
if (pre.isEmpty || now.isEmpty || pre.length != now.length) {
_totalDelta = 0;
_user = 0;
_sys = 0;
_iowait = 0;
_idle = 0;
return;
}
_totalDelta = now[0].total - pre[0].total;
_user = _getUser();
_sys = _getSys();
_iowait = _getIowait();
_idle = _getIdle();
_updateSpots();
//_updateRange();
}
double usedPercent({int coreIdx = 0}) {
if (now.length != pre.length) return 0;
if (now.isEmpty) return 0;
if (coreIdx >= now.length) return 0;
try {
final idleDelta = now[coreIdx].idle - pre[coreIdx].idle;
final totalDelta = now[coreIdx].total - pre[coreIdx].total;
if (totalDelta == 0) return 0;
final used = idleDelta / totalDelta;
return used.isNaN ? 0 : 100 - used * 100;
} catch (e, s) {
@@ -157,6 +166,7 @@ class SingleCpuCore extends TimeSeqIface<SingleCpuCore> {
final id = item.split(' ').firstOrNull;
if (id == null) continue;
final matches = item.replaceFirst(id, '').trim().split(' ');
if (matches.length < 7) continue;
cpus.add(
SingleCpuCore(
id,

View File

@@ -280,7 +280,7 @@ class Disk with EquatableMixin {
];
}
class DiskIO extends TimeSeq<List<DiskIOPiece>> {
class DiskIO extends TimeSeq<DiskIOPiece> {
DiskIO(super.init1, super.init2);
@override

View File

@@ -18,7 +18,7 @@ class NetSpeedPart extends TimeSeqIface<NetSpeedPart> {
typedef CachedNetVals = ({String sizeIn, String sizeOut, String speedIn, String speedOut});
class NetSpeed extends TimeSeq<List<NetSpeedPart>> {
class NetSpeed extends TimeSeq<NetSpeedPart> {
NetSpeed(super.init1, super.init2);
@override
@@ -164,7 +164,8 @@ class NetSpeed extends TimeSeq<List<NetSpeedPart>> {
final bytesIn = BigInt.parse(bytes.first);
final bytesOut = BigInt.parse(bytes[8]);
results.add(NetSpeedPart(device, bytesIn, bytesOut, time));
} catch (_) {
} catch (e, s) {
Loggers.app.warning('Failed to parse net speed data: $item', e, s);
continue;
}
}

View File

@@ -97,8 +97,8 @@ class Proc {
}
String get binary {
final parts = command.split(' ');
return parts[0];
final parts = command.trim().split(' ').where((e) => e.isNotEmpty).toList();
return parts.isNotEmpty ? parts[0] : '';
}
}

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
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';
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';
@@ -22,7 +22,7 @@ part 'server_private_info.g.dart';
abstract class Spi with _$Spi {
const Spi._();
@JsonSerializable(includeIfNull: false, explicitToJson: true)
@JsonSerializable(includeIfNull: false)
const factory Spi({
required String name,
required String ip,
@@ -36,8 +36,15 @@ abstract class Spi with _$Spi {
String? alterUrl,
@Default(true) bool autoConnect,
/// [id] of the jump server
/// [id] of the jump server (legacy, single hop)
///
/// Migrated to [jumpChainIds].
String? jumpId,
/// Jump chain hop ids (nearest -> farthest)
///
/// Preferred over [jumpId].
@JsonKey(includeIfNull: false) List<String>? jumpChainIds,
ServerCustom? custom,
WakeOnLanCfg? wolCfg,
@@ -46,18 +53,14 @@ abstract class Spi with _$Spi {
@Default('') @JsonKey(fromJson: Spi.parseId) String id,
/// Custom system type (unix or windows). If set, skip auto-detection.
SystemType? customSystemType,
@JsonKey(includeIfNull: false) SystemType? customSystemType,
/// Disabled command types for this server
List<String>? disabledCmdTypes,
/// ProxyCommand configuration for SSH connections
ProxyCommandConfig? proxyCommand,
@JsonKey(includeIfNull: false) List<String>? disabledCmdTypes,
}) = _Spi;
factory Spi.fromJson(Map<String, dynamic> json) => _$SpiFromJson(json);
@override
String toString() => 'Spi<$oldId>';
@@ -84,7 +87,10 @@ extension Spix on Spi {
String? migrateId() {
if (id.isNotEmpty) return null;
ServerStore.instance.delete(oldId);
final newSpi = copyWith(id: ShortId.generate());
final newSpi = copyWith(
id: ShortId.generate(),
jumpChainIds: jumpChainIds ?? (jumpId == null ? null : [jumpId!]),
);
newSpi.save();
return newSpi.id;
}
@@ -99,7 +105,8 @@ extension Spix on Spi {
port == other.port &&
pwd == other.pwd &&
keyId == other.keyId &&
jumpId == other.jumpId;
jumpId == other.jumpId &&
listEquals(jumpChainIds, other.jumpChainIds);
}
/// Returns true if the connection should be re-established.
@@ -142,7 +149,7 @@ extension Spix on Spi {
tags: ['tag1', 'tag2'],
alterUrl: 'user@ip:port',
autoConnect: true,
jumpId: 'jump_server_id',
jumpChainIds: ['jump_server_id'],
custom: ServerCustom(
pveAddr: 'http://localhost:8006',
pveIgnoreCert: false,

View File

@@ -16,12 +16,16 @@ 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
String? get jumpId; 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 (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.
Map<String, String>? get envs;@JsonKey(fromJson: Spi.parseId) String get id;/// Custom system type (unix or windows). If set, skip auto-detection.
SystemType? get customSystemType;/// Disabled command types for this server
List<String>? get disabledCmdTypes;/// ProxyCommand configuration for SSH connections
ProxyCommandConfig? get proxyCommand;
@JsonKey(includeIfNull: false) SystemType? get customSystemType;/// Disabled command types for this server
@JsonKey(includeIfNull: false) List<String>? get disabledCmdTypes;
/// Create a copy of Spi
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -34,12 +38,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)&&(identical(other.proxyCommand, proxyCommand) || other.proxyCommand == proxyCommand));
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));
}
@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),proxyCommand);
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));
@@ -50,11 +54,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, SystemType? customSystemType, List<String>? disabledCmdTypes, ProxyCommandConfig? proxyCommand
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
});
$ProxyCommandConfigCopyWith<$Res>? get proxyCommand;
}
/// @nodoc
@@ -67,7 +71,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,Object? proxyCommand = 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? jumpChainIds = 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
@@ -79,29 +83,17 @@ 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?,custom: freezed == custom ? _self.custom : custom // 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 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
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>?,proxyCommand: freezed == proxyCommand ? _self.proxyCommand : proxyCommand // ignore: cast_nullable_to_non_nullable
as ProxyCommandConfig?,
as List<String>?,
));
}
/// 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));
});
}
}
@@ -183,10 +175,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, SystemType? customSystemType, List<String>? disabledCmdTypes, ProxyCommandConfig? proxyCommand)? $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, @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;
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,_that.proxyCommand);case _:
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 orElse();
}
@@ -204,10 +196,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, SystemType? customSystemType, List<String>? disabledCmdTypes, ProxyCommandConfig? proxyCommand) $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, @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;
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,_that.proxyCommand);case _:
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 _:
throw StateError('Unexpected subclass');
}
@@ -224,10 +216,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, SystemType? customSystemType, List<String>? disabledCmdTypes, ProxyCommandConfig? proxyCommand)? $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, @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;
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,_that.proxyCommand);case _:
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 null;
}
@@ -237,9 +229,9 @@ return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,
/// @nodoc
@JsonSerializable(includeIfNull: false, explicitToJson: true)
@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, 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._();
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._();
factory _Spi.fromJson(Map<String, dynamic> json) => _$SpiFromJson(json);
@override final String name;
@@ -260,8 +252,25 @@ class _Spi extends Spi {
@override final String? alterUrl;
@override@JsonKey() final bool autoConnect;
/// [id] of the jump server
/// [id] of the jump server (legacy, single hop)
///
/// Migrated to [jumpChainIds].
@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.
@@ -277,11 +286,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 final SystemType? customSystemType;
@override@JsonKey(includeIfNull: false) final SystemType? customSystemType;
/// Disabled command types for this server
final List<String>? _disabledCmdTypes;
/// Disabled command types for this server
@override List<String>? get disabledCmdTypes {
@override@JsonKey(includeIfNull: false) List<String>? get disabledCmdTypes {
final value = _disabledCmdTypes;
if (value == null) return null;
if (_disabledCmdTypes is EqualUnmodifiableListView) return _disabledCmdTypes;
@@ -289,8 +298,6 @@ 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.
@@ -305,12 +312,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)&&(identical(other.proxyCommand, proxyCommand) || other.proxyCommand == proxyCommand));
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));
}
@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),proxyCommand);
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));
@@ -321,11 +328,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, SystemType? customSystemType, List<String>? disabledCmdTypes, ProxyCommandConfig? proxyCommand
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
});
@override $ProxyCommandConfigCopyWith<$Res>? get proxyCommand;
}
/// @nodoc
@@ -338,7 +345,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,Object? proxyCommand = 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? jumpChainIds = 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
@@ -350,30 +357,18 @@ 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?,custom: freezed == custom ? _self.custom : custom // 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 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
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>?,proxyCommand: freezed == proxyCommand ? _self.proxyCommand : proxyCommand // ignore: cast_nullable_to_non_nullable
as ProxyCommandConfig?,
as List<String>?,
));
}
/// Create a copy of Spi
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$ProxyCommandConfigCopyWith<$Res>? get proxyCommand {
if (_self.proxyCommand == null) {
return null;
}
return $ProxyCommandConfigCopyWith<$Res>(_self.proxyCommand!, (value) {
return _then(_self.copyWith(proxyCommand: value));
});
}
}
// dart format on

View File

@@ -17,6 +17,9 @@ _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>),
@@ -34,11 +37,6 @@ _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>{
@@ -52,13 +50,13 @@ Map<String, dynamic> _$SpiToJson(_Spi instance) => <String, dynamic>{
'alterUrl': ?instance.alterUrl,
'autoConnect': instance.autoConnect,
'jumpId': ?instance.jumpId,
'custom': ?instance.custom?.toJson(),
'wolCfg': ?instance.wolCfg?.toJson(),
'jumpChainIds': ?instance.jumpChainIds,
'custom': ?instance.custom,
'wolCfg': ?instance.wolCfg,
'envs': ?instance.envs,
'id': instance.id,
'customSystemType': ?_$SystemTypeEnumMap[instance.customSystemType],
'disabledCmdTypes': ?instance.disabledCmdTypes,
'proxyCommand': ?instance.proxyCommand?.toJson(),
};
const _$SystemTypeEnumMap = {

View File

@@ -378,18 +378,27 @@ void _parseWindowsCpuData(ServerStatusUpdateReq req, Map<String, String> parsedO
// Windows CPU parsing - JSON format from PowerShell
final cpuRaw = WindowsStatusCmdType.cpu.findInMap(parsedOutput);
if (cpuRaw.isNotEmpty && cpuRaw != 'null' && !cpuRaw.contains('error') && !cpuRaw.contains('Exception')) {
final cpus = WindowsParser.parseCpu(cpuRaw, req.ss);
if (cpus.isNotEmpty) {
req.ss.cpu.update(cpus);
final cpuResult = WindowsParser.parseCpu(cpuRaw, req.ss);
if (cpuResult.cores.isNotEmpty) {
req.ss.cpu.update(cpuResult.cores);
final brandRaw = WindowsStatusCmdType.cpuBrand.findInMap(parsedOutput);
if (brandRaw.isNotEmpty && brandRaw != 'null') {
req.ss.cpu.brand.clear();
final brandLines = brandRaw.trim().split('\n');
final uniqueBrands = <String>{};
for (final line in brandLines) {
final trimmedLine = line.trim();
if (trimmedLine.isNotEmpty) {
uniqueBrands.add(trimmedLine);
}
}
if (uniqueBrands.isNotEmpty) {
final brandName = uniqueBrands.first;
req.ss.cpu.brand[brandName] = cpuResult.coreCount;
}
}
}
}
// Windows CPU brand parsing
final brandRaw = WindowsStatusCmdType.cpuBrand.findInMap(parsedOutput);
if (brandRaw.isNotEmpty && brandRaw != 'null') {
req.ss.cpu.brand.clear();
req.ss.cpu.brand[brandRaw.trim()] = 1;
}
} catch (e, s) {
Loggers.app.warning('Windows CPU parsing failed: $e', s);
}
@@ -427,8 +436,11 @@ void _parseWindowsDiskData(ServerStatusUpdateReq req, Map<String, String> parsed
/// Parse Windows uptime data
void _parseWindowsUptimeData(ServerStatusUpdateReq req, Map<String, String> parsedOutput) {
try {
final uptime = WindowsParser.parseUpTime(WindowsStatusCmdType.uptime.findInMap(parsedOutput));
if (uptime != null) {
final uptimeRaw = WindowsStatusCmdType.uptime.findInMap(parsedOutput);
if (uptimeRaw.isNotEmpty && uptimeRaw != 'null') {
// PowerShell now returns pre-formatted uptime string (e.g., "28 days, 5:00" or "5:00")
// No parsing needed - use it directly
final uptime = uptimeRaw.trim();
req.ss.more[StatusCmdType.uptime] = uptime;
}
} catch (e, s) {
@@ -541,38 +553,36 @@ List<NetSpeedPart> _parseWindowsNetwork(String raw, int currentTime) {
final dynamic jsonData = json.decode(raw);
final List<NetSpeedPart> netParts = [];
// PowerShell Get-Counter returns a structure with CounterSamples
if (jsonData is Map && jsonData.containsKey('CounterSamples')) {
final samples = jsonData['CounterSamples'] as List?;
if (samples != null && samples.length >= 2) {
// We need 2 samples to calculate speed (interval between them)
final Map<String, double> interfaceRx = {};
final Map<String, double> interfaceTx = {};
for (final sample in samples) {
final path = sample['Path']?.toString() ?? '';
final cookedValue = sample['CookedValue'] as num? ?? 0;
if (path.contains('Bytes Received/sec')) {
final interfaceName = _extractInterfaceName(path);
if (interfaceName.isNotEmpty) {
interfaceRx[interfaceName] = cookedValue.toDouble();
}
} else if (path.contains('Bytes Sent/sec')) {
final interfaceName = _extractInterfaceName(path);
if (interfaceName.isNotEmpty) {
interfaceTx[interfaceName] = cookedValue.toDouble();
}
}
}
// Create NetSpeedPart for each interface
for (final interfaceName in interfaceRx.keys) {
final rx = interfaceRx[interfaceName] ?? 0;
final tx = interfaceTx[interfaceName] ?? 0;
if (jsonData is List && jsonData.length >= 2) {
var sample1 = jsonData[jsonData.length - 2];
var sample2 = jsonData[jsonData.length - 1];
if (sample1 is Map && sample1.containsKey('value')) {
sample1 = sample1['value'];
}
if (sample2 is Map && sample2.containsKey('value')) {
sample2 = sample2['value'];
}
if (sample1 is List && sample2 is List && sample1.length == sample2.length) {
for (int i = 0; i < sample1.length; i++) {
final s1 = sample1[i];
final s2 = sample2[i];
final name = s1['Name']?.toString() ?? '';
if (name.isEmpty || name == '_Total') continue;
final rx1 = (s1['BytesReceivedPersec'] as num?)?.toDouble() ?? 0;
final rx2 = (s2['BytesReceivedPersec'] as num?)?.toDouble() ?? 0;
final tx1 = (s1['BytesSentPersec'] as num?)?.toDouble() ?? 0;
final tx2 = (s2['BytesSentPersec'] as num?)?.toDouble() ?? 0;
final time1 = (s1['Timestamp_Sys100NS'] as num?)?.toDouble() ?? 0;
final time2 = (s2['Timestamp_Sys100NS'] as num?)?.toDouble() ?? 0;
final timeDelta = (time2 - time1) / 10000000;
if (timeDelta <= 0) continue;
final rxDelta = rx2 - rx1;
final txDelta = tx2 - tx1;
if (rxDelta < 0 || txDelta < 0) continue;
final rxSpeed = rxDelta / timeDelta;
final txSpeed = txDelta / timeDelta;
netParts.add(
NetSpeedPart(interfaceName, BigInt.from(rx.toInt()), BigInt.from(tx.toInt()), currentTime),
NetSpeedPart(name, BigInt.from(rxSpeed.toInt()), BigInt.from(txSpeed.toInt()), currentTime),
);
}
}
@@ -584,53 +594,45 @@ List<NetSpeedPart> _parseWindowsNetwork(String raw, int currentTime) {
}
}
String _extractInterfaceName(String path) {
// Extract interface name from path like
// "\\Computer\\NetworkInterface(Interface Name)\\..."
final match = RegExp(r'\\NetworkInterface\(([^)]+)\)\\').firstMatch(path);
return match?.group(1) ?? '';
}
List<DiskIOPiece> _parseWindowsDiskIO(String raw, int currentTime) {
try {
final dynamic jsonData = json.decode(raw);
final List<DiskIOPiece> diskParts = [];
// PowerShell Get-Counter returns a structure with CounterSamples
if (jsonData is Map && jsonData.containsKey('CounterSamples')) {
final samples = jsonData['CounterSamples'] as List?;
if (samples != null) {
final Map<String, double> diskReads = {};
final Map<String, double> diskWrites = {};
for (final sample in samples) {
final path = sample['Path']?.toString() ?? '';
final cookedValue = sample['CookedValue'] as num? ?? 0;
if (path.contains('Disk Read Bytes/sec')) {
final diskName = _extractDiskName(path);
if (diskName.isNotEmpty) {
diskReads[diskName] = cookedValue.toDouble();
}
} else if (path.contains('Disk Write Bytes/sec')) {
final diskName = _extractDiskName(path);
if (diskName.isNotEmpty) {
diskWrites[diskName] = cookedValue.toDouble();
}
}
}
// Create DiskIOPiece for each disk - convert bytes to sectors
// (assuming 512 bytes per sector)
for (final diskName in diskReads.keys) {
final readBytes = diskReads[diskName] ?? 0;
final writeBytes = diskWrites[diskName] ?? 0;
final sectorsRead = (readBytes / 512).round();
final sectorsWrite = (writeBytes / 512).round();
if (jsonData is List && jsonData.length >= 2) {
var sample1 = jsonData[jsonData.length - 2];
var sample2 = jsonData[jsonData.length - 1];
if (sample1 is Map && sample1.containsKey('value')) {
sample1 = sample1['value'];
}
if (sample2 is Map && sample2.containsKey('value')) {
sample2 = sample2['value'];
}
if (sample1 is List && sample2 is List && sample1.length == sample2.length) {
for (int i = 0; i < sample1.length; i++) {
final s1 = sample1[i];
final s2 = sample2[i];
final name = s1['Name']?.toString() ?? '';
if (name.isEmpty || name == '_Total') continue;
final read1 = (s1['DiskReadBytesPersec'] as num?)?.toDouble() ?? 0;
final read2 = (s2['DiskReadBytesPersec'] as num?)?.toDouble() ?? 0;
final write1 = (s1['DiskWriteBytesPersec'] as num?)?.toDouble() ?? 0;
final write2 = (s2['DiskWriteBytesPersec'] as num?)?.toDouble() ?? 0;
final time1 = (s1['Timestamp_Sys100NS'] as num?)?.toDouble() ?? 0;
final time2 = (s2['Timestamp_Sys100NS'] as num?)?.toDouble() ?? 0;
final timeDelta = (time2 - time1) / 10000000;
if (timeDelta <= 0) continue;
final readDelta = read2 - read1;
final writeDelta = write2 - write1;
if (readDelta < 0 || writeDelta < 0) continue;
final readSpeed = readDelta / timeDelta;
final writeSpeed = writeDelta / timeDelta;
final sectorsRead = (readSpeed / 512).round();
final sectorsWrite = (writeSpeed / 512).round();
diskParts.add(
DiskIOPiece(
dev: diskName,
dev: name,
sectorsRead: sectorsRead,
sectorsWrite: sectorsWrite,
time: currentTime,
@@ -646,13 +648,6 @@ List<DiskIOPiece> _parseWindowsDiskIO(String raw, int currentTime) {
}
}
String _extractDiskName(String path) {
// Extract disk name from path like
// "\\Computer\\PhysicalDisk(Disk Name)\\..."
final match = RegExp(r'\\PhysicalDisk\(([^)]+)\)\\').firstMatch(path);
return match?.group(1) ?? '';
}
void _parseWindowsTemperatures(Temperatures temps, String raw) {
try {
// Handle error output
@@ -684,7 +679,7 @@ void _parseWindowsTemperatures(Temperatures temps, String raw) {
if (typeLines.isNotEmpty && valueLines.isNotEmpty) {
temps.parse(typeLines.join('\n'), valueLines.join('\n'));
}
} catch (e) {
// If JSON parsing fails, ignore temperature data
} catch (e, s) {
Loggers.app.warning('Failed to parse Windows temperature data', e, s);
}
}

View File

@@ -37,27 +37,39 @@ class Fifo<T> extends ListBase<T> {
}
}
abstract class TimeSeq<T extends List<TimeSeqIface>> extends Fifo<T> {
abstract class TimeSeq<T extends TimeSeqIface<T>> extends Fifo<List<T>> {
/// Due to the design, at least two elements are required, otherwise [pre] /
/// [now] will throw.
TimeSeq(T init1, T init2, {super.capacity}) : super(list: [init1, init2]);
TimeSeq(List<T> init1, List<T> init2, {super.capacity}) : super(list: [init1, init2]);
T get pre {
List<T> get pre {
return _list[length - 2];
}
T get now {
List<T> get now {
return _list[length - 1];
}
void onUpdate();
void update(T new_) {
void update(List<T> new_) {
add(new_);
if (pre.length != now.length) {
pre.removeWhere((e) => now.any((el) => e.same(el)));
pre.addAll(now.where((e) => pre.every((el) => !e.same(el))));
final previous = pre.toList(growable: false);
final remaining = previous.toList(growable: true);
final aligned = <T>[];
for (final current in now) {
final matchIndex = remaining.indexWhere((item) => item.same(current));
if (matchIndex >= 0) {
aligned.add(remaining.removeAt(matchIndex));
} else {
aligned.add(current);
}
}
_list[length - 2] = aligned;
}
onUpdate();

View File

@@ -7,6 +7,13 @@ import 'package:server_box/data/model/server/disk.dart';
import 'package:server_box/data/model/server/memory.dart';
import 'package:server_box/data/model/server/server.dart';
/// Windows CPU parse result
class WindowsCpuResult {
final List<SingleCpuCore> cores;
final int coreCount;
const WindowsCpuResult(this.cores, this.coreCount);
}
/// Windows-specific status parsing utilities
///
/// This module handles parsing of Windows PowerShell command outputs
@@ -94,30 +101,75 @@ class WindowsParser {
}
/// Parse Windows CPU information from PowerShell output
static List<SingleCpuCore> parseCpu(String raw, ServerStatus serverStatus) {
/// Returns WindowsCpuResult containing CPU cores and total core count
static WindowsCpuResult parseCpu(String raw, ServerStatus serverStatus) {
try {
final dynamic jsonData = json.decode(raw);
final List<SingleCpuCore> cpus = [];
int totalCoreCount = 1;
if (jsonData is List) {
for (int i = 0; i < jsonData.length; i++) {
final cpu = jsonData[i];
final loadPercentage = cpu['LoadPercentage'] ?? 0;
final usage = loadPercentage as int;
// Multiple physical processors
totalCoreCount = 0; // Reset to sum up
var logicalProcessorOffset = 0;
final prevCpus = serverStatus.cpu.now;
for (int procIdx = 0; procIdx < jsonData.length; procIdx++) {
final processor = jsonData[procIdx];
final loadPercentage = (processor['LoadPercentage'] as num?) ?? 0;
final numberOfCores = (processor['NumberOfCores'] as int?) ?? 1;
final numberOfLogicalProcessors = (processor['NumberOfLogicalProcessors'] as int?) ?? numberOfCores;
totalCoreCount += numberOfCores;
final usage = loadPercentage.toInt();
final idle = 100 - usage;
// Get previous CPU data to calculate cumulative values
final prevCpus = serverStatus.cpu.now;
final prevCpu = i < prevCpus.length ? prevCpus[i] : null;
// Create a SingleCpuCore entry for each logical processor
// Windows only reports overall CPU load, so we distribute it evenly
for (int i = 0; i < numberOfLogicalProcessors; i++) {
final coreId = logicalProcessorOffset + i;
// Skip summary entry at index 0 when looking up previous samples
final prevIndex = coreId + 1;
final prevCpu = prevIndex < prevCpus.length ? prevCpus[prevIndex] : null;
// LIMITATION: Windows CPU counters approach
// PowerShell provides LoadPercentage as instantaneous percentage, not cumulative time.
// We simulate cumulative counters by adding current percentages to previous totals.
// This approach has limitations:
// 1. Not as accurate as true cumulative time counters (Linux /proc/stat)
// 2. May drift over time with variable polling intervals
// 3. Results depend on consistent polling frequency
// However, this allows compatibility with existing delta-based CPU calculation logic.
// LIMITATION: Windows CPU counters approach
// PowerShell provides LoadPercentage as instantaneous percentage, not cumulative time.
// We simulate cumulative counters by adding current percentages to previous totals.
// Additionally, Windows only provides overall CPU load, not per-core load.
// We distribute the load evenly across all logical processors.
final newUser = (prevCpu?.user ?? 0) + usage;
final newIdle = (prevCpu?.idle ?? 0) + idle;
cpus.add(
SingleCpuCore(
'cpu$coreId',
newUser, // cumulative user time
0, // sys (not available)
0, // nice (not available)
newIdle, // cumulative idle time
0, // iowait (not available)
0, // irq (not available)
0, // softirq (not available)
),
);
}
logicalProcessorOffset += numberOfLogicalProcessors;
}
} else if (jsonData is Map) {
// Single physical processor
final loadPercentage = (jsonData['LoadPercentage'] as num?) ?? 0;
final numberOfCores = (jsonData['NumberOfCores'] as int?) ?? 1;
final numberOfLogicalProcessors = (jsonData['NumberOfLogicalProcessors'] as int?) ?? numberOfCores;
totalCoreCount = numberOfCores;
final usage = loadPercentage.toInt();
final idle = 100 - usage;
// Create a SingleCpuCore entry for each logical processor
final prevCpus = serverStatus.cpu.now;
for (int i = 0; i < numberOfLogicalProcessors; i++) {
// Skip summary entry at index 0 when looking up previous samples
final prevIndex = i + 1;
final prevCpu = prevIndex < prevCpus.length ? prevCpus[prevIndex] : null;
// LIMITATION: See comment above for Windows CPU counter limitations
final newUser = (prevCpu?.user ?? 0) + usage;
final newIdle = (prevCpu?.idle ?? 0) + idle;
@@ -125,46 +177,43 @@ class WindowsParser {
SingleCpuCore(
'cpu$i',
newUser, // cumulative user time
0, // sys (not available)
0, // nice (not available)
0, // sys
0, // nice
newIdle, // cumulative idle time
0, // iowait (not available)
0, // irq (not available)
0, // softirq (not available)
0, // iowait
0, // irq
0, // softirq
),
);
}
} else if (jsonData is Map) {
// Single CPU core
final loadPercentage = jsonData['LoadPercentage'] ?? 0;
final usage = loadPercentage as int;
final idle = 100 - usage;
// Get previous CPU data to calculate cumulative values
final prevCpus = serverStatus.cpu.now;
final prevCpu = prevCpus.isNotEmpty ? prevCpus[0] : null;
// LIMITATION: See comment above for Windows CPU counter limitations
final newUser = (prevCpu?.user ?? 0) + usage;
final newIdle = (prevCpu?.idle ?? 0) + idle;
cpus.add(
SingleCpuCore(
'cpu0',
newUser, // cumulative user time
0, // sys
0, // nice
newIdle, // cumulative idle time
0, // iowait
0, // irq
0, // softirq
),
);
}
return cpus;
} catch (e) {
return [];
// Add a summary entry at the beginning (like Linux 'cpu' line)
// This is the aggregate of all logical processors
if (cpus.isNotEmpty) {
int totalUser = 0;
int totalIdle = 0;
for (final core in cpus) {
totalUser += core.user;
totalIdle += core.idle;
}
// Insert at the beginning with ID 'cpu' (matching Linux format)
cpus.insert(0, SingleCpuCore(
'cpu', // Summary entry, like Linux
totalUser,
0,
0,
totalIdle,
0,
0,
0,
));
}
return WindowsCpuResult(cpus, totalCoreCount);
} catch (e, s) {
Loggers.app.warning('Windows CPU parsing failed: $e', s);
return WindowsCpuResult([], 1);
}
}

View File

@@ -6,17 +6,32 @@ class SftpReq {
final String localPath;
final SftpReqType type;
String? privateKey;
Spi? jumpSpi;
String? jumpPrivateKey;
List<Spi>? jumpChain;
List<String?>? jumpPrivateKeys;
Map<String, String>? knownHostFingerprints;
SftpReq(this.spi, this.remotePath, this.localPath, this.type) {
final keyId = spi.keyId;
if (keyId != null) {
privateKey = getPrivateKey(keyId);
}
if (spi.jumpId != null) {
jumpSpi = Stores.server.box.get(spi.jumpId);
jumpPrivateKey = Stores.key.fetchOne(jumpSpi?.keyId)?.key;
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;
}
try {
knownHostFingerprints = Map<String, String>.from(Stores.setting.sshKnownHostFingerprints.get());
} catch (e, s) {
Loggers.app.warning('Failed to load SSH known host fingerprints', e, s);
knownHostFingerprints = null;
}
}
}
@@ -30,7 +45,7 @@ class SftpReqStatus {
late SftpWorker worker;
final Completer? completer;
String get fileName => req.localPath.split('/').last;
String get fileName => req.localPath.split(Pfs.seperator).last;
// status of the download
double? progress;
@@ -83,4 +98,4 @@ class SftpReqStatus {
}
}
enum SftpWorkerStatus { preparing, sshConnectted, loading, finished }
enum SftpWorkerStatus { preparing, sshConnected, loading, finished }

View File

@@ -63,13 +63,14 @@ Future<void> _download(SftpReq req, SendPort mainSendPort, SendErrorFunction sen
final client = await genClient(
req.spi,
privateKey: req.privateKey,
jumpSpi: req.jumpSpi,
jumpPrivateKey: req.jumpPrivateKey,
jumpChain: req.jumpChain,
jumpPrivateKeys: req.jumpPrivateKeys,
knownHostFingerprints: req.knownHostFingerprints,
);
mainSendPort.send(SftpWorkerStatus.sshConnectted);
mainSendPort.send(SftpWorkerStatus.sshConnected);
/// Create the directory if not exists
final dirPath = req.localPath.substring(0, req.localPath.lastIndexOf('/'));
final dirPath = req.localPath.substring(0, req.localPath.lastIndexOf(Pfs.seperator));
await Directory(dirPath).create(recursive: true);
/// Use [FileMode.write] to overwrite the file
@@ -119,10 +120,11 @@ Future<void> _upload(SftpReq req, SendPort mainSendPort, SendErrorFunction sendE
final client = await genClient(
req.spi,
privateKey: req.privateKey,
jumpSpi: req.jumpSpi,
jumpPrivateKey: req.jumpPrivateKey,
jumpChain: req.jumpChain,
jumpPrivateKeys: req.jumpPrivateKeys,
knownHostFingerprints: req.knownHostFingerprints,
);
mainSendPort.send(SftpWorkerStatus.sshConnectted);
mainSendPort.send(SftpWorkerStatus.sshConnected);
final local = File(req.localPath);
if (!await local.exists()) {

View File

@@ -186,7 +186,7 @@ class ContainerNotifier extends _$ContainerNotifier {
(element) => element.contains(id.substring(0, 5)),
);
if (statsLine == null) continue;
item.parseStats(statsLine);
item.parseStats(statsLine, state.version);
}
} catch (e, trace) {
state = state.copyWith(
@@ -280,7 +280,7 @@ enum ContainerCmdType {
return switch (this) {
ContainerCmdType.version => '$prefix version $_jsonFmt',
ContainerCmdType.ps => switch (type) {
/// TODO: Rollback to json format when permformance recovers.
/// TODO: Rollback to json format when performance recovers.
/// Use [_jsonFmt] in Docker will cause the operation to slow down.
ContainerType.docker =>
'$prefix ps -a --format "table {{printf \\"'

View File

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

View File

@@ -6,7 +6,6 @@ import 'package:dartssh2/dartssh2.dart';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
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';
@@ -108,7 +107,7 @@ class PveNotifier extends _$PveNotifier {
final newUrl = Uri.parse(
addr,
).replace(host: 'localhost', port: _localPort).toString();
debugPrint('Forwarding $newUrl to $addr');
dprint('Forwarding $newUrl to $addr');
}
}
@@ -235,11 +234,15 @@ class PveNotifier extends _$PveNotifier {
Future<void> dispose() async {
try {
await _serverSocket.close();
} catch (_) {}
} catch (e, s) {
Loggers.app.warning('Failed to close server socket', e, s);
}
for (final forward in _forwards) {
try {
forward.close();
} catch (_) {}
} catch (e, s) {
Loggers.app.warning('Failed to close forward', e, s);
}
}
}
}

View File

@@ -58,7 +58,7 @@ final class PveNotifierProvider
}
}
String _$pveNotifierHash() => r'ba5f2d6cb47c33735f7cc09b771b4a86501b86c6';
String _$pveNotifierHash() => r'1e71faadee074b9c07bee731ef4ae6505e791967';
final class PveNotifierFamily extends $Family
with $ClassFamilyOverride<PveNotifier, PveState, PveState, PveState, Spi> {

View File

@@ -103,37 +103,44 @@ class ServersNotifier extends _$ServersNotifier {
return;
}
await Future.wait(
state.servers.entries.map((entry) async {
final serverId = entry.key;
final spi = entry.value;
final serversToRefresh = <MapEntry<String, Spi>>[];
final idsToResetLimiter = <String>[];
if (onlyFailed) {
final serverState = ref.read(serverProvider(serverId));
if (serverState.conn != ServerConn.failed) return;
TryLimiter.reset(serverId);
}
for (final entry in state.servers.entries) {
final serverId = entry.key;
final spi = entry.value;
if (state.manualDisconnectedIds.contains(serverId)) return;
if (state.manualDisconnectedIds.contains(serverId)) continue;
final serverState = ref.read(serverProvider(serverId));
if (serverState.conn == ServerConn.disconnected && !spi.autoConnect) {
return;
}
final serverState = ref.read(serverProvider(serverId));
final serverNotifier = ref.read(serverProvider(serverId).notifier);
await serverNotifier.refresh();
}),
);
if (onlyFailed) {
if (serverState.conn != ServerConn.failed) continue;
idsToResetLimiter.add(serverId);
}
if (serverState.conn == ServerConn.disconnected && !spi.autoConnect) continue;
serversToRefresh.add(entry);
}
for (final id in idsToResetLimiter) {
TryLimiter.reset(id);
}
for (final entry in serversToRefresh) {
final serverNotifier = ref.read(serverProvider(entry.key).notifier);
serverNotifier.refresh().ignore();
}
}
Future<void> startAutoRefresh() async {
var duration = Stores.setting.serverStatusUpdateInterval.fetch();
stopAutoRefresh();
if (duration == 0) return;
if (duration < 0 || duration > 10 || duration == 1) {
duration = 3;
if (duration <= 1 || duration > 10) {
Loggers.app.warning('Invalid duration: $duration, use default 3');
duration = 3;
}
final timer = Timer.periodic(Duration(seconds: duration), (_) async {
await refresh();
@@ -145,8 +152,8 @@ class ServersNotifier extends _$ServersNotifier {
final timer = state.autoRefreshTimer;
if (timer != null) {
timer.cancel();
state = state.copyWith(autoRefreshTimer: null);
}
state = state.copyWith(autoRefreshTimer: null);
}
bool get isAutoRefreshOn => state.autoRefreshTimer != null;

View File

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

View File

@@ -1,15 +1,14 @@
import 'dart:async';
import 'dart:convert';
import 'package:computer/computer.dart';
import 'package:dartssh2/dartssh2.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter_gbk2utf8/flutter_gbk2utf8.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:server_box/core/extension/ssh_client.dart';
import 'package:server_box/core/utils/server.dart';
import 'package:server_box/core/utils/ssh_auth.dart';
import 'package:server_box/data/helper/ssh_decoder.dart';
import 'package:server_box/data/helper/system_detector.dart';
import 'package:server_box/data/model/app/error.dart';
import 'package:server_box/data/model/app/scripts/script_consts.dart';
@@ -36,7 +35,6 @@ abstract class ServerState with _$ServerState {
required ServerStatus status,
@Default(ServerConn.disconnected) ServerConn conn,
SSHClient? client,
Future<void>? updateFuture,
}) = _ServerState;
}
@@ -82,19 +80,16 @@ class ServerNotifier extends _$ServerNotifier {
}
// Refresh server status
bool _isRefreshing = false;
Future<void> refresh() async {
if (state.updateFuture != null) {
await state.updateFuture;
return;
}
final updateFuture = _updateServer();
state = state.copyWith(updateFuture: updateFuture);
if (_isRefreshing) return;
_isRefreshing = true;
try {
await updateFuture;
await _updateServer();
} finally {
state = state.copyWith(updateFuture: null);
_isRefreshing = false;
}
}
@@ -140,7 +135,7 @@ class ServerNotifier extends _$ServerNotifier {
final time2 = DateTime.now();
final spentTime = time2.difference(time1).inMilliseconds;
if (spi.jumpId == null) {
if ((spi.jumpChainIds?.isNotEmpty != true) && spi.jumpId == null) {
Loggers.app.info('Connected to ${spi.name} in $spentTime ms.');
} else {
Loggers.app.info('Jump to ${spi.name} in $spentTime ms.');
@@ -213,7 +208,9 @@ class ServerNotifier extends _$ServerNotifier {
final newStatus = state.status..system = detectedSystemType;
updateStatus(newStatus);
final (_, writeScriptResult) = await state.client!.exec(
Loggers.app.info('Writing script for ${spi.name} (${detectedSystemType.name})');
final (stdoutResult, writeScriptResult) = await state.client!.execSafe(
(session) async {
final scriptRaw = ShellFuncManager.allScript(
spi.custom?.cmds,
@@ -228,10 +225,22 @@ class ServerNotifier extends _$ServerNotifier {
systemType: detectedSystemType,
customDir: spi.custom?.scriptDir,
),
systemType: detectedSystemType,
context: 'WriteScript<${spi.name}>',
);
if (writeScriptResult.isNotEmpty && detectedSystemType != SystemType.windows) {
ShellFuncManager.switchScriptDir(spi.id, systemType: detectedSystemType);
throw writeScriptResult;
if (stdoutResult.isNotEmpty) {
Loggers.app.info('Script write stdout for ${spi.name}: $stdoutResult');
}
if (writeScriptResult.isNotEmpty) {
Loggers.app.warning('Script write stderr for ${spi.name}: $writeScriptResult');
if (detectedSystemType != SystemType.windows) {
ShellFuncManager.switchScriptDir(spi.id, systemType: detectedSystemType);
throw writeScriptResult;
}
} else {
Loggers.app.info('Script written successfully for ${spi.name}');
}
} on SSHAuthAbortError catch (e) {
TryLimiter.inc(sid);
@@ -278,43 +287,25 @@ class ServerNotifier extends _$ServerNotifier {
String? raw;
try {
final execResult = await state.client?.run(
ShellFunc.status.exec(spi.id, systemType: state.status.system, customDir: spi.custom?.scriptDir),
);
final statusCmd = ShellFunc.status.exec(spi.id, systemType: state.status.system, customDir: spi.custom?.scriptDir);
// Loggers.app.info('Running status command for ${spi.name} (${state.status.system.name}): $statusCmd');
final execResult = await state.client?.run(statusCmd);
if (execResult != null) {
String? rawStr;
bool needGbk = false;
try {
rawStr = utf8.decode(execResult, allowMalformed: true);
// If there are unparseable characters, try fallback to GBK decoding
if (rawStr.contains('<EFBFBD>')) {
Loggers.app.warning('UTF8 decoding failed, use GBK decoding');
needGbk = true;
}
} catch (e) {
Loggers.app.warning('UTF8 decoding failed, use GBK decoding', e);
needGbk = true;
}
if (needGbk) {
try {
rawStr = gbk.decode(execResult);
} catch (e2) {
Loggers.app.warning('GBK decoding failed', e2);
rawStr = null;
}
}
if (rawStr == null) {
Loggers.app.warning('Decoding failed, execResult: $execResult');
}
raw = rawStr;
raw = SSHDecoder.decode(
execResult,
isWindows: state.status.system == SystemType.windows,
context: 'GetStatus<${spi.name}>',
);
// Loggers.app.info('Status response length for ${spi.name}: ${raw.length} bytes');
} else {
raw = execResult.toString();
raw = '';
Loggers.app.warning('No status result from ${spi.name}');
}
if (raw == null || raw.isEmpty) {
if (raw.isEmpty) {
TryLimiter.inc(sid);
final newStatus = state.status
..err = SSHErr(type: SSHErrType.segements, message: 'decode or split failed, raw:\n$raw');
..err = SSHErr(type: SSHErrType.segements, message: 'Empty response from server');
updateStatus(newStatus);
updateConnection(ServerConn.failed);
@@ -324,7 +315,7 @@ class ServerNotifier extends _$ServerNotifier {
}
segments = raw.split(ScriptConstants.separator).map((e) => e.trim()).toList();
if (raw.isEmpty || segments.isEmpty) {
if (segments.isEmpty) {
if (Stores.setting.keepStatusWhenErr.fetch()) {
// Keep previous server status when error occurs
if (state.conn != ServerConn.failed && state.status.more.isNotEmpty) {
@@ -333,7 +324,7 @@ class ServerNotifier extends _$ServerNotifier {
}
TryLimiter.inc(sid);
final newStatus = state.status
..err = SSHErr(type: SSHErrType.segements, message: 'Seperate segments failed, raw:\n$raw');
..err = SSHErr(type: SSHErrType.segements, message: 'Separate segments failed, raw:\n$raw');
updateStatus(newStatus);
updateConnection(ServerConn.failed);

View File

@@ -14,7 +14,7 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ServerState {
Spi get spi; ServerStatus get status; ServerConn get conn; SSHClient? get client; Future<void>? get updateFuture;
Spi get spi; ServerStatus get status; ServerConn get conn; SSHClient? get client;
/// Create a copy of ServerState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -25,16 +25,16 @@ $ServerStateCopyWith<ServerState> get copyWith => _$ServerStateCopyWithImpl<Serv
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ServerState&&(identical(other.spi, spi) || other.spi == spi)&&(identical(other.status, status) || other.status == status)&&(identical(other.conn, conn) || other.conn == conn)&&(identical(other.client, client) || other.client == client)&&(identical(other.updateFuture, updateFuture) || other.updateFuture == updateFuture));
return identical(this, other) || (other.runtimeType == runtimeType&&other is ServerState&&(identical(other.spi, spi) || other.spi == spi)&&(identical(other.status, status) || other.status == status)&&(identical(other.conn, conn) || other.conn == conn)&&(identical(other.client, client) || other.client == client));
}
@override
int get hashCode => Object.hash(runtimeType,spi,status,conn,client,updateFuture);
int get hashCode => Object.hash(runtimeType,spi,status,conn,client);
@override
String toString() {
return 'ServerState(spi: $spi, status: $status, conn: $conn, client: $client, updateFuture: $updateFuture)';
return 'ServerState(spi: $spi, status: $status, conn: $conn, client: $client)';
}
@@ -45,7 +45,7 @@ abstract mixin class $ServerStateCopyWith<$Res> {
factory $ServerStateCopyWith(ServerState value, $Res Function(ServerState) _then) = _$ServerStateCopyWithImpl;
@useResult
$Res call({
Spi spi, ServerStatus status, ServerConn conn, SSHClient? client, Future<void>? updateFuture
Spi spi, ServerStatus status, ServerConn conn, SSHClient? client
});
@@ -62,14 +62,13 @@ class _$ServerStateCopyWithImpl<$Res>
/// Create a copy of ServerState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? spi = null,Object? status = null,Object? conn = null,Object? client = freezed,Object? updateFuture = freezed,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? spi = null,Object? status = null,Object? conn = null,Object? client = freezed,}) {
return _then(_self.copyWith(
spi: null == spi ? _self.spi : spi // ignore: cast_nullable_to_non_nullable
as Spi,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as ServerStatus,conn: null == conn ? _self.conn : conn // ignore: cast_nullable_to_non_nullable
as ServerConn,client: freezed == client ? _self.client : client // ignore: cast_nullable_to_non_nullable
as SSHClient?,updateFuture: freezed == updateFuture ? _self.updateFuture : updateFuture // ignore: cast_nullable_to_non_nullable
as Future<void>?,
as SSHClient?,
));
}
/// Create a copy of ServerState
@@ -163,10 +162,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( Spi spi, ServerStatus status, ServerConn conn, SSHClient? client, Future<void>? updateFuture)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( Spi spi, ServerStatus status, ServerConn conn, SSHClient? client)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ServerState() when $default != null:
return $default(_that.spi,_that.status,_that.conn,_that.client,_that.updateFuture);case _:
return $default(_that.spi,_that.status,_that.conn,_that.client);case _:
return orElse();
}
@@ -184,10 +183,10 @@ return $default(_that.spi,_that.status,_that.conn,_that.client,_that.updateFutur
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( Spi spi, ServerStatus status, ServerConn conn, SSHClient? client, Future<void>? updateFuture) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( Spi spi, ServerStatus status, ServerConn conn, SSHClient? client) $default,) {final _that = this;
switch (_that) {
case _ServerState():
return $default(_that.spi,_that.status,_that.conn,_that.client,_that.updateFuture);case _:
return $default(_that.spi,_that.status,_that.conn,_that.client);case _:
throw StateError('Unexpected subclass');
}
@@ -204,10 +203,10 @@ return $default(_that.spi,_that.status,_that.conn,_that.client,_that.updateFutur
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( Spi spi, ServerStatus status, ServerConn conn, SSHClient? client, Future<void>? updateFuture)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( Spi spi, ServerStatus status, ServerConn conn, SSHClient? client)? $default,) {final _that = this;
switch (_that) {
case _ServerState() when $default != null:
return $default(_that.spi,_that.status,_that.conn,_that.client,_that.updateFuture);case _:
return $default(_that.spi,_that.status,_that.conn,_that.client);case _:
return null;
}
@@ -219,14 +218,13 @@ return $default(_that.spi,_that.status,_that.conn,_that.client,_that.updateFutur
class _ServerState implements ServerState {
const _ServerState({required this.spi, required this.status, this.conn = ServerConn.disconnected, this.client, this.updateFuture});
const _ServerState({required this.spi, required this.status, this.conn = ServerConn.disconnected, this.client});
@override final Spi spi;
@override final ServerStatus status;
@override@JsonKey() final ServerConn conn;
@override final SSHClient? client;
@override final Future<void>? updateFuture;
/// Create a copy of ServerState
/// with the given fields replaced by the non-null parameter values.
@@ -238,16 +236,16 @@ _$ServerStateCopyWith<_ServerState> get copyWith => __$ServerStateCopyWithImpl<_
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ServerState&&(identical(other.spi, spi) || other.spi == spi)&&(identical(other.status, status) || other.status == status)&&(identical(other.conn, conn) || other.conn == conn)&&(identical(other.client, client) || other.client == client)&&(identical(other.updateFuture, updateFuture) || other.updateFuture == updateFuture));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ServerState&&(identical(other.spi, spi) || other.spi == spi)&&(identical(other.status, status) || other.status == status)&&(identical(other.conn, conn) || other.conn == conn)&&(identical(other.client, client) || other.client == client));
}
@override
int get hashCode => Object.hash(runtimeType,spi,status,conn,client,updateFuture);
int get hashCode => Object.hash(runtimeType,spi,status,conn,client);
@override
String toString() {
return 'ServerState(spi: $spi, status: $status, conn: $conn, client: $client, updateFuture: $updateFuture)';
return 'ServerState(spi: $spi, status: $status, conn: $conn, client: $client)';
}
@@ -258,7 +256,7 @@ abstract mixin class _$ServerStateCopyWith<$Res> implements $ServerStateCopyWith
factory _$ServerStateCopyWith(_ServerState value, $Res Function(_ServerState) _then) = __$ServerStateCopyWithImpl;
@override @useResult
$Res call({
Spi spi, ServerStatus status, ServerConn conn, SSHClient? client, Future<void>? updateFuture
Spi spi, ServerStatus status, ServerConn conn, SSHClient? client
});
@@ -275,14 +273,13 @@ class __$ServerStateCopyWithImpl<$Res>
/// Create a copy of ServerState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? spi = null,Object? status = null,Object? conn = null,Object? client = freezed,Object? updateFuture = freezed,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? spi = null,Object? status = null,Object? conn = null,Object? client = freezed,}) {
return _then(_ServerState(
spi: null == spi ? _self.spi : spi // ignore: cast_nullable_to_non_nullable
as Spi,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as ServerStatus,conn: null == conn ? _self.conn : conn // ignore: cast_nullable_to_non_nullable
as ServerConn,client: freezed == client ? _self.client : client // ignore: cast_nullable_to_non_nullable
as SSHClient?,updateFuture: freezed == updateFuture ? _self.updateFuture : updateFuture // ignore: cast_nullable_to_non_nullable
as Future<void>?,
as SSHClient?,
));
}

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ abstract final class GithubIds {
'MasedMSD',
'GitGitro',
'Shin-suechtig',
'GT-610'
};
static const participants = <GhId>{

View File

@@ -89,15 +89,12 @@ class ServerStore extends HiveStore {
// Replace ids in jump server settings.
final spi = get<Spi>(newId);
if (spi != null) {
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);
}
}
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);
}
// Replace ids in [Snippet]

View File

@@ -228,10 +228,6 @@ class SettingStore extends HiveStore {
late final betaTest = propertyDefault('betaTest', false);
late final proxyCmdCustomExecs = listProperty('proxyCmdCustomExecs');
late final proxyCmdCustomPresets = listProperty('proxyCmdCustomPresets');
/// For desktop only.
/// Record the position and size of the window.
late final windowState = property<WindowState>(

View File

@@ -1885,6 +1885,54 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'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.'**
String get writeScriptTip;
/// No description provided for @menuSettings.
///
/// In en, this message translates to:
/// **'Setting'**
String get menuSettings;
/// No description provided for @menuQuit.
///
/// In en, this message translates to:
/// **'Quit'**
String get menuQuit;
/// No description provided for @menuNavigate.
///
/// In en, this message translates to:
/// **'Navigate'**
String get menuNavigate;
/// No description provided for @menuInfo.
///
/// In en, this message translates to:
/// **'Info'**
String get menuInfo;
/// No description provided for @menuGitHubRepository.
///
/// In en, this message translates to:
/// **'GitHub Repository'**
String get menuGitHubRepository;
/// No description provided for @menuWiki.
///
/// In en, this message translates to:
/// **'Wiki'**
String get menuWiki;
/// No description provided for @menuHelp.
///
/// In en, this message translates to:
/// **'Help'**
String get menuHelp;
/// No description provided for @logs.
///
/// In en, this message translates to:
/// **'Logs'**
String get logs;
}
class _AppLocalizationsDelegate

View File

@@ -1007,4 +1007,28 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get 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.';
@override
String get menuSettings => 'Setting';
@override
String get menuQuit => 'Quit';
@override
String get menuNavigate => 'Navigate';
@override
String get menuInfo => 'Info';
@override
String get menuGitHubRepository => 'GitHub Repository';
@override
String get menuWiki => 'Wiki';
@override
String get menuHelp => 'Help';
@override
String get logs => 'Protokolle';
}

View File

@@ -998,4 +998,28 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get 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.';
@override
String get menuSettings => 'Setting';
@override
String get menuQuit => 'Quit';
@override
String get menuNavigate => 'Navigate';
@override
String get menuInfo => 'Info';
@override
String get menuGitHubRepository => 'GitHub Repository';
@override
String get menuWiki => 'Wiki';
@override
String get menuHelp => 'Help';
@override
String get logs => 'Logs';
}

View File

@@ -1009,4 +1009,28 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get 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.';
@override
String get menuSettings => 'Setting';
@override
String get menuQuit => 'Quit';
@override
String get menuNavigate => 'Navigate';
@override
String get menuInfo => 'Info';
@override
String get menuGitHubRepository => 'GitHub Repository';
@override
String get menuWiki => 'Wiki';
@override
String get menuHelp => 'Help';
@override
String get logs => 'Registros';
}

View File

@@ -1012,4 +1012,28 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get 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.';
@override
String get menuSettings => 'Setting';
@override
String get menuQuit => 'Quit';
@override
String get menuNavigate => 'Navigate';
@override
String get menuInfo => 'Info';
@override
String get menuGitHubRepository => 'GitHub Repository';
@override
String get menuWiki => 'Wiki';
@override
String get menuHelp => 'Help';
@override
String get logs => 'Journaux';
}

View File

@@ -998,4 +998,28 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get 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.';
@override
String get menuSettings => 'Setting';
@override
String get menuQuit => 'Quit';
@override
String get menuNavigate => 'Navigate';
@override
String get menuInfo => 'Info';
@override
String get menuGitHubRepository => 'GitHub Repository';
@override
String get menuWiki => 'Wiki';
@override
String get menuHelp => 'Help';
@override
String get logs => 'Log';
}

View File

@@ -967,5 +967,29 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get writeScriptTip =>
'サーバーに接続すると、システムの状態を監視するためのスクリプトが `~/.config/server_box` \n | `/tmp/server_box` に書き込まれます。スクリプトの内容を確認できます。';
'サーバーへの接続後、システムステータスを監視するスクリプトが `~/.config/server_box` \n | `/tmp/server_box` に書き込まれます。スクリプトの内容を確認できます。';
@override
String get menuSettings => 'Setting';
@override
String get menuQuit => 'Quit';
@override
String get menuNavigate => 'Navigate';
@override
String get menuInfo => 'Info';
@override
String get menuGitHubRepository => 'GitHub Repository';
@override
String get menuWiki => 'Wiki';
@override
String get menuHelp => 'Help';
@override
String get logs => 'ログ';
}

View File

@@ -1005,4 +1005,28 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get 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.';
@override
String get menuSettings => 'Setting';
@override
String get menuQuit => 'Quit';
@override
String get menuNavigate => 'Navigate';
@override
String get menuInfo => 'Info';
@override
String get menuGitHubRepository => 'GitHub Repository';
@override
String get menuWiki => 'Wiki';
@override
String get menuHelp => 'Help';
@override
String get logs => 'Logboeken';
}

View File

@@ -1000,4 +1000,28 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get 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.';
@override
String get menuSettings => 'Setting';
@override
String get menuQuit => 'Quit';
@override
String get menuNavigate => 'Navigate';
@override
String get menuInfo => 'Info';
@override
String get menuGitHubRepository => 'GitHub Repository';
@override
String get menuWiki => 'Wiki';
@override
String get menuHelp => 'Help';
@override
String get logs => 'Logs';
}

View File

@@ -1004,4 +1004,28 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get writeScriptTip =>
'После подключения к серверу скрипт будет записан в `~/.config/server_box` \n | `/tmp/server_box` для мониторинга состояния системы. Вы можете проверить содержимое скрипта.';
@override
String get menuSettings => 'Setting';
@override
String get menuQuit => 'Quit';
@override
String get menuNavigate => 'Navigate';
@override
String get menuInfo => 'Info';
@override
String get menuGitHubRepository => 'GitHub Repository';
@override
String get menuWiki => 'Wiki';
@override
String get menuHelp => 'Help';
@override
String get logs => 'Журналы';
}

View File

@@ -999,4 +999,28 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get 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.';
@override
String get menuSettings => 'Setting';
@override
String get menuQuit => 'Quit';
@override
String get menuNavigate => 'Navigate';
@override
String get menuInfo => 'Info';
@override
String get menuGitHubRepository => 'GitHub Repository';
@override
String get menuWiki => 'Wiki';
@override
String get menuHelp => 'Help';
@override
String get logs => 'Günlükler';
}

View File

@@ -1004,4 +1004,28 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get writeScriptTip =>
'Після підключення до сервера скрипт буде записано у `~/.config/server_box` \n | `/tmp/server_box` для моніторингу стану системи. Ви можете переглянути вміст скрипта.';
@override
String get menuSettings => 'Setting';
@override
String get menuQuit => 'Quit';
@override
String get menuNavigate => 'Navigate';
@override
String get menuInfo => 'Info';
@override
String get menuGitHubRepository => 'GitHub Repository';
@override
String get menuWiki => 'Wiki';
@override
String get menuHelp => 'Help';
@override
String get logs => 'Журнали';
}

View File

@@ -953,6 +953,30 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get writeScriptTip =>
'在连接服务器后,会向 `~/.config/server_box` \n | `/tmp/server_box` 写入脚本来监测系统状态,你可以审查脚本内容。';
@override
String get menuSettings => '设置';
@override
String get menuQuit => '退出';
@override
String get menuNavigate => '导航';
@override
String get menuInfo => '信息';
@override
String get menuGitHubRepository => 'GitHub 仓库';
@override
String get menuWiki => 'Wiki';
@override
String get menuHelp => '帮助';
@override
String get logs => '日志';
}
/// The translations for Chinese, as used in Taiwan (`zh_TW`).
@@ -1904,4 +1928,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get writeScriptTip =>
'連線到伺服器後,將會在 `~/.config/server_box` \n | `/tmp/server_box` 中寫入一個腳本來監測系統狀態。你可以審查腳本內容。';
@override
String get logs => '日誌';
}

View File

@@ -3,7 +3,6 @@ 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';
@@ -20,6 +19,5 @@ import 'package:server_box/data/model/ssh/virtual_key.dart';
AdapterSpec<ServerCustom>(),
AdapterSpec<WakeOnLanCfg>(),
AdapterSpec<SystemType>(),
AdapterSpec<ProxyCommandConfig>(),
])
part 'hive_adapters.g.dart';

View File

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

View File

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

View File

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

View File

@@ -293,5 +293,6 @@
"wolTip": "Nach der Konfiguration von WOL (Wake-on-LAN) wird jedes Mal, wenn der Server verbunden wird, eine WOL-Anfrage gesendet.",
"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."
"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"
}

View File

@@ -296,5 +296,13 @@
"wolTip": "After configuring WOL (Wake-on-LAN), a WOL request is sent each time the server is connected.",
"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."
"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.",
"menuSettings": "Setting",
"menuQuit": "Quit",
"menuNavigate": "Navigate",
"menuInfo": "Info",
"menuGitHubRepository": "GitHub Repository",
"menuWiki": "Wiki",
"menuHelp": "Help",
"logs": "Logs"
}

View File

@@ -293,5 +293,6 @@
"wolTip": "Después de configurar WOL (Wake-on-LAN), se envía una solicitud de WOL cada vez que se conecta el servidor.",
"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."
"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"
}

View File

@@ -293,5 +293,6 @@
"wolTip": "Après avoir configuré le WOL (Wake-on-LAN), une requête WOL est envoyée chaque fois que le serveur est connecté.",
"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."
"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"
}

View File

@@ -293,5 +293,6 @@
"wolTip": "Setelah mengonfigurasi WOL (Wake-on-LAN), permintaan WOL dikirim setiap kali server terhubung.",
"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."
"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"
}

View File

@@ -293,5 +293,6 @@
"wolTip": "WOLWake-on-LANを設定した後、サーバーに接続するたびにWOLリクエストが送信されます。",
"write": "書き込み",
"writeScriptFailTip": "スクリプトの書き込みに失敗しました。権限がないかディレクトリが存在しない可能性があります。",
"writeScriptTip": "サーバーに接続すると、システムの状態を監視するためのスクリプトが `~/.config/server_box` \n | `/tmp/server_box` に書き込まれます。スクリプトの内容を確認できます。"
"writeScriptTip": "サーバーへの接続後、システムステータスを監視するスクリプトが `~/.config/server_box` \n | `/tmp/server_box` に書き込まれます。スクリプトの内容を確認できます。",
"logs": "ログ"
}

View File

@@ -293,5 +293,6 @@
"wolTip": "Na het configureren van WOL (Wake-on-LAN), wordt elke keer dat de server wordt verbonden een WOL-verzoek verzonden.",
"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."
"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"
}

View File

@@ -293,5 +293,6 @@
"wolTip": "Após configurar o WOL (Wake-on-LAN), um pedido de WOL é enviado cada vez que o servidor é conectado.",
"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."
"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"
}

View File

@@ -293,5 +293,6 @@
"wolTip": "После настройки WOL (Wake-on-LAN) при каждом подключении к серверу отправляется запрос WOL.",
"write": "Запись",
"writeScriptFailTip": "Запись скрипта не удалась, возможно, из-за отсутствия прав или потому что, директории не существует.",
"writeScriptTip": "После подключения к серверу скрипт будет записан в `~/.config/server_box` \n | `/tmp/server_box` для мониторинга состояния системы. Вы можете проверить содержимое скрипта."
"writeScriptTip": "После подключения к серверу скрипт будет записан в `~/.config/server_box` \n | `/tmp/server_box` для мониторинга состояния системы. Вы можете проверить содержимое скрипта.",
"logs": "Журналы"
}

View File

@@ -293,5 +293,6 @@
"wolTip": "WOL (Wake-on-LAN) yapılandırıldıktan sonra, sunucuya her bağlanıldığında bir WOL isteği gönderilir.",
"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."
"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"
}

View File

@@ -293,5 +293,6 @@
"wolTip": "Після налаштування WOL (Wake-on-LAN), при кожному підключенні до сервера відправляється запит WOL.",
"write": "Записати",
"writeScriptFailTip": "Запис у скрипт не вдався, можливо, через брак дозволів або каталог не існує.",
"writeScriptTip": "Після підключення до сервера скрипт буде записано у `~/.config/server_box` \n | `/tmp/server_box` для моніторингу стану системи. Ви можете переглянути вміст скрипта."
"writeScriptTip": "Після підключення до сервера скрипт буде записано у `~/.config/server_box` \n | `/tmp/server_box` для моніторингу стану системи. Ви можете переглянути вміст скрипта.",
"logs": "Журнали"
}

View File

@@ -293,5 +293,13 @@
"wolTip": "配置 WOL 后,每次连接服务器时将自动发送唤醒请求",
"write": "写",
"writeScriptFailTip": "写入脚本失败,可能是没有权限/目录不存在等",
"writeScriptTip": "在连接服务器后,会向 `~/.config/server_box` \n | `/tmp/server_box` 写入脚本来监测系统状态,你可以审查脚本内容。"
"writeScriptTip": "在连接服务器后,会向 `~/.config/server_box` \n | `/tmp/server_box` 写入脚本来监测系统状态,你可以审查脚本内容。",
"menuSettings": "设置",
"menuQuit": "退出",
"menuNavigate": "导航",
"menuInfo": "信息",
"menuGitHubRepository": "GitHub 仓库",
"menuWiki": "Wiki",
"menuHelp": "帮助",
"logs": "日志"
}

View File

@@ -293,5 +293,6 @@
"wolTip": "設定 WOL 後,每次連線伺服器時將自動發送喚醒請求",
"write": "寫入",
"writeScriptFailTip": "寫入腳本失敗,可能是沒有權限/目錄不存在等。",
"writeScriptTip": "連線到伺服器後,將會在 `~/.config/server_box` \n | `/tmp/server_box` 中寫入一個腳本來監測系統狀態。你可以審查腳本內容。"
"writeScriptTip": "連線到伺服器後,將會在 `~/.config/server_box` \n | `/tmp/server_box` 中寫入一個腳本來監測系統狀態。你可以審查腳本內容。",
"logs": "日誌"
}

View File

@@ -10,7 +10,6 @@ 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';
@@ -58,9 +57,6 @@ 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();
@@ -74,7 +70,6 @@ void _setupDebug() {
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((record) {
DebugProvider.addLog(record);
lprint(record);
if (record.error != null) print(record.error);
if (record.stackTrace != null) print(record.stackTrace);
});

View File

@@ -219,7 +219,7 @@ extension on _ContainerPageState {
'${switch (_containerState.type) {
ContainerType.podman => 'podman',
ContainerType.docker => 'docker',
}} exec -it ${dItem.id} sh',
}} exec -it ${dItem.id} sh -c "command -v bash && exec bash || command -v ash && exec ash || exec sh"',
);
SSHPage.route.go(context, args);
break;

View File

@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/foundation.dart' show kReleaseMode;
import 'package:flutter/material.dart';
@@ -5,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:server_box/core/chan.dart';
import 'package:server_box/core/sync.dart';
import 'package:server_box/data/model/app/menu/platform.dart';
import 'package:server_box/data/model/app/tab.dart';
import 'package:server_box/data/provider/server/all.dart';
import 'package:server_box/data/res/build_data.dart';
@@ -134,7 +137,7 @@ class _HomePageState extends ConsumerState<HomePage>
super.build(context);
final isMobile = ResponsiveBreakpoints.of(context).isMobile;
return Scaffold(
final Widget mainContent = Scaffold(
appBar: _AppBar(MediaQuery.paddingOf(context).top),
body: Row(
children: [
@@ -157,6 +160,16 @@ class _HomePageState extends ConsumerState<HomePage>
),
bottomNavigationBar: isMobile ? _buildBottomBar() : null,
);
if (Platform.isMacOS) {
return PlatformMenuBar(
menus: MacOSMenuBarManager.buildMenuBar(context, (int index) {
_onDestinationSelected(index);
}),
child: mainContent,
);
}
return mainContent;
}
Widget _buildBottomBar() {

View File

@@ -63,6 +63,9 @@ class _ServerDetailPageState extends ConsumerState<ServerDetailPage> with Single
final _netSortType = ValueNotifier(_NetSortType.device);
late final _collapse = _settings.collapseUIDefault.fetch();
late final _textFactor = TextScaler.linear(_settings.textFactor.fetch());
late final _cpuViewAsProgress = _settings.cpuViewAsProgress.fetch();
late final _moveServerFuncs = _settings.moveServerFuncs.fetch();
late final _displayCpuIndex = _settings.displayCpuIndex.fetch();
@override
void dispose() {
@@ -97,7 +100,7 @@ class _ServerDetailPageState extends ConsumerState<ServerDetailPage> with Single
}
Widget _buildMainPage(ServerState si) {
final buildFuncs = !Stores.setting.moveServerFuncs.fetch();
final buildFuncs = !_moveServerFuncs;
final logo = _buildLogo(si);
final children = <Widget>[if (logo != null) logo, if (buildFuncs) ServerFuncBtns(spi: si.spi)];
for (final card in _cardsOrder) {
@@ -197,7 +200,7 @@ class _ServerDetailPageState extends ConsumerState<ServerDetailPage> with Single
]);
}
final List<Widget> children = Stores.setting.cpuViewAsProgress.fetch()
final List<Widget> children = _cpuViewAsProgress
? _buildCPUProgress(ss.cpu)
: [_buildCPUChart(ss)];
@@ -258,7 +261,7 @@ class _ServerDetailPageState extends ConsumerState<ServerDetailPage> with Single
const kRowThreshold = 4;
const kCoresCountThreshold = kMaxColumn * kRowThreshold;
final children = <Widget>[];
final displayCpuIndexSetting = Stores.setting.displayCpuIndex.fetch();
final displayCpuIndexSetting = _displayCpuIndex;
if (cs.coresCount > kCoresCountThreshold) {
final numCoresToDisplay = cs.coresCount - 1;

View File

@@ -222,6 +222,30 @@ 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,
@@ -265,54 +289,6 @@ 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,
@@ -325,14 +301,14 @@ extension _Actions on _ServerEditPageState {
tags: _tags.value.isEmpty ? null : _tags.value.toList(),
alterUrl: _altUrlController.text.selfNotEmptyOrNull,
autoConnect: _autoConnect.value,
jumpId: _jumpServer.value,
jumpId: null,
jumpChainIds: _jumpChain.value.isEmpty ? null : _jumpChain.value,
custom: custom,
wolCfg: wol,
envs: _env.value.isEmpty ? null : _env.value,
id: widget.args?.spi.id ?? ShortId.generate(),
customSystemType: _systemType.value,
disabledCmdTypes: _disabledCmdTypes.value.isEmpty ? null : _disabledCmdTypes.value.toList(),
proxyCommand: proxyCommand,
);
if (this.spi == null) {
@@ -470,7 +446,7 @@ extension _Utils on _ServerEditPageState {
_altUrlController.text = spi.alterUrl ?? '';
_autoConnect.value = spi.autoConnect;
_jumpServer.value = spi.jumpId;
_jumpChain.value = spi.jumpChainIds ?? (spi.jumpId == null ? const <String>[] : [spi.jumpId!]);
final custom = spi.custom;
if (custom != null) {
@@ -499,34 +475,5 @@ extension _Utils on _ServerEditPageState {
final allAvailableCmdTypes = ShellCmdType.all.map((e) => e.displayName);
disabledCmdTypes.removeWhere((e) => !allAvailableCmdTypes.contains(e));
_disabledCmdTypes.value = disabledCmdTypes;
// Load ProxyCommand configuration
final proxyCommand = spi.proxyCommand;
if (proxyCommand != null && !Platform.isIOS) {
_proxyCommandEnabled.value = true;
_proxyCommandController.text = proxyCommand.command;
_proxyCommandTimeout.value = proxyCommand.timeout.inSeconds;
// Try to match with a preset
final presets = ProxyCommandExecutor.getPresets();
for (final entry in presets.entries) {
if (entry.value.command == proxyCommand.command) {
_proxyCommandPreset.value = entry.key;
break;
}
}
} else {
_proxyCommandEnabled.value = false;
_proxyCommandController.text = '';
_proxyCommandTimeout.value = 30;
_proxyCommandPreset.value = null;
}
if (Platform.isIOS) {
_proxyCommandEnabled.value = false;
_proxyCommandController.text = '';
_proxyCommandTimeout.value = 30;
_proxyCommandPreset.value = null;
}
}
}

View File

@@ -7,16 +7,13 @@ 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';
@@ -28,6 +25,7 @@ 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 {
@@ -69,7 +67,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 _jumpServer = nvn<String?>();
final _jumpChain = <String>[].vn;
final _pveIgnoreCert = ValueNotifier(false);
final _env = <String, String>{}.vn;
final _customCmds = <String, String>{}.vn;
@@ -77,12 +75,6 @@ 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();
@@ -109,18 +101,13 @@ class _ServerEditPageState extends ConsumerState<ServerEditPage> with AfterLayou
_keyIdx.dispose();
_autoConnect.dispose();
_jumpServer.dispose();
_jumpChain.dispose();
_pveIgnoreCert.dispose();
_env.dispose();
_customCmds.dispose();
_tags.dispose();
_systemType.dispose();
_disabledCmdTypes.dispose();
_proxyCommandEnabled.dispose();
_proxyCommandController.dispose();
_proxyCommandPreset.dispose();
_proxyCommandTimeout.dispose();
}
@override
@@ -213,8 +200,6 @@ class _ServerEditPageState extends ConsumerState<ServerEditPage> with AfterLayou
),
_buildAuth(),
_buildSystemType(),
_buildJumpServer(),
_buildProxyCommand(),
_buildMore(),
];
return AutoMultiList(children: children);

View File

@@ -0,0 +1,176 @@
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,6 +132,7 @@ extension _Widgets on _ServerEditPageState {
return ExpandTile(
title: Text(l10n.more),
children: [
_buildJumpChain(),
Input(
controller: _logoUrlCtrl,
type: TextInputType.url,
@@ -347,48 +348,6 @@ 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(
@@ -449,123 +408,6 @@ 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: () {
@@ -589,21 +431,4 @@ extension _Widgets on _ServerEditPageState {
icon: const Icon(Icons.delete),
);
}
String _getPresetDisplayName(String presetKey) {
switch (presetKey) {
case 'cloudflare_access':
return 'Cloudflare Access';
case 'ssh_via_bastion':
return 'SSH via Bastion';
case 'nc_netcat':
return 'Netcat';
case 'socat':
return 'Socat';
default:
return presetKey.split('_').map((word) =>
word[0].toUpperCase() + word.substring(1)
).join(' ');
}
}
}

View File

@@ -346,6 +346,6 @@ class _ServerPageState extends ConsumerState<ServerPage>
static const _kCardHeightMin = 23.0;
static const _kCardHeightFlip = 99.0;
static const _kCardHeightNormal = 108.0;
static const _kCardHeightNormal = 110.0;
static const _kCardHeightMoveOutFuncs = 135.0;
}

View File

@@ -94,19 +94,33 @@ extension _App on _AppSettingsPageState {
}),
onTap: () {
withTextFieldController((ctrl) async {
ctrl.text = Color(_setting.colorSeed.fetch()).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(() {}),
),
DynamicColorBuilder(
builder: (light, dark) {
final supported = light != null || dark != null;
if (!supported) {
if (!_setting.useSystemPrimaryColor.fetch()) {
_setting.useSystemPrimaryColor.put(false);
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {});
});
}
return const SizedBox.shrink();
}
return ListTile(
title: Text(l10n.followSystem),
trailing: StoreSwitch(
prop: _setting.useSystemPrimaryColor,
callback: (_) => setState(() {}),
),
);
},
),
];
if (!_setting.useSystemPrimaryColor.fetch()) {
@@ -129,12 +143,22 @@ extension _App on _AppSettingsPageState {
void _onSaveColor(String s) {
final color = s.fromColorHex;
if (color == null) {
context.showSnackBar(libL10n.fail);
return;
}
UIs.colorSeed = color;
// Save the color seed to settings
_setting.colorSeed.put(color.value255);
// Only update UIs colors if we're not in system mode
if (!_setting.useSystemPrimaryColor.fetch()) {
UIs.primaryColor = color;
UIs.colorSeed = color;
}
RNodes.app.notify();
context.pop();
}

View File

@@ -1,6 +1,7 @@
import 'dart:convert';
import 'dart:io';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter_highlight/theme_map.dart';
@@ -28,6 +29,7 @@ import 'package:server_box/view/page/setting/seq/srv_seq.dart';
import 'package:server_box/view/page/setting/seq/virt_key.dart';
part 'about.dart';
part 'entries/ai.dart';
part 'entries/app.dart';
part 'entries/container.dart';
part 'entries/editor.dart';
@@ -35,7 +37,6 @@ 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;
@@ -71,9 +72,9 @@ class _SettingsPageState extends ConsumerState<SettingsPage> with SingleTickerPr
),
actions: [
Btn.text(
text: 'Logs',
text: context.l10n.logs,
onTap: () =>
DebugPage.route.go(context, args: const DebugPageArgs(title: 'Logs(${BuildData.build})')),
DebugPage.route.go(context, args: DebugPageArgs(title: '${context.l10n.logs}(${BuildData.build})')),
),
Btn.icon(
icon: const Icon(Icons.delete),

View File

@@ -62,6 +62,7 @@ class _SnippetListPageState extends ConsumerState<SnippetListPage> with Automati
tags: snippetState.tags.vn,
onTagChanged: (tag) => _tag.value = tag,
initTag: _tag.value,
singleLine: true,
),
body: _buildSnippetList(snippets, tag),
floatingActionButton: FloatingActionButton(

View File

@@ -84,6 +84,7 @@ class _AskAiSheetState extends ConsumerState<_AskAiSheet> {
String? _streamingContent;
String? _error;
bool _isStreaming = false;
bool _isMinimized = false;
@override
void initState() {
@@ -250,6 +251,13 @@ class _AskAiSheetState extends ConsumerState<_AskAiSheet> {
context.showSnackBar(libL10n.success);
}
Future<void> _copyText(BuildContext context, String text) async {
if (text.trim().isEmpty) return;
await Clipboard.setData(ClipboardData(text: text));
if (!mounted) return;
context.showSnackBar(libL10n.success);
}
void _sendMessage() {
if (_isStreaming) return;
final text = _inputController.text.trim();
@@ -310,7 +318,23 @@ class _AskAiSheetState extends ConsumerState<_AskAiSheet> {
streaming ? l10n.askAiAwaitingResponse : l10n.askAiNoResponse,
style: theme.textTheme.bodySmall,
)
: SimpleMarkdown(data: content);
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SimpleMarkdown(data: content),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: () => _copyText(context, content),
icon: const Icon(Icons.copy, size: 18),
label: Text(libL10n.copy),
),
],
),
],
);
return Align(
alignment: Alignment.centerLeft,
child: CardX(
@@ -364,12 +388,23 @@ class _AskAiSheetState extends ConsumerState<_AskAiSheet> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bottomPadding = MediaQuery.viewInsetsOf(context).bottom;
final heightFactor = _isMinimized ? 0.18 : 0.85;
return FractionallySizedBox(
heightFactor: 0.85,
return TweenAnimationBuilder<double>(
tween: Tween<double>(end: heightFactor),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
builder: (context, animatedHeightFactor, child) {
return ClipRect(
child: FractionallySizedBox(
heightFactor: animatedHeightFactor,
child: child,
),
);
},
child: SafeArea(
child: Column(
children: [
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Row(
@@ -379,83 +414,96 @@ class _AskAiSheetState extends ConsumerState<_AskAiSheet> {
if (_isStreaming)
const SizedBox(height: 16, width: 16, child: CircularProgressIndicator(strokeWidth: 2)),
const Spacer(),
IconButton(
icon: Icon(_isMinimized ? Icons.unfold_more : Icons.unfold_less),
tooltip: libL10n.fold,
onPressed: () {
FocusManager.instance.primaryFocus?.unfocus();
setState(() {
_isMinimized = !_isMinimized;
});
},
),
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop()),
],
),
),
Expanded(
child: Scrollbar(
controller: _scrollController,
child: ListView(
if (!_isMinimized) ...[
Expanded(
child: Scrollbar(
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),
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: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
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),
],
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),
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,
),
] else
const SizedBox(height: 8),
],
).cardx,
),
),
],
),
),
);
}
}

View File

@@ -252,7 +252,7 @@ class SSHPageState extends ConsumerState<SSHPage>
deleteDetection: isMobile,
autofocus: false,
keyboardAppearance: _isDark ? Brightness.dark : Brightness.light,
showToolbar: isMobile,
showToolbar: true,
viewOffset: Offset(2 * _horizonPadding, CustomAppBar.sysStatusBarHeight),
hideScrollBar: false,
focusNode: widget.args.focusNode,

View File

@@ -123,7 +123,7 @@ class _LocalFilePageState extends ConsumerState<LocalFilePage> with AutomaticKee
final item = items![index];
final file = item.$1;
final fileName = file.path.split('/').last;
final fileName = file.path.split(Pfs.seperator).last;
final stat = item.$2;
final isDir = stat.type == FileSystemEntityType.directory;
@@ -140,11 +140,23 @@ class _LocalFilePageState extends ConsumerState<LocalFilePage> with AutomaticKee
required FileStat stat,
required bool isDir,
}) {
final isServerFolder = isDir && file.parent.path == Paths.file;
String? serverName;
if (isServerFolder) {
final servers = ref.read(serversProvider).servers;
final server = servers[fileName];
if (server != null) {
serverName = server.name;
}
}
return CardX(
child: ListTile(
leading: isDir ? const Icon(Icons.folder_open) : const Icon(Icons.insert_drive_file),
title: Text(fileName),
subtitle: isDir ? null : Text(stat.size.bytes2Str, style: UIs.textGrey),
title: Text(serverName ?? fileName),
subtitle: isDir
? (serverName != null ? Text(fileName, style: UIs.textGrey) : null)
: Text(stat.size.bytes2Str, style: UIs.textGrey),
trailing: Text(stat.modified.ymdhms(), style: UIs.textGrey),
onLongPress: () {
if (isDir) {
@@ -216,7 +228,7 @@ extension _Actions on _LocalFilePageState {
}
Future<void> _showFileActionDialog(FileSystemEntity file) async {
final fileName = file.path.split('/').lastOrNull ?? '';
final fileName = file.path.split(Pfs.seperator).lastOrNull ?? '';
if (isPickFile) {
context.showRoundDialog(
title: libL10n.file,
@@ -308,7 +320,7 @@ extension _Actions on _LocalFilePageState {
}
void _showDeleteDialog(FileSystemEntity file) {
final fileName = file.path.split('/').last;
final fileName = file.path.split(Pfs.seperator).last;
context.showRoundDialog(
title: libL10n.delete,
child: Text(libL10n.askContinue('${libL10n.delete} $fileName')),

View File

@@ -177,32 +177,56 @@ extension _UI on _SftpPageState {
Widget _buildItem(SftpName file, {VoidCallback? beforeTap}) {
final isDir = file.attr.isDirectory;
final trailing = Text(
'${_getTime(file.attr.modifyTime)}\n${file.attr.mode?.str ?? ''}',
style: UIs.textGrey,
textAlign: TextAlign.right,
);
return CardX(
child: ListTile(
leading: Icon(isDir ? Icons.folder_outlined : Icons.insert_drive_file),
title: Text(file.filename),
trailing: trailing,
subtitle: isDir ? null : Text((file.attr.size ?? 0).bytes2Str, style: UIs.textGrey),
onTap: () {
beforeTap?.call();
if (isDir) {
_status.path.path = file.filename;
_listDir();
} else {
_onItemPress(file, true);
}
},
onLongPress: () {
beforeTap?.call();
_onItemPress(file, !isDir);
},
),
);
final double screenWidth = MediaQuery.sizeOf(context).width;
if (screenWidth < 350) {
return CardX(
child: ListTile(
leading: Icon(isDir ? Icons.folder_outlined : Icons.insert_drive_file),
title: Text(file.filename),
subtitle: isDir ? Text('${_getTime(file.attr.modifyTime)}\n${file.attr.mode?.str ?? ''}', style: UIs.textGrey) :
Text('${(file.attr.size ?? 0).bytes2Str}\n${_getTime(file.attr.modifyTime)}\n${file.attr.mode?.str ?? ''}', style: UIs.textGrey),
onTap: () {
beforeTap?.call();
if (isDir) {
_status.path.path = file.filename;
_listDir();
} else {
_onItemPress(file, true);
}
},
onLongPress: () {
beforeTap?.call();
_onItemPress(file, !isDir);
},
),
);
} else {
return CardX(
child: ListTile(
leading: Icon(isDir ? Icons.folder_outlined : Icons.insert_drive_file),
title: Text(file.filename),
trailing: Text(
'${_getTime(file.attr.modifyTime)}\n${file.attr.mode?.str ?? ''}',
style: UIs.textGrey,
textAlign: TextAlign.right,
),
subtitle: isDir ? null : Text((file.attr.size ?? 0).bytes2Str, style: UIs.textGrey),
onTap: () {
beforeTap?.call();
if (isDir) {
_status.path.path = file.filename;
_listDir();
} else {
_onItemPress(file, true);
}
},
onLongPress: () {
beforeTap?.call();
_onItemPress(file, !isDir);
},
),
);
}
}
}
@@ -561,7 +585,11 @@ extension _Actions on _SftpPageState {
/// Local file dir + server id + remote path
String _getLocalPath(String remotePath) {
return Paths.file.joinPath(widget.args.spi.oldId).joinPath(remotePath);
var normalizedPath = remotePath.replaceAll('/', Pfs.seperator);
if (normalizedPath.startsWith(Pfs.seperator)) {
normalizedPath = normalizedPath.substring(1);
}
return Paths.file.joinPath(widget.args.spi.id).joinPath(normalizedPath);
}
/// Only return true if the path is changed

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.sshConnectted) => _buildConnected(status),
const (SftpWorkerStatus.sshConnected) => _buildConnected(status),
const (SftpWorkerStatus.preparing) => _buildPreparing(status),
_ => _buildDefault(status),
};

View File

@@ -88,14 +88,14 @@ SPEC CHECKSUMS:
flutter_secure_storage_macos: 7f45e30f838cf2659862a4e4e3ee1c347c2b3b54
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
icloud_storage: eb5b0f20687cf5a4fabc0b541f3b079cd6df7dcb
local_auth_darwin: d2e8c53ef0c4f43c646462e3415432c4dab3ae19
local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673
wakelock_plus: 21ddc249ac4b8d018838dbdabd65c5976c308497
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b
window_manager: b729e31d38fb04905235df9ea896128991cad99e
PODFILE CHECKSUM: 8cdf29216ea1ab6b9743188287968d22b4579c1d

Some files were not shown because too many files have changed in this diff Show More