Compare commits

...

85 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
Korb
ffda27d057 add: fdroid Russian metadata translation (#947)
* Create ru/short_description.txt

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

* new(ask_ai): settings

* fix: app hot reload

* new: l10n

* chore: deps.

* opt.
2025-10-18 01:15:43 +08:00
lollipopkit🏳️‍⚧️
860c11d4a8 bump: v1262 2025-10-10 09:18:38 +08:00
lollipopkit🏳️‍⚧️
bd949288ed fix: code editor tool bar (#933) 2025-10-10 09:14:41 +08:00
lollipopkit🏳️‍⚧️
bb3e3b4848 opt.: no Tag Switcher on desktop (#932) 2025-10-08 21:21:23 +08:00
lollipopkit🏳️‍⚧️
3307fca620 fix: cant sort servers order (#930) 2025-10-08 17:35:07 +08:00
lollipopkit🏳️‍⚧️
da8517bcf7 migrate: riverpod 3 2025-10-08 17:03:13 +08:00
lollipopkit🏳️‍⚧️
f68c4a851b feat: discover local ssh server (#921) 2025-09-19 23:29:01 +08:00
lollipopkit🏳️‍⚧️
17db393c12 bump: v1256 2025-09-15 02:35:42 +08:00
lollipopkit🏳️‍⚧️
275581cfa3 fix: notification permission (#914) 2025-09-14 22:34:01 +08:00
lollipopkit🏳️‍⚧️
d7168ea1ff fix: version code err caused by Flutter (#913) 2025-09-14 14:23:17 +08:00
lollipopkit🏳️‍⚧️
fd2bf08f78 bump: v1253 2025-09-09 13:32:17 +08:00
lollipopkit🏳️‍⚧️
98e13c39cf fix: android channel invoke 2025-09-09 13:30:37 +08:00
lollipopkit🏳️‍⚧️
e70abeef04 bump: v1251 2025-09-09 13:14:01 +08:00
lollipopkit🏳️‍⚧️
194774d6fb opt.: system detect logic to avoid creating useless file (#905) 2025-09-09 13:10:40 +08:00
lollipopkit🏳️‍⚧️
640d61bab9 fix: holding Backspace doesnt work on desktop (#903) 2025-09-08 14:06:35 +08:00
lollipopkit🏳️‍⚧️
7f4cf22cc9 fix: rm camera perm on mac 2025-09-08 12:37:30 +08:00
lollipopkit🏳️‍⚧️
05a927753f feat: stop all servers in noti center (#901) 2025-09-06 14:04:53 +08:00
lollipopkit🏳️‍⚧️
0c7b72fb2c bump: v1246 2025-09-05 12:31:33 +08:00
lollipopkit🏳️‍⚧️
a869b97502 fix: server stat l10n 2025-09-05 00:24:18 +08:00
lollipopkit🏳️‍⚧️
eadd343205 readd: home drawer
Fixes #900
2025-09-05 00:12:41 +08:00
lollipopkit🏳️‍⚧️
1bac986fe0 bug: single server providers should be keepalived (#899) 2025-09-04 23:50:00 +08:00
lollipopkit🏳️‍⚧️
a94be6c2c3 fix: macOS appstore rejection (#893) 2025-09-03 22:19:04 +08:00
lollipopkit🏳️‍⚧️
fc8e9b4bb1 bump: v1241 2025-09-03 09:24:33 +08:00
lollipopkit🏳️‍⚧️
ec4b633889 fix: watchOS app cfg (#890) 2025-09-03 01:41:08 +08:00
lollipopkit🏳️‍⚧️
e51804fa70 new: custom tabs (#889) 2025-09-03 01:05:03 +08:00
lollipopkit🏳️‍⚧️
2466341999 feat: server conn statistics (#888) 2025-09-02 19:41:56 +08:00
lollipopkit🏳️‍⚧️
929061213f refactor: docker status parsing (#886) 2025-09-02 13:22:54 +08:00
lollipopkit🏳️‍⚧️
6b52679942 fix: resolve Docker interface blank issue caused by LateInitializationError (#884) 2025-09-02 12:44:05 +08:00
lollipopkit🏳️‍⚧️
efc0315c93 new: CLAUDE.md 2025-09-01 23:32:44 +08:00
lollipopkit🏳️‍⚧️
8e4c2a7cde fix: fallback to df on incompatible system (#880) 2025-09-01 23:32:20 +08:00
lollipopkit🏳️‍⚧️
4ec7f5895e fix: imported servers from ssh config are the same (#882) 2025-09-01 23:06:58 +08:00
lollipopkit🏳️‍⚧️
ee22cdb55f fix: private key can't be selected in edit page (#879) 2025-09-01 13:05:54 +08:00
lollipopkit🏳️‍⚧️
b1b0d9a18f bump: v1231 2025-09-01 01:19:23 +08:00
lollipopkit🏳️‍⚧️
56e67f4725 fix: sync will refresh the entire app (#877) 2025-09-01 01:18:06 +08:00
lollipopkit🏳️‍⚧️
3b7fdf36fb opt. 2025-08-31 23:59:53 +08:00
lollipopkit🏳️‍⚧️
5291d316a2 fix: ensure unique IDs for bulk server import to prevent overwriting (#875) 2025-08-31 21:20:27 +08:00
lollipopkit🏳️‍⚧️
4c369546da fix: replace String.fromCharCodes with utf8.decode for proper Chinese character handling in JSON import (#874) 2025-08-31 20:06:47 +08:00
lollipopkit🏳️‍⚧️
12a243d139 feat: import servers from ~/.ssh/config (#873) 2025-08-31 19:33:29 +08:00
lollipopkit🏳️‍⚧️
a97b3cf43e opt.: bak pwd is optional (#872) 2025-08-31 11:11:47 +08:00
lollipopkit🏳️‍⚧️
53a7c0d8ff migrate: riverpod + freezed (#870) 2025-08-31 00:55:54 +08:00
lollipopkit🏳️‍⚧️
9cb705f8dd fix: parsing hostname (#865) 2025-08-22 09:18:21 +08:00
lollipopkit🏳️‍⚧️
8270674b7d chore: tests 2025-08-22 00:25:26 +08:00
lxdklp
24fd4b782d fix: GBK decoding fallback (#863)
* fix #757

* fix #757

* apply the code recommendations from sourcery ai

* Make sure raw is non-empty data

* Modified the way to judge gbk, fixed the problem that null cannot throw an error
2025-08-21 23:28:06 +08:00
198 changed files with 20882 additions and 4385 deletions

View File

@@ -16,18 +16,17 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
with:
fetch-depth: 1
- uses: subosito/flutter-action@v2 - uses: subosito/flutter-action@v2
with: with:
channel: 'stable' # or: 'beta', 'dev' or 'master' channel: 'stable'
- name: Install dependencies - name: Install dependencies
run: flutter pub get run: flutter pub get
# Uncomment this step to verify the use of 'dart format' on each commit.
- name: Verify formatting
run: dart format --output=none .
# Consider passing '--fatal-infos' for slightly stricter analysis. # Consider passing '--fatal-infos' for slightly stricter analysis.
- name: Analyze project source - name: Analyze project source
run: dart analyze run: dart analyze

View File

@@ -9,18 +9,23 @@ on:
permissions: permissions:
contents: write contents: write
# Set by fl_build
# env:
# APP_NAME: ServerBox
# BUILD_NUMBER: ${{ github.ref_name }}
jobs: jobs:
releaseAndroid: releaseAndroid:
name: Release android name: Release android
runs-on: ubuntu-22.04 runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Install Flutter - name: Install Flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
with: with:
channel: "stable" channel: "stable"
flutter-version: "3.35.1" flutter-version: "3.38.0"
- uses: actions/setup-java@v4 - uses: actions/setup-java@v4
with: with:
distribution: "zulu" distribution: "zulu"
@@ -48,10 +53,10 @@ jobs:
releaseLinux: releaseLinux:
name: Release linux name: Release linux
runs-on: ubuntu-22.04 runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Install Flutter - name: Install Flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
- name: Install dependencies - name: Install dependencies
@@ -77,7 +82,7 @@ jobs:
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Install Flutter - name: Install Flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
- name: Build - name: Build
@@ -95,19 +100,15 @@ jobs:
# runs-on: macos-latest # runs-on: macos-latest
# steps: # steps:
# - name: Checkout # - name: Checkout
# uses: actions/checkout@v4 # uses: actions/checkout@v6
# - name: Install Flutter # - name: Install Flutter
# uses: subosito/flutter-action@v2 # uses: subosito/flutter-action@v2
# with:
# channel: 'stable'
# flutter-version: '3.32.1'
# - name: Build # - name: Build
# run: dart run fl_build -p ios,mac # run: dart run fl_build -p ios
# - name: Create Release # - name: Create Release
# uses: softprops/action-gh-release@v2 # uses: softprops/action-gh-release@v2
# with: # with:
# files: | # files: |
# ${{ env.APP_NAME }}_universal_macos.zip
# ${{ env.APP_NAME }}_universal.ipa # ${{ env.APP_NAME }}_universal.ipa
# env: # env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

95
CLAUDE.md Normal file
View File

@@ -0,0 +1,95 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
### Development
- `flutter run` - Run the app in development mode
- `dart run fl_build -p PLATFORM` - Build the app for specific platform (see fl_build package)
- `dart run build_runner build --delete-conflicting-outputs` - Generate code for models with annotations (json_serializable, freezed, hive, riverpod)
- Every time you change model files, run this command to regenerate code (Hive adapters, Riverpod providers, etc.)
- Generated files include: `*.g.dart`, `*.freezed.dart` files
### Testing
- `flutter test` - Run unit tests
- `flutter test test/battery_test.dart` - Run specific test file
## Architecture
This is a Flutter application for managing Linux servers with the following key architectural components:
### Project Structure
- `lib/core/` - Core utilities, extensions, and routing
- `lib/data/` - Data layer with models, providers, and storage
- `model/` - Data models organized by feature (server, container, ssh, etc.)
- `provider/` - Riverpod providers for state management
- `store/` - Local storage implementations using Hive
- `lib/view/` - UI layer with pages and widgets
- `lib/generated/` - Generated localization files
- `lib/hive/` - Hive adapters for local storage
### Key Technologies
- **State Management**: Riverpod with code generation (riverpod_annotation)
- **Local Storage**: Hive for persistent data with generated adapters
- **SSH/SFTP**: Custom dartssh2 fork for server connections
- **Terminal**: Custom xterm.dart fork for SSH terminal interface
- **Networking**: dio for HTTP requests
- **Charts**: fl_chart for server status visualization
- **Localization**: Flutter's built-in i18n with ARB files
- **Code Generation**: Uses build_runner with json_serializable, freezed, hive_generator, riverpod_generator
### Data Models
- Server management models in `lib/data/model/server/`
- Container/Docker models in `lib/data/model/container/`
- SSH and SFTP models in respective directories
- Most models use freezed for immutability and json_annotation for serialization
### Features
- Server status monitoring (CPU, memory, disk, network)
- SSH terminal with virtual keyboard
- SFTP file browser
- Docker container management
- Process and systemd service management
- Server snippets and custom commands
- Multi-language support (12+ languages)
- Cross-platform support (iOS, Android, macOS, Linux, Windows)
### State Management Pattern
- Uses Riverpod providers for dependency injection and state management
- Uses Freezed for immutable state models
- Providers are organized by feature in `lib/data/provider/`
- State is often persisted using Hive stores in `lib/data/store/`
### Build System
- Uses custom `fl_build` package for cross-platform building
- `make.dart` script handles pre/post build tasks (metadata generation)
- Supports building for multiple platforms with platform-specific configurations
- Many dependencies are custom forks hosted on GitHub (dartssh2, xterm, fl_lib, etc.)
### Important Notes
- **Never run code formatting commands** - The codebase has specific formatting that should not be changed
- **Always run code generation** after modifying models with annotations (freezed, json_serializable, hive, riverpod)
- Generated files (`*.g.dart`, `*.freezed.dart`) should not be manually edited
- AGAIN, NEVER run code formatting commands.
- USE dependency injection via GetIt for services like Stores, Services and etc.
- Generate all l10n files using `flutter gen-l10n` command after modifying ARB files.
- USE `hive_ce` not `hive` package for Hive integration.
- Which no need to config `HiveField` and `HiveType` manually.
- USE widgets and utilities from `fl_lib` package for common functionalities.
- Such as `CustomAppBar`, `context.showRoundDialog`, `Input`, `Btnx.cancelOk`, etc.
- You can use context7 MCP to search `lppcg fl_lib KEYWORD` to find relevant widgets and utilities.
- USE `libL10n` and `l10n` for localization strings.
- `libL10n` is from `fl_lib` package, and `l10n` is from this project.
- Before adding new strings, check if it already exists in `libL10n`.
- Prioritize using strings from `libL10n` to avoid duplication, even if the meaning is not 100% exact, just use the substitution of `libL10n`.
- Split UI into Widget build, Actions, Utils. use `extension on` to achieve this

143
LICENSE
View File

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

View File

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

View File

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

View File

@@ -113,7 +113,7 @@ android.applicationVariants.all { variant ->
variant.outputs.each { output -> variant.outputs.each { output ->
def abiVersionCode = project.ext.abiCodes.get(output.getFilter(OutputFile.ABI)) def abiVersionCode = project.ext.abiCodes.get(output.getFilter(OutputFile.ABI))
if (abiVersionCode != null) { if (abiVersionCode != null) {
output.versionCodeOverride = variant.versionCode * 10 + abiVersionCode output.versionCodeOverride = variant.versionCode * 100 + abiVersionCode
} }
} }
} }

View File

@@ -2,6 +2,8 @@ package tech.lolli.toolbox
import android.app.* import android.app.*
import android.content.Intent import android.content.Intent
import android.content.pm.ServiceInfo
import android.graphics.drawable.Icon
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.util.Log import android.util.Log
@@ -16,8 +18,7 @@ class ForegroundService : Service() {
var isRunning: Boolean = false var isRunning: Boolean = false
} }
private val chanId = "ForegroundServiceChannel" private val chanId = "ForegroundServiceChannel"
private val GROUP_KEY = "ssh_sessions_group" private val NOTIFICATION_ID = 1000
private val SUMMARY_ID = 1000
private val ACTION_STOP_FOREGROUND = "ACTION_STOP_FOREGROUND" private val ACTION_STOP_FOREGROUND = "ACTION_STOP_FOREGROUND"
private val ACTION_UPDATE_SESSIONS = "tech.lolli.toolbox.ACTION_UPDATE_SESSIONS" private val ACTION_UPDATE_SESSIONS = "tech.lolli.toolbox.ACTION_UPDATE_SESSIONS"
private val ACTION_DISCONNECT_SESSION = "tech.lolli.toolbox.ACTION_DISCONNECT_SESSION" private val ACTION_DISCONNECT_SESSION = "tech.lolli.toolbox.ACTION_DISCONNECT_SESSION"
@@ -49,19 +50,22 @@ class ForegroundService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
try { try {
// Check notification permission for Android 13+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
androidx.core.content.ContextCompat.checkSelfPermission( androidx.core.content.ContextCompat.checkSelfPermission(
this, android.Manifest.permission.POST_NOTIFICATIONS this, android.Manifest.permission.POST_NOTIFICATIONS
) != android.content.pm.PackageManager.PERMISSION_GRANTED ) != android.content.pm.PackageManager.PERMISSION_GRANTED
) { ) {
Log.w("ForegroundService", "Notification permission denied. Stopping service.") Log.w("ForegroundService", "Notification permission denied. Stopping service gracefully.")
stopForegroundService() // Don't call stopForegroundService() here as we haven't started foreground yet
stopSelf()
return START_NOT_STICKY return START_NOT_STICKY
} }
if (intent == null) { if (intent == null) {
Log.w("ForegroundService", "onStartCommand called with null intent") Log.w("ForegroundService", "onStartCommand called with null intent")
stopForegroundService() // Don't call stopForegroundService() here as we haven't started foreground yet
stopSelf()
return START_NOT_STICKY return START_NOT_STICKY
} }
@@ -70,6 +74,9 @@ class ForegroundService : Service() {
return when (action) { return when (action) {
ACTION_STOP_FOREGROUND -> { ACTION_STOP_FOREGROUND -> {
// Notify Flutter to stop all connections before stopping service
val stopAllIntent = Intent("tech.lolli.toolbox.STOP_ALL_CONNECTIONS")
sendBroadcast(stopAllIntent)
clearAll() clearAll()
stopForegroundService() stopForegroundService()
START_NOT_STICKY START_NOT_STICKY
@@ -81,7 +88,7 @@ class ForegroundService : Service() {
} }
else -> { else -> {
// Default bring up foreground with placeholder // Default bring up foreground with placeholder
ensureForeground(createSummaryNotification(0, emptyList())) ensureForeground(createMergedNotification(0, emptyList(), emptyList()))
START_STICKY START_STICKY
} }
} }
@@ -99,37 +106,67 @@ class ForegroundService : Service() {
private fun createNotificationChannel() { private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = getSystemService(NotificationManager::class.java) try {
if (manager == null) { val manager = getSystemService(NotificationManager::class.java)
Log.e("ForegroundService", "Failed to get NotificationManager") if (manager == null) {
return Log.e("ForegroundService", "Failed to get NotificationManager")
return
}
val serviceChannel = NotificationChannel(
chanId,
"ForegroundServiceChannel",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "For foreground service"
}
manager.createNotificationChannel(serviceChannel)
Log.d("ForegroundService", "Notification channel created successfully")
} catch (e: Exception) {
logError("Failed to create notification channel", e)
} }
val serviceChannel = NotificationChannel(
chanId,
"ForegroundServiceChannel",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "For foreground service"
}
manager.createNotificationChannel(serviceChannel)
} }
} }
private fun ensureForeground(notification: Notification) { private fun ensureForeground(notification: Notification) {
try { try {
// Double-check notification permission before starting foreground service
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
androidx.core.content.ContextCompat.checkSelfPermission(
this, android.Manifest.permission.POST_NOTIFICATIONS
) != android.content.pm.PackageManager.PERMISSION_GRANTED
) {
Log.w("ForegroundService", "Cannot start foreground service without notification permission")
stopSelf()
return
}
if (!isFgStarted) { if (!isFgStarted) {
startForeground(SUMMARY_ID, notification) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(NOTIFICATION_ID, notification)
}
isFgStarted = true isFgStarted = true
Log.d("ForegroundService", "Foreground service started successfully")
} else { } else {
val nm = getSystemService(NotificationManager::class.java) val nm = getSystemService(NotificationManager::class.java)
nm?.notify(SUMMARY_ID, notification) if (nm != null) {
nm.notify(NOTIFICATION_ID, notification)
} else {
Log.w("ForegroundService", "NotificationManager is null, cannot update notification")
}
} }
} catch (e: SecurityException) {
logError("Security exception when starting foreground service (likely missing permission)", e)
stopSelf()
} catch (e: Exception) { } catch (e: Exception) {
logError("Failed to start/update foreground", e) logError("Failed to start/update foreground", e)
// Don't stop the service for other exceptions, just log them
} }
} }
private fun createSummaryNotification(count: Int, lines: List<String>): Notification {
private fun createMergedNotification(count: Int, lines: List<String>, sessions: List<SessionItem>): Notification {
val notificationIntent = Intent(this, MainActivity::class.java) val notificationIntent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity( val pendingIntent = PendingIntent.getActivity(
this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE
@@ -140,24 +177,66 @@ class ForegroundService : Service() {
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, chanId) Notification.Builder(this, chanId)
} else { } else {
@Suppress("DEPRECATION")
Notification.Builder(this) Notification.Builder(this)
} }
val inbox = Notification.InboxStyle() // Use the earliest session's start time for chronometer
lines.forEach { inbox.addLine(it) } val earliestStartTime = sessions.minOfOrNull { it.startWhen } ?: System.currentTimeMillis()
return builder val title = when (count) {
.setContentTitle("SSH sessions: $count active") 0 -> "Server Box"
.setContentText(if (lines.isNotEmpty()) lines.first() else "Running") 1 -> sessions.first().title
else -> "SSH sessions: $count active"
}
val contentText = when (count) {
0 -> "Ready for connections"
1 -> {
val session = sessions.first()
"${session.subtitle} · ${session.status}"
}
else -> "Multiple SSH connections active"
}
// For multiple sessions, show details in expanded view
val style = if (count > 1) {
val inbox = Notification.InboxStyle()
val maxLines = 5
val displayLines = if (lines.size > maxLines) {
lines.take(maxLines) + "...and ${lines.size - maxLines} more"
} else {
lines
}
displayLines.forEach { inbox.addLine(it) }
inbox.setBigContentTitle(title)
inbox
} else {
null
}
val notification = builder
.setContentTitle(title)
.setContentText(contentText)
.setSmallIcon(R.mipmap.ic_launcher) .setSmallIcon(R.mipmap.ic_launcher)
.setStyle(inbox) .setWhen(earliestStartTime)
.setUsesChronometer(true)
.setOngoing(true) .setOngoing(true)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setGroup(GROUP_KEY)
.setGroupSummary(true)
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.addAction(android.R.drawable.ic_delete, "Stop", stopPending) .addAction(
.build() Notification.Action.Builder(
Icon.createWithResource(this, android.R.drawable.ic_delete),
"Stop All",
stopPending
).build()
)
if (style != null) {
notification.setStyle(style)
}
return notification.build()
} }
private fun handleUpdateSessions(payload: String) { private fun handleUpdateSessions(payload: String) {
@@ -192,71 +271,21 @@ class ForegroundService : Service() {
return return
} }
// Build per-session notifications // Cancel any existing individual notifications (we only show merged notification now)
val currentIds = mutableSetOf<Int>() val toCancel = postedIds.toSet()
val summaryLines = mutableListOf<String>()
sessions.forEach { s ->
// Assign a stable, collision-resistant id per session for this service lifecycle
val nid = notificationIdMap.getOrPut(s.id) { nextNotificationId.getAndIncrement() }
currentIds.add(nid)
summaryLines.add("${s.title}: ${s.status}")
val disconnectIntent = Intent(this, MainActivity::class.java).apply {
action = ACTION_DISCONNECT_SESSION
putExtra("session_id", s.id)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
val disconnectPending = PendingIntent.getActivity(
this, nid, disconnectIntent, PendingIntent.FLAG_IMMUTABLE
)
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, chanId)
} else {
Notification.Builder(this)
}
val noti = builder
.setContentTitle(s.title)
.setContentText("${s.subtitle} · ${s.status}")
.setSmallIcon(R.mipmap.ic_launcher)
.setWhen(s.startWhen)
.setUsesChronometer(true)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setGroup(GROUP_KEY)
.addAction(android.R.drawable.ic_media_pause, "Disconnect", disconnectPending)
.build()
nm.notify(nid, noti)
}
// Cancel stale ones
val toCancel = postedIds - currentIds
toCancel.forEach { nm.cancel(it) } toCancel.forEach { nm.cancel(it) }
// Clean up id mappings for canceled notifications to prevent growth
if (toCancel.isNotEmpty()) {
val keysToRemove = notificationIdMap.filterValues { it in toCancel }.keys
keysToRemove.forEach { notificationIdMap.remove(it) }
}
postedIds.clear() postedIds.clear()
postedIds.addAll(currentIds) notificationIdMap.clear()
// Post/update summary and ensure foreground // Create merged notification content
val maxSummaryLines = 5 val summaryLines = sessions.map { "${it.title}: ${it.status}" }
val truncated = summaryLines.size > maxSummaryLines val mergedNotification = createMergedNotification(sessions.size, summaryLines, sessions)
val displaySummaryLines = if (truncated) { ensureForeground(mergedNotification)
summaryLines.take(maxSummaryLines) + "...and ${summaryLines.size - maxSummaryLines} more"
} else {
summaryLines
}
val summary = createSummaryNotification(sessions.size, displaySummaryLines)
ensureForeground(summary)
} }
private fun clearAll() { private fun clearAll() {
val nm = getSystemService(NotificationManager::class.java) val nm = getSystemService(NotificationManager::class.java)
nm?.cancel(SUMMARY_ID) nm?.cancel(NOTIFICATION_ID)
postedIds.forEach { id -> nm?.cancel(id) } postedIds.forEach { id -> nm?.cancel(id) }
postedIds.clear() postedIds.clear()
isFgStarted = false isFgStarted = false
@@ -272,7 +301,10 @@ class ForegroundService : Service() {
private fun stopForegroundService() { private fun stopForegroundService() {
try { try {
stopForeground(true) if (isFgStarted) {
stopForeground(STOP_FOREGROUND_REMOVE)
isFgStarted = false
}
} catch (e: Exception) { } catch (e: Exception) {
logError("Error stopping foreground", e) logError("Error stopping foreground", e)
} }

View File

@@ -4,6 +4,9 @@ import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.Manifest import android.Manifest
import android.content.BroadcastReceiver
import android.content.Context
import android.content.IntentFilter
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.android.FlutterFragmentActivity
@@ -16,6 +19,8 @@ class MainActivity: FlutterFragmentActivity() {
private lateinit var channel: MethodChannel private lateinit var channel: MethodChannel
private val ACTION_UPDATE_SESSIONS = "tech.lolli.toolbox.ACTION_UPDATE_SESSIONS" private val ACTION_UPDATE_SESSIONS = "tech.lolli.toolbox.ACTION_UPDATE_SESSIONS"
private val ACTION_DISCONNECT_SESSION = "tech.lolli.toolbox.ACTION_DISCONNECT_SESSION" private val ACTION_DISCONNECT_SESSION = "tech.lolli.toolbox.ACTION_DISCONNECT_SESSION"
private val ACTION_STOP_ALL_CONNECTIONS = "tech.lolli.toolbox.STOP_ALL_CONNECTIONS"
private var stopAllReceiver: BroadcastReceiver? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
@@ -92,24 +97,32 @@ class MainActivity: FlutterFragmentActivity() {
// Handle intent if launched via notification action // Handle intent if launched via notification action
handleActionIntent(intent) handleActionIntent(intent)
// Register broadcast receiver for stop all connections
setupStopAllReceiver()
} }
private fun reqPerm() { private fun reqPerm() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
// Check if we already have the permission to avoid unnecessary prompts try {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) // Check if we already have the permission to avoid unnecessary prompts
!= PackageManager.PERMISSION_GRANTED) { if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
try { != PackageManager.PERMISSION_GRANTED) {
// Check if we should show rationale
if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.POST_NOTIFICATIONS)) {
android.util.Log.i("MainActivity", "User previously denied notification permission")
}
ActivityCompat.requestPermissions( ActivityCompat.requestPermissions(
this, this,
arrayOf(Manifest.permission.POST_NOTIFICATIONS), arrayOf(Manifest.permission.POST_NOTIFICATIONS),
123, 123,
) )
} catch (e: Exception) {
// Log error but don't crash
android.util.Log.e("MainActivity", "Failed to request permissions: ${e.message}")
} }
} catch (e: Exception) {
// Log error but don't crash
android.util.Log.e("MainActivity", "Failed to request permissions: ${e.message}")
} }
} }
@@ -141,4 +154,52 @@ class MainActivity: FlutterFragmentActivity() {
} }
} }
} }
private fun setupStopAllReceiver() {
stopAllReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == ACTION_STOP_ALL_CONNECTIONS && ::channel.isInitialized) {
try {
channel.invokeMethod("stopAllConnections", null)
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to invoke stopAllConnections: ${e.message}")
}
}
}
}
val filter = IntentFilter(ACTION_STOP_ALL_CONNECTIONS)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ContextCompat.registerReceiver(this, stopAllReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
} else {
registerReceiver(stopAllReceiver, filter)
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == 123) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
android.util.Log.i("MainActivity", "Notification permission granted")
} else {
android.util.Log.w("MainActivity", "Notification permission denied")
// Optionally inform user about the limitation
}
}
}
override fun onDestroy() {
super.onDestroy()
stopAllReceiver?.let {
try {
unregisterReceiver(it)
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to unregister receiver: ${e.message}")
}
stopAllReceiver = null
}
}
} }

View File

@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists 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 { plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0" 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 id "org.jetbrains.kotlin.android" version "2.1.21" apply false
} }

3
devtools_options.yaml Normal file
View File

@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import 'package:fl_lib/fl_lib.dart';
import 'package:fl_lib/generated/l10n/lib_l10n.dart'; import 'package:fl_lib/generated/l10n/lib_l10n.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:icons_plus/icons_plus.dart'; import 'package:icons_plus/icons_plus.dart';
import 'package:server_box/core/app_navigator.dart';
import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/data/res/build_data.dart'; import 'package:server_box/data/res/build_data.dart';
import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/res/store.dart';
@@ -11,12 +12,20 @@ import 'package:server_box/view/page/home.dart';
part 'intro.dart'; part 'intro.dart';
class MyApp extends StatelessWidget { class MyApp extends StatefulWidget {
const MyApp({super.key}); const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late final Future<List<IntroPageBuilder>> _introFuture = _IntroPage.builders;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
_setup(context); _setup(context);
return ListenableBuilder( return ListenableBuilder(
listenable: RNodes.app, listenable: RNodes.app,
builder: (context, _) { builder: (context, _) {
@@ -31,6 +40,7 @@ class MyApp extends StatelessWidget {
Widget _build(BuildContext context) { Widget _build(BuildContext context) {
final colorSeed = Color(Stores.setting.colorSeed.fetch()); final colorSeed = Color(Stores.setting.colorSeed.fetch());
UIs.colorSeed = colorSeed; UIs.colorSeed = colorSeed;
UIs.primaryColor = colorSeed; UIs.primaryColor = colorSeed;
@@ -53,12 +63,31 @@ class MyApp extends StatelessWidget {
Widget _buildDynamicColor(BuildContext context) { Widget _buildDynamicColor(BuildContext context) {
return DynamicColorBuilder( return DynamicColorBuilder(
builder: (light, dark) { builder: (light, dark) {
final lightTheme = ThemeData(useMaterial3: true, colorScheme: light); final lightSeed = light?.primary;
final darkTheme = ThemeData(useMaterial3: true, brightness: Brightness.dark, colorScheme: dark); 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) { if (context.isDark && dark != null) {
UIs.primaryColor = dark.primary; UIs.primaryColor = dark.primary;
UIs.colorSeed = dark.primary;
} else if (!context.isDark && light != null) { } else if (!context.isDark && light != null) {
UIs.primaryColor = light.primary; 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); return _buildApp(context, light: lightTheme, dark: darkTheme);
@@ -78,6 +107,7 @@ class MyApp extends StatelessWidget {
return MaterialApp( return MaterialApp(
key: ValueKey(locale), key: ValueKey(locale),
navigatorKey: AppNavigator.key,
builder: ResponsivePoints.builder, builder: ResponsivePoints.builder,
locale: locale, locale: locale,
localizationsDelegates: const [LibLocalizations.delegate, ...AppLocalizations.localizationsDelegates], localizationsDelegates: const [LibLocalizations.delegate, ...AppLocalizations.localizationsDelegates],
@@ -89,7 +119,7 @@ class MyApp extends StatelessWidget {
theme: light.fixWindowsFont, theme: light.fixWindowsFont,
darkTheme: (tMode < 3 ? dark : dark.toAmoled).fixWindowsFont, darkTheme: (tMode < 3 ? dark : dark.toAmoled).fixWindowsFont,
home: FutureBuilder<List<IntroPageBuilder>>( home: FutureBuilder<List<IntroPageBuilder>>(
future: _IntroPage.builders, future: _introFuture,
builder: (context, snapshot) { builder: (context, snapshot) {
context.setLibL10n(); context.setLibL10n();
final appL10n = AppLocalizations.of(context); final appL10n = AppLocalizations.of(context);

View File

@@ -0,0 +1,8 @@
import 'package:flutter/widgets.dart';
/// Global navigator access used for cross-cutting flows (e.g. dialogs).
abstract final class AppNavigator {
static final key = GlobalKey<NavigatorState>();
static BuildContext? get context => key.currentContext;
}

View File

@@ -35,8 +35,8 @@ abstract final class MethodChans {
try { try {
Loggers.app.info('Updating Android sessions: $payload'); Loggers.app.info('Updating Android sessions: $payload');
await _channel.invokeMethod('updateSessions', payload); await _channel.invokeMethod('updateSessions', payload);
} catch (_) { } catch (e, s) {
// ignore Loggers.app.warning('Failed to update Android sessions', e, s);
} }
} }
@@ -46,7 +46,8 @@ abstract final class MethodChans {
try { try {
final res = await _channel.invokeMethod('isServiceRunning'); final res = await _channel.invokeMethod('isServiceRunning');
return res == true; return res == true;
} catch (_) { } catch (e, s) {
Loggers.app.warning('Failed to check if Android service is running', e, s);
return false; return false;
} }
} }
@@ -57,7 +58,9 @@ abstract final class MethodChans {
try { try {
Loggers.app.info('Starting iOS Live Activity: $payload'); Loggers.app.info('Starting iOS Live Activity: $payload');
await _channel.invokeMethod('startLiveActivity', 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 { static Future<void> updateLiveActivity(String payload) async {
@@ -65,7 +68,9 @@ abstract final class MethodChans {
try { try {
Loggers.app.info('Updating iOS Live Activity: $payload'); Loggers.app.info('Updating iOS Live Activity: $payload');
await _channel.invokeMethod('updateLiveActivity', 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 { static Future<void> stopLiveActivity() async {
@@ -73,12 +78,16 @@ abstract final class MethodChans {
try { try {
Loggers.app.info('Stopping iOS Live Activity'); Loggers.app.info('Stopping iOS Live Activity');
await _channel.invokeMethod('stopLiveActivity'); 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. /// Register a handler for native -> Flutter callbacks.
/// Currently handles: `disconnectSession` with argument map {id: string} /// Currently handles:
static void registerHandler(Future<void> Function(String id) onDisconnect) { /// - `disconnectSession` with argument map {id: string}
/// - `stopAllConnections` with no arguments
static void registerHandler(Future<void> Function(String id) onDisconnect, [VoidCallback? onStopAll]) {
_channel.setMethodCallHandler((call) async { _channel.setMethodCallHandler((call) async {
switch (call.method) { switch (call.method) {
case 'disconnectSession': case 'disconnectSession':
@@ -88,6 +97,9 @@ abstract final class MethodChans {
await onDisconnect(id); await onDisconnect(id);
} }
return; return;
case 'stopAllConnections':
onStopAll?.call();
return;
default: default:
return; return;
} }

View File

@@ -2,10 +2,10 @@ import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:server_box/data/model/app/scripts/cmd_types.dart'; import 'package:server_box/data/model/app/scripts/cmd_types.dart';
import 'package:server_box/data/model/server/dist.dart'; import 'package:server_box/data/model/server/dist.dart';
import 'package:server_box/data/model/server/server.dart'; import 'package:server_box/data/provider/server/single.dart';
import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/res/store.dart';
extension LogoExt on Server { extension LogoExt on ServerState {
String? getLogoUrl(BuildContext context) { String? getLogoUrl(BuildContext context) {
var logoUrl = spi.custom?.logoUrl ?? Stores.setting.serverLogoUrl.fetch().selfNotEmptyOrNull; var logoUrl = spi.custom?.logoUrl ?? Stores.setting.serverLogoUrl.fetch().selfNotEmptyOrNull;
if (logoUrl == null) { if (logoUrl == null) {

View File

@@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'package:dartssh2/dartssh2.dart'; import 'package:dartssh2/dartssh2.dart';
import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/widgets.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/model/server/system.dart';
import 'package:server_box/data/res/misc.dart'; import 'package:server_box/data/res/misc.dart';
@@ -170,4 +171,98 @@ extension SSHClientX on SSHClient {
); );
return ret.$2; 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

@@ -0,0 +1,411 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/data/model/server/discovery_result.dart';
class SshDiscoveryService {
static const _sshPort = 22;
static Future<SshDiscoveryReport> discover([SshDiscoveryConfig config = const SshDiscoveryConfig()]) async {
final t0 = DateTime.now();
final candidates = <InternetAddress>{};
// 1) Get neighbors from ARP/NDP tables
candidates.addAll(await _neighborsIPv4());
candidates.addAll(await _neighborsIPv6());
// 2) Enumerate small subnets from local interfaces (IPv4 only)
final cidrs = await _localIPv4Cidrs();
for (final c in cidrs) {
if (c.prefix >= 24 && c.prefix <= 30) {
candidates.addAll(c.enumerateHosts(limit: config.hostEnumerationLimit));
}
}
// 3) Optional: mDNS/Bonjour SSH services
if (config.enableMdns) {
candidates.addAll(await _mdnsSshCandidates());
}
// Filter out unwanted addresses: loopback, link-local, 0.0.0.0, broadcast, multicast
candidates.removeWhere(
(a) => a.isLoopback || a.isLinkLocal || a.address == '0.0.0.0' || _isBroadcastOrMulticast(a),
);
// 4) Concurrent SSH port scanning
final scanner = _Scanner(
timeout: Duration(milliseconds: config.timeoutMs),
maxConcurrency: config.maxConcurrency,
);
final results = await scanner.scan(candidates.toList(growable: false));
results.sort((a, b) => a.addr.address.compareTo(b.addr.address));
final discoveryResults = results
.map((r) => SshDiscoveryResult(ip: r.addr.address, port: _sshPort, banner: r.banner?.trim()))
.toList();
return SshDiscoveryReport(
generatedAt: DateTime.now().toIso8601String(),
durationMs: DateTime.now().difference(t0).inMilliseconds,
count: discoveryResults.length,
items: discoveryResults,
);
}
static Future<String?> _run(String exe, List<String> args, {Duration? timeout}) async {
try {
final p = await Process.start(exe, args, runInShell: false);
final out = await p.stdout
.transform(utf8.decoder)
.join()
.timeout(
timeout ?? const Duration(seconds: 5),
onTimeout: () {
p.kill();
return '';
},
);
final code = await p.exitCode;
if (code == 0) return out;
// Some tools return non-zero but still have useful output
if (out.trim().isNotEmpty) return out;
return null;
} catch (e, s) {
Loggers.app.warning('Failed to run command: $exe ${args.join(' ')}', e, s);
return null;
}
}
static bool get _isLinux => Platform.isLinux;
static bool get _isMac => Platform.isMacOS;
static Future<Set<InternetAddress>> _neighborsIPv4() async {
final set = <InternetAddress>{};
if (_isLinux) {
final s = await _run('ip', ['neigh']);
if (s != null) {
for (final line in const LineSplitter().convert(s)) {
final tok = line.split(RegExp(r'\s+'));
if (tok.isNotEmpty) {
final ip = tok[0];
if (InternetAddress.tryParse(ip)?.type == InternetAddressType.IPv4) {
set.add(InternetAddress(ip));
}
}
}
}
} else if (_isMac) {
final s = await _run('/usr/sbin/arp', ['-an']);
if (s != null) {
int matchCount = 0;
for (final line in const LineSplitter().convert(s)) {
final m = RegExp(r'\((\d+\.\d+\.\d+\.\d+)\)').firstMatch(line);
if (m != null) {
set.add(InternetAddress(m.group(1)!));
matchCount++;
}
}
if (matchCount == 0) {
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}',
);
}
}
}
return set;
}
static Future<Set<InternetAddress>> _neighborsIPv6() async {
final set = <InternetAddress>{};
if (_isLinux) {
final s = await _run('ip', ['-6', 'neigh']);
if (s != null) {
for (final line in const LineSplitter().convert(s)) {
final ip = line.split(RegExp(r'\s+')).firstOrNull;
if (ip != null && InternetAddress.tryParse(ip)?.type == InternetAddressType.IPv6) {
set.add(InternetAddress(ip));
}
}
}
} else if (_isMac) {
final s = await _run('/usr/sbin/ndp', ['-a']);
if (s != null) {
for (final line in const LineSplitter().convert(s)) {
final ip = line.trim().split(RegExp(r'\s+')).firstOrNull;
if (ip != null && InternetAddress.tryParse(ip)?.type == InternetAddressType.IPv6) {
set.add(InternetAddress(ip));
}
}
}
}
return set;
}
static Future<List<_Cidr>> _localIPv4Cidrs() async {
final res = <_Cidr>[];
if (_isLinux) {
final s = await _run('ip', ['-o', '-4', 'addr', 'show', 'scope', 'global']);
if (s != null) {
for (final line in const LineSplitter().convert(s)) {
final m = RegExp(r'inet\s+(\d+\.\d+\.\d+\.\d+)\/(\d+)').firstMatch(line);
if (m != null) {
final ip = InternetAddress(m.group(1)!);
final prefix = int.parse(m.group(2)!);
final mask = _prefixToMask(prefix);
final net = _networkAddress(ip, mask);
final brd = _broadcastAddress(ip, mask);
res.add(_Cidr(ip, prefix, mask, net, brd));
}
}
}
} else if (_isMac) {
final s = await _run('/sbin/ifconfig', []);
if (s != null) {
for (final raw in const LineSplitter().convert(s)) {
final line = raw.trimRight();
final ifMatch = RegExp(r'^([a-z0-9]+):').firstMatch(line);
if (ifMatch != null) {
continue;
}
if (line.contains('inet ') && !line.contains('127.0.0.1')) {
try {
final ipm = RegExp(
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) {
Loggers.app.warning('[ssh_discovery] Warning: Unexpected ifconfig line format: $line');
continue;
}
final ip = InternetAddress(ipm.group(1)!);
final hexMask = int.parse(ipm.group(2)!, radix: 16);
final dotted =
'${(hexMask >> 24) & 0xff}.${(hexMask >> 16) & 0xff}.${(hexMask >> 8) & 0xff}.${hexMask & 0xff}';
final mask = InternetAddress(dotted);
final prefix = _maskToPrefix(mask.address);
final net = _networkAddress(ip, mask);
final brd = InternetAddress(ipm.group(3) ?? _broadcastAddress(ip, mask).address);
res.add(_Cidr(ip, prefix, mask, net, brd));
} catch (e) {
Loggers.app.warning('[ssh_discovery] Error parsing ifconfig output: $e, line: $line');
continue;
}
}
}
}
}
return res;
}
static bool _isBroadcastOrMulticast(InternetAddress a) {
// IPv4 broadcast: ends with .255 or is 255.255.255.255
if (a.type == InternetAddressType.IPv4) {
if (a.address == '255.255.255.255') return true;
if (a.address.split('.').last == '255') return true;
// Multicast: 224.0.0.0 - 239.255.255.255
final firstOctet = int.tryParse(a.address.split('.').first) ?? 0;
if (firstOctet >= 224 && firstOctet <= 239) return true;
} else if (a.type == InternetAddressType.IPv6) {
// IPv6 multicast: starts with ff
if (a.address.toLowerCase().startsWith('ff')) return true;
}
return false;
}
static Future<Set<InternetAddress>> _mdnsSshCandidates() async {
final set = <InternetAddress>{};
if (_isMac) {
try {
final proc = await Process.start('/usr/bin/dns-sd', ['-B', '_ssh._tcp']);
final lines = <String>[];
final subscription = proc.stdout
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen(lines.add);
await Future<void>.delayed(const Duration(seconds: 2));
proc.kill();
await subscription.cancel();
for (final l in lines) {
final m = RegExp(r'Add\s+\d+\s+(\S+)\.\s+_ssh\._tcp\.').firstMatch(l);
if (m != null) {
final name = m.group(1)!;
final det = await _run('/usr/bin/dns-sd', [
'-L',
name,
'_ssh._tcp',
'local.',
], timeout: const Duration(seconds: 3));
if (det != null) {
for (final ip in RegExp(
r'Address\s*=\s*([0-9a-fA-F:\.]+)',
).allMatches(det).map((e) => e.group(1)!)) {
final parsed = InternetAddress.tryParse(ip);
if (parsed != null) set.add(parsed);
}
}
}
}
} 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) {
for (final ip in RegExp(
r'address = \[(.*?)\]',
).allMatches(s).map((m) => m.group(1)!).where((e) => e.isNotEmpty)) {
final parsed = InternetAddress.tryParse(ip);
if (parsed != null) set.add(parsed);
}
}
}
return set;
}
}
class _Cidr {
final InternetAddress ip;
final int prefix;
final InternetAddress netmask;
final InternetAddress network;
final InternetAddress broadcast;
_Cidr(this.ip, this.prefix, this.netmask, this.network, this.broadcast);
Iterable<InternetAddress> enumerateHosts({int? limit}) sync* {
final n = _ipv4ToInt(network.address);
final b = _ipv4ToInt(broadcast.address);
int emitted = 0;
for (int v = n + 1; v <= b - 1; v++) {
if (limit != null && emitted >= limit) break;
emitted++;
yield InternetAddress(_intToIPv4(v));
}
}
@override
String toString() => '${network.address}/$prefix';
}
class _ScanResult {
final InternetAddress addr;
final String? banner;
_ScanResult(this.addr, this.banner);
}
class _Scanner {
final Duration timeout;
final int maxConcurrency;
_Scanner({required this.timeout, required this.maxConcurrency});
Future<List<_ScanResult>> scan(List<InternetAddress> addrs) async {
final sem = _Semaphore(maxConcurrency);
final futures = <Future<_ScanResult?>>[];
for (final a in addrs) {
futures.add(_guarded(sem, () => _probeSsh(a)));
}
final out = await Future.wait(futures);
return out.whereType<_ScanResult>().toList();
}
Future<_ScanResult?> _probeSsh(InternetAddress ip) async {
Socket? socket;
StreamSubscription? sub;
try {
socket = await Socket.connect(ip, SshDiscoveryService._sshPort, timeout: timeout);
socket.timeout(timeout);
final c = Completer<String?>();
sub = socket.listen(
(data) {
final s = utf8.decode(data, allowMalformed: true);
final line = s.split('\n').firstWhere((_) => true, orElse: () => s);
if (!c.isCompleted) {
c.complete(line.trim());
sub?.cancel();
}
},
onDone: () {
if (!c.isCompleted) c.complete(null);
},
onError: (_) {
if (!c.isCompleted) c.complete(null);
},
);
final banner = await c.future.timeout(timeout, onTimeout: () => null);
return _ScanResult(ip, banner);
} catch (e, s) {
Loggers.app.warning('Failed to probe SSH at ${ip.address}', e, s);
return null;
} finally {
sub?.cancel();
socket?.destroy();
}
}
}
class _Semaphore {
int _permits;
final Queue<Completer<void>> _q = Queue();
_Semaphore(this._permits);
Future<T> withPermit<T>(Future<T> Function() fn) async {
if (_permits > 0) {
_permits--;
try {
return await fn();
} finally {
_permits++;
if (_q.isNotEmpty) _q.removeFirst().complete();
}
} else {
final c = Completer<void>();
_q.add(c);
await c.future;
return withPermit(fn);
}
}
}
Future<T> _guarded<T>(_Semaphore sem, Future<T> Function() fn) => sem.withPermit(fn);
// IPv4 utilities
int _ipv4ToInt(String ip) {
final p = ip.split('.').map(int.parse).toList();
return (p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3];
}
String _intToIPv4(int v) => '${(v >> 24) & 0xff}.${(v >> 16) & 0xff}.${(v >> 8) & 0xff}.${v & 0xff}';
InternetAddress _prefixToMask(int prefix) {
final mask = prefix == 0 ? 0 : 0xffffffff << (32 - prefix);
return InternetAddress(_intToIPv4(mask & 0xffffffff));
}
int _maskToPrefix(String mask) {
final v = _ipv4ToInt(mask);
int c = 0;
for (int i = 31; i >= 0; i--) {
if ((v & (1 << i)) != 0) {
c++;
} else {
break;
}
}
return c;
}
InternetAddress _networkAddress(InternetAddress ip, InternetAddress mask) {
final v = _ipv4ToInt(ip.address) & _ipv4ToInt(mask.address);
return InternetAddress(_intToIPv4(v));
}
InternetAddress _broadcastAddress(InternetAddress ip, InternetAddress mask) {
final n = _ipv4ToInt(ip.address) & _ipv4ToInt(mask.address);
final b = n | (~_ipv4ToInt(mask.address) & 0xffffffff);
return InternetAddress(_intToIPv4(b));
}

View File

@@ -14,11 +14,7 @@ final class BakSyncer extends SyncIface {
@override @override
Future<void> saveToFile() async { Future<void> saveToFile() async {
final pwd = await SecureStoreProps.bakPwd.read(); final pwd = await SecureStoreProps.bakPwd.read();
if (pwd == null || pwd.isEmpty) { await BackupV2.backup(null, pwd?.isEmpty == true ? null : pwd);
// Enforce password for non-clipboard backups
throw Exception('Backup password not set');
}
await BackupV2.backup(null, pwd);
} }
@override @override
@@ -30,7 +26,8 @@ final class BakSyncer extends SyncIface {
return MergeableUtils.fromJsonString(content, pwd).$1; return MergeableUtils.fromJsonString(content, pwd).$1;
} }
return MergeableUtils.fromJsonString(content).$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 // Fallback: try without password if detection failed
return MergeableUtils.fromJsonString(content).$1; return MergeableUtils.fromJsonString(content).$1;
} }

View File

@@ -0,0 +1,26 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:server_box/core/utils/server.dart';
import 'package:server_box/core/utils/ssh_auth.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/res/store.dart';
Future<bool> ensureHostKeyAcceptedForSftp(BuildContext context, Spi spi) async {
final known = Stores.setting.sshKnownHostFingerprints.get();
final hostId = spi.id.isNotEmpty ? spi.id : spi.oldId;
final prefix = '$hostId::';
if (known.keys.any((key) => key.startsWith(prefix))) {
return true;
}
final (result, error) = await context.showLoadingDialog<bool>(
fn: () async {
await ensureKnownHostKey(
spi,
onKeyboardInteractive: (_) => KeybordInteractive.defaultHandle(spi, ctx: context),
);
return true;
},
);
return error == null && result == true;
}

View File

@@ -1,8 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:dartssh2/dartssh2.dart'; import 'package:dartssh2/dartssh2.dart';
import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:server_box/core/app_navigator.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/data/model/app/error.dart'; import 'package:server_box/data/model/app/error.dart';
import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/res/store.dart';
@@ -29,11 +33,82 @@ enum GenSSHClientStatus { socket, key, pwd }
String getPrivateKey(String id) { String getPrivateKey(String id) {
final pki = Stores.key.fetchOne(id); final pki = Stores.key.fetchOne(id);
if (pki == null) { if (pki == null) {
throw SSHErr(type: SSHErrType.noPrivateKey, message: 'key [$id] not found'); throw SSHErr(type: SSHErrType.noPrivateKey, message: l10n.privateKeyNotFoundFmt(id));
} }
return pki.key; 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( Future<SSHClient> genClient(
Spi spi, { Spi spi, {
void Function(GenSSHClientStatus)? onStatus, void Function(GenSSHClientStatus)? onStatus,
@@ -41,46 +116,187 @@ Future<SSHClient> genClient(
/// Only pass this param if using multi-threading and key login /// Only pass this param if using multi-threading and key login
String? privateKey, String? privateKey,
/// Only pass this param if using multi-threading and key login /// Pre-resolved jump chain (in `spi.jumpId` order: immediate -> farthest).
String? jumpPrivateKey,
Duration timeout = const Duration(seconds: 5),
/// [Spi] of the jump server
/// ///
/// Must pass this param if using multi-threading and key login /// This is mainly used when `Stores` is unavailable (e.g. in an isolate).
Spi? jumpSpi, 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 /// Handle keyboard-interactive authentication
SSHUserInfoRequestHandler? onKeyboardInteractive, SSHUserInfoRequestHandler? onKeyboardInteractive,
Map<String, String>? knownHostFingerprints,
void Function(String storageKey, String fingerprintHex)? onHostKeyAccepted,
Future<bool> Function(HostKeyPromptInfo info)? onHostKeyPrompt,
}) async { }) 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); onStatus?.call(GenSSHClientStatus.socket);
final hostKeyCache = Map<String, String>.from(knownHostFingerprints ?? _loadKnownHostFingerprints());
final hostKeyPersist = onHostKeyAccepted ?? _persistHostKeyFingerprint;
final hostKeyPrompt = onHostKeyPrompt ?? _defaultHostKeyPrompt;
String? alterUser; String? alterUser;
final socket = await () async { final (socket, hopClients) = await () async {
// Proxy if (socketOverride != null) return (socketOverride, <SSHClient>[]);
final jumpSpi_ = () {
// Multi-thread or key login
if (jumpSpi != null) return jumpSpi;
// Main thread
if (spi.jumpId != null) return Stores.server.box.get(spi.jumpId);
}();
if (jumpSpi_ != null) {
final jumpClient = await genClient(jumpSpi_, privateKey: jumpPrivateKey, timeout: timeout);
return await jumpClient.forwardLocal(spi.ip, spi.port); if (followJumpConfig) {
final injectedSpiMap = <String, Spi>{};
final injectedKeyMap = <String, String?>{};
if (jumpChain != null) {
for (var i = 0; i < jumpChain.length; i++) {
final s = jumpChain[i];
injectedSpiMap[s.id] = s;
if (s.oldId.isNotEmpty) injectedSpiMap[s.oldId] = s;
if (jumpPrivateKeys != null && i < jumpPrivateKeys.length) {
injectedKeyMap[s.id] = jumpPrivateKeys[i];
if (s.oldId.isNotEmpty) injectedKeyMap[s.oldId] = jumpPrivateKeys[i];
}
}
}
Spi resolveSpi(String id) {
final injected = injectedSpiMap[id];
if (injected != null) return injected;
if (jumpChain != null) {
throw SSHErr(type: SSHErrType.connect, message: 'Jump server not found in provided chain: $id');
}
final fromStore = Stores.server.box.get(id);
if (fromStore == null) {
throw SSHErr(type: SSHErrType.connect, message: 'Jump server not found: $id');
}
return fromStore;
}
String? resolveHopPrivateKey(Spi hop) {
final keyId = hop.keyId;
if (keyId == null) return null;
final injected = injectedKeyMap[hop.id] ?? injectedKeyMap[hop.oldId];
return injected ?? getPrivateKey(keyId);
}
final hops = _resolveMergedJumpChainInternal(spi, resolveSpi: resolveSpi);
if (hops.isNotEmpty) {
// Build multi-hop forward chain with dedup/merge.
final createdClients = <SSHClient>[];
SSHClient? currentClient;
try {
final firstHop = hops.first;
final firstKey = resolveHopPrivateKey(firstHop);
if (firstHop.keyId != null && firstKey == null) {
throw SSHErr(type: SSHErrType.noPrivateKey, message: l10n.privateKeyNotFoundFmt(firstHop.keyId ?? ''));
}
currentClient = await _genClientInternal(
firstHop,
privateKey: firstKey,
jumpChain: jumpChain,
jumpPrivateKeys: jumpPrivateKeys,
timeout: timeout,
onKeyboardInteractive: onKeyboardInteractive,
knownHostFingerprints: hostKeyCache,
onHostKeyAccepted: hostKeyPersist,
onHostKeyPrompt: hostKeyPrompt,
visited: visited,
followJumpConfig: false,
);
createdClients.add(currentClient);
for (var i = 1; i < hops.length; i++) {
final hop = hops[i];
final forwarded = await currentClient!.forwardLocal(hop.ip, hop.port);
final hopKey = resolveHopPrivateKey(hop);
if (hop.keyId != null && hopKey == null) {
throw SSHErr(type: SSHErrType.noPrivateKey, message: l10n.privateKeyNotFoundFmt(hop.keyId ?? ''));
}
currentClient = await _genClientInternal(
hop,
privateKey: hopKey,
jumpChain: jumpChain,
jumpPrivateKeys: jumpPrivateKeys,
timeout: timeout,
onKeyboardInteractive: onKeyboardInteractive,
knownHostFingerprints: hostKeyCache,
onHostKeyAccepted: hostKeyPersist,
onHostKeyPrompt: hostKeyPrompt,
visited: visited,
socketOverride: forwarded,
followJumpConfig: false,
);
createdClients.add(currentClient);
}
final forwardedSocket = await currentClient!.forwardLocal(spi.ip, spi.port);
return (forwardedSocket, createdClients);
} catch (e) {
// Close all created clients on error to avoid leaks
for (final client in createdClients) {
try {
client.close();
} catch (_) {
// Ignore close errors during cleanup
}
}
rethrow;
}
// Note: On success, all intermediate clients must remain open
// because the returned socket tunnels through them.
}
} }
// Direct // Direct
try { try {
return await SSHSocket.connect(spi.ip, spi.port, timeout: timeout); return (await SSHSocket.connect(spi.ip, spi.port, timeout: timeout), <SSHClient>[]);
} catch (e) { } catch (e) {
Loggers.app.warning('genClient', e); Loggers.app.warning('genClient', e);
if (spi.alterUrl == null) rethrow; if (spi.alterUrl == null) rethrow;
try { try {
final res = spi.fromStringUrl(); final res = spi.parseAlterUrl();
alterUser = res.$2; alterUser = res.$2;
return await SSHSocket.connect(res.$1, res.$3, timeout: timeout); return (await SSHSocket.connect(res.$1, res.$3, timeout: timeout), <SSHClient>[]);
} catch (e) { } catch (e) {
Loggers.app.warning('genClient alterUrl', e); Loggers.app.warning('genClient alterUrl', e);
rethrow; rethrow;
@@ -88,28 +304,307 @@ Future<SSHClient> genClient(
} }
}(); }();
final keyId = spi.keyId; final hostKeyVerifier = _HostKeyVerifier(
if (keyId == null) { spi: spi,
onStatus?.call(GenSSHClientStatus.pwd); cache: hostKeyCache,
persistCallback: hostKeyPersist,
prompt: hostKeyPrompt,
);
Future<SSHClient> buildClient(SSHSocket socket) async {
final keyId = spi.keyId;
if (keyId == null) {
onStatus?.call(GenSSHClientStatus.pwd);
return SSHClient(
socket,
username: alterUser ?? spi.user,
onPasswordRequest: () => spi.pwd,
onUserInfoRequest: onKeyboardInteractive,
onVerifyHostKey: hostKeyVerifier.call,
// printDebug: debugPrint,
// printTrace: debugPrint,
);
}
privateKey ??= getPrivateKey(keyId);
onStatus?.call(GenSSHClientStatus.key);
return SSHClient( return SSHClient(
socket, socket,
username: alterUser ?? spi.user, username: spi.user,
onPasswordRequest: () => spi.pwd, // Must use [compute] here, instead of [Computer.shared.start]
identities: await compute(loadIndentity, privateKey!),
onUserInfoRequest: onKeyboardInteractive, onUserInfoRequest: onKeyboardInteractive,
onVerifyHostKey: hostKeyVerifier.call,
// printDebug: debugPrint, // printDebug: debugPrint,
// printTrace: debugPrint, // printTrace: debugPrint,
); );
} }
privateKey ??= getPrivateKey(keyId);
onStatus?.call(GenSSHClientStatus.key); final client = await buildClient(socket);
return SSHClient(
socket, // Tie hop clients' lifetime to the final client: close all hop clients
username: spi.user, // when the target client disconnects to avoid leaking SSH connections.
// Must use [compute] here, instead of [Computer.shared.start] if (hopClients.isNotEmpty) {
identities: await compute(loadIndentity, privateKey), client.done.whenComplete(() {
onUserInfoRequest: onKeyboardInteractive, for (final hopClient in hopClients) {
// printDebug: debugPrint, try {
// printTrace: debugPrint, hopClient.close();
} catch (_) {
// Ignore close errors during cleanup
}
}
});
}
return client;
}
typedef _HostKeyPersistCallback = void Function(String storageKey, String fingerprintHex);
class HostKeyPromptInfo {
HostKeyPromptInfo({
required this.spi,
required this.keyType,
required this.fingerprintHex,
required this.fingerprintBase64,
required this.isMismatch,
this.previousFingerprintHex,
});
final Spi spi;
final String keyType;
final String fingerprintHex;
final String fingerprintBase64;
final bool isMismatch;
final String? previousFingerprintHex;
}
class _HostKeyVerifier {
_HostKeyVerifier({
required this.spi,
required Map<String, String> cache,
required this.prompt,
this.persistCallback,
}) : _cache = cache;
final Spi spi;
final Map<String, String> _cache;
final _HostKeyPersistCallback? persistCallback;
final Future<bool> Function(HostKeyPromptInfo info) prompt;
Future<bool> call(String keyType, Uint8List fingerprintBytes) async {
final storageKey = _hostKeyStorageKey(spi, keyType);
final fingerprintHex = _fingerprintToHex(fingerprintBytes);
final fingerprintBase64 = _fingerprintToBase64(fingerprintBytes);
final existing = _cache[storageKey];
if (existing == null) {
final accepted = await prompt(
HostKeyPromptInfo(
spi: spi,
keyType: keyType,
fingerprintHex: fingerprintHex,
fingerprintBase64: fingerprintBase64,
isMismatch: false,
),
);
if (!accepted) {
Loggers.app.warning('User rejected new SSH host key for ${spi.name} ($keyType).');
return false;
}
_cache[storageKey] = fingerprintHex;
persistCallback?.call(storageKey, fingerprintHex);
Loggers.app.info('Trusted SSH host key for ${spi.name} ($keyType).');
return true;
}
if (existing == fingerprintHex) {
return true;
}
final accepted = await prompt(
HostKeyPromptInfo(
spi: spi,
keyType: keyType,
fingerprintHex: fingerprintHex,
fingerprintBase64: fingerprintBase64,
isMismatch: true,
previousFingerprintHex: existing,
),
);
if (!accepted) {
Loggers.app.warning(
'SSH host key mismatch for ${spi.name}',
'expected $existing but received $fingerprintHex ($keyType)',
);
return false;
}
_cache[storageKey] = fingerprintHex;
persistCallback?.call(storageKey, fingerprintHex);
Loggers.app.warning('Updated stored SSH host key for ${spi.name} ($keyType) after user confirmation.');
return true;
}
}
Map<String, String> _loadKnownHostFingerprints() {
try {
final prop = Stores.setting.sshKnownHostFingerprints;
return Map<String, String>.from(prop.get());
} catch (e, stack) {
Loggers.app.warning('Load SSH host key fingerprints failed', e, stack);
return <String, String>{};
}
}
void _persistHostKeyFingerprint(String storageKey, String fingerprintHex) {
try {
final prop = Stores.setting.sshKnownHostFingerprints;
final updated = Map<String, String>.from(prop.get());
if (updated[storageKey] == fingerprintHex) {
return;
}
updated[storageKey] = fingerprintHex;
prop.put(updated);
Loggers.app.info('Stored SSH host key fingerprint for $storageKey');
} catch (e, stack) {
Loggers.app.warning('Persist SSH host key fingerprint failed', e, stack);
}
}
Future<bool> _defaultHostKeyPrompt(HostKeyPromptInfo info) async {
final ctx = AppNavigator.context;
if (ctx == null) {
Loggers.app.warning('Host key prompt skipped: navigator context unavailable.');
return false;
}
final hostLine = '${info.spi.user}@${info.spi.ip}:${info.spi.port}';
final description = info.isMismatch
? l10n.sshHostKeyChangedDesc(info.spi.name)
: l10n.sshHostKeyNewDesc(info.spi.name);
final result = await ctx.showRoundDialog<bool>(
title: libL10n.attention,
barrierDismiss: false,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(description),
const SizedBox(height: 12),
SelectableText('${l10n.server}: ${info.spi.name}'),
SelectableText('${libL10n.addr}: $hostLine'),
SelectableText('${l10n.sshHostKeyType}: ${info.keyType}'),
SelectableText(l10n.sshHostKeyFingerprintMd5Hex(info.fingerprintHex)),
SelectableText(l10n.sshHostKeyFingerprintMd5Base64(info.fingerprintBase64)),
if (info.previousFingerprintHex != null) ...[
const SizedBox(height: 12),
SelectableText(l10n.sshHostKeyStoredFingerprint(info.previousFingerprintHex!)),
],
],
),
actions: [
TextButton(onPressed: () => ctx.pop(false), child: Text(libL10n.cancel)),
TextButton(onPressed: () => ctx.pop(true), child: Text(libL10n.ok)),
],
);
return result ?? false;
}
Future<void> ensureKnownHostKey(
Spi spi, {
Duration timeout = const Duration(seconds: 5),
SSHUserInfoRequestHandler? onKeyboardInteractive,
}) async {
var cache = _loadKnownHostFingerprints();
final hops = resolveMergedJumpChain(spi);
// Check each hop's host key, routing through preceding hops
for (var i = 0; i < hops.length; i++) {
final hop = hops[i];
// Preceding hops needed to reach this hop
final precedingHops = i > 0 ? hops.sublist(0, i) : null;
final precedingKeys = precedingHops?.map((h) =>
h.keyId != null ? getPrivateKey(h.keyId!) : null
).toList();
cache = await _ensureKnownHostKeyForSingle(
hop,
cache: cache,
timeout: timeout,
onKeyboardInteractive: onKeyboardInteractive,
jumpChain: precedingHops,
jumpPrivateKeys: precedingKeys,
);
}
// 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(
spi,
timeout: timeout,
onKeyboardInteractive: onKeyboardInteractive,
knownHostFingerprints: cache,
jumpChain: jumpChain,
jumpPrivateKeys: jumpPrivateKeys,
);
try {
await client.authenticated;
} finally {
client.close();
}
cache.addAll(_loadKnownHostFingerprints());
return cache;
}
bool _hasKnownHostFingerprintForSpi(Spi spi, Map<String, String> cache) {
final prefix = '${_hostIdentifier(spi)}::';
return cache.keys.any((key) => key.startsWith(prefix));
}
String _hostKeyStorageKey(Spi spi, String keyType) {
final base = _hostIdentifier(spi);
return '$base::$keyType';
}
String _hostIdentifier(Spi spi) => spi.id.isNotEmpty ? spi.id : spi.oldId;
String _fingerprintToHex(Uint8List fingerprint) {
final buffer = StringBuffer();
for (var i = 0; i < fingerprint.length; i++) {
if (i > 0) buffer.write(':');
buffer.write(fingerprint[i].toRadixString(16).padLeft(2, '0'));
}
return buffer.toString();
}
String _fingerprintToBase64(Uint8List fingerprint) => base64.encode(fingerprint);

View File

@@ -0,0 +1,84 @@
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/store/server.dart';
class ServerDeduplication {
/// Remove duplicate servers from the import list based on existing servers
/// Returns the deduplicated list
static List<Spi> deduplicateServers(List<Spi> importedServers) {
final existingServers = ServerStore.instance.fetch();
final deduplicated = <Spi>[];
for (final imported in importedServers) {
if (!_isDuplicate(imported, existingServers)) {
deduplicated.add(imported);
}
}
return deduplicated;
}
/// Check if an imported server is a duplicate of an existing server
static bool _isDuplicate(Spi imported, List<Spi> existing) {
for (final existingSpi in existing) {
if (imported.isSameAs(existingSpi)) {
return true;
}
}
return false;
}
/// Resolve name conflicts by appending suffixes
static List<Spi> resolveNameConflicts(List<Spi> importedServers) {
final existingServers = ServerStore.instance.fetch();
final existingNames = existingServers.map((s) => s.name).toSet();
final processedNames = <String>{};
final result = <Spi>[];
for (final server in importedServers) {
String newName = server.name;
int suffix = 1;
// Check against both existing servers and already processed servers
while (existingNames.contains(newName) || processedNames.contains(newName)) {
newName = '${server.name} ($suffix)';
suffix++;
}
processedNames.add(newName);
if (newName != server.name) {
result.add(server.copyWith(name: newName));
} else {
result.add(server);
}
}
return result;
}
/// Get summary of import operation
static ImportSummary getImportSummary(List<Spi> originalList, List<Spi> deduplicatedList) {
final duplicateCount = originalList.length - deduplicatedList.length;
return ImportSummary(
total: originalList.length,
duplicates: duplicateCount,
toImport: deduplicatedList.length,
);
}
}
class ImportSummary {
final int total;
final int duplicates;
final int toImport;
const ImportSummary({
required this.total,
required this.duplicates,
required this.toImport,
});
bool get hasDuplicates => duplicates > 0;
bool get hasItemsToImport => toImport > 0;
}

View File

@@ -0,0 +1,190 @@
import 'dart:io';
import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
/// Utility class to parse SSH config files under `~/.ssh/config`
abstract final class SSHConfig {
static const String _defaultPath = '~/.ssh/config';
static String? get _homePath {
final homePath = isWindows ? Platform.environment['USERPROFILE'] : Platform.environment['HOME'];
if (homePath == null || homePath.isEmpty) {
return null;
}
return homePath;
}
/// Get possible SSH config file paths, with macOS-specific handling
static List<String> get _possibleConfigPaths {
final paths = <String>[];
final homePath = _homePath;
if (homePath != null) {
// Standard path
paths.add('$homePath/.ssh/config');
// On macOS, also try the actual user home directory
if (isMacOS) {
// Try to get the real user home directory
final username = Platform.environment['USER'];
if (username != null) {
paths.add('/Users/$username/.ssh/config');
}
}
}
return paths;
}
/// Parse SSH config file and return a list of Spi objects
static Future<List<Spi>> parseConfig([String? configPath]) async {
final (file, exists) = configExists(configPath);
if (!exists || file == null) {
Loggers.app.info('SSH config file does not exist at path: ${configPath ?? _defaultPath}');
return [];
}
final content = await file.readAsString();
return _parseSSHConfig(content);
}
/// Parse SSH config content
static List<Spi> _parseSSHConfig(String content) {
final servers = <Spi>[];
final lines = content.split('\n');
String? currentHost;
String? hostname;
String? user;
int port = 22;
String? identityFile;
String? jumpHost;
void addServer() {
if (currentHost != null && currentHost != '*' && hostname != null) {
final spi = Spi(
id: ShortId.generate(),
name: currentHost,
ip: hostname,
port: port,
user: user ?? 'root', // Default user is 'root'
keyId: identityFile,
jumpId: jumpHost,
);
servers.add(spi);
}
}
for (final line in lines) {
final trimmed = line.trim();
if (trimmed.isEmpty || trimmed.startsWith('#')) continue;
// Handle inline comments
final commentIndex = trimmed.indexOf('#');
final cleanLine = commentIndex != -1 ? trimmed.substring(0, commentIndex).trim() : trimmed;
if (cleanLine.isEmpty) continue;
final parts = cleanLine.split(RegExp(r'\s+'));
if (parts.length < 2) continue;
final key = parts[0].toLowerCase();
var value = parts.sublist(1).join(' ');
// Remove quotes from values
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.substring(1, value.length - 1);
}
switch (key) {
case 'host':
// Save previous host config
addServer();
// Reset for new host
final originalValue = parts.sublist(1).join(' ');
final isQuoted =
(originalValue.startsWith('"') && originalValue.endsWith('"')) ||
(originalValue.startsWith("'") && originalValue.endsWith("'"));
currentHost = value;
// Skip hosts with multiple patterns (contains spaces but not quoted)
if (currentHost.contains(' ') && !isQuoted) {
currentHost = null; // Mark as invalid to skip
}
hostname = null;
user = null;
port = 22;
identityFile = null;
jumpHost = null;
break;
case 'hostname':
hostname = value;
break;
case 'user':
user = value;
break;
case 'port':
port = int.tryParse(value) ?? 22;
break;
case 'identityfile':
identityFile = value; // Store the path directly
break;
case 'proxyjump':
case 'proxycommand':
jumpHost = _extractJumpHost(value);
break;
}
}
// Add the last server
addServer();
return servers;
}
/// Extract jump host from ProxyJump or ProxyCommand
static String? _extractJumpHost(String value) {
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;
}
return null;
}
/// Check if SSH config file exists, trying multiple possible paths
static (File?, bool) configExists([String? configPath]) {
if (configPath != null) {
// If specific path is provided, use it directly
final homePath = _homePath;
if (homePath == null) {
Loggers.app.warning('Cannot determine home directory for SSH config parsing.');
return (null, false);
}
final expandedPath = configPath.replaceFirst('~', homePath);
dprint('Checking SSH config at path: $expandedPath');
final file = File(expandedPath);
return (file, file.existsSync());
}
// Try multiple possible paths
for (final path in _possibleConfigPaths) {
dprint('Checking SSH config at path: $path');
final file = File(path);
if (file.existsSync()) {
dprint('Found SSH config at: $path');
return (file, true);
}
}
dprint('SSH config file not found in any of the expected locations');
return (null, false);
}
}

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:dartssh2/dartssh2.dart';
import 'package:fl_lib/fl_lib.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/server_private_info.dart';
import 'package:server_box/data/model/server/system.dart'; import 'package:server_box/data/model/server/system.dart';
@@ -9,8 +10,8 @@ class SystemDetector {
/// ///
/// First checks if a custom system type is configured in [spi]. /// First checks if a custom system type is configured in [spi].
/// If not, attempts to detect the system by running commands: /// If not, attempts to detect the system by running commands:
/// 1. 'ver' command to detect Windows /// 1. 'uname -a' command to detect Linux/BSD/Darwin
/// 2. 'uname -a' command to detect Linux/BSD/Darwin /// 2. 'ver' command to detect Windows (if uname fails)
/// ///
/// Returns [SystemType.linux] as default if detection fails. /// Returns [SystemType.linux] as default if detection fails.
static Future<SystemType> detect(SSHClient client, Spi spi) async { static Future<SystemType> detect(SSHClient client, Spi spi) async {
@@ -22,17 +23,11 @@ class SystemDetector {
} }
try { try {
// Try to detect Windows systems first (more reliable detection) // Try to detect Unix/Linux/BSD systems first (more reliable and doesn't create files)
final powershellResult = await client.run('ver 2>nul').string; final unixResult = await client.runSafe(
if (powershellResult.isNotEmpty && 'uname -a 2>/dev/null',
(powershellResult.contains('Windows') || powershellResult.contains('NT'))) { context: 'uname detection for ${spi.oldId}',
detectedSystemType = SystemType.windows; );
dprint('Detected Windows system type for ${spi.oldId}');
return detectedSystemType;
}
// Try to detect Unix/Linux/BSD systems
final unixResult = await client.run('uname -a').string;
if (unixResult.contains('Linux')) { if (unixResult.contains('Linux')) {
detectedSystemType = SystemType.linux; detectedSystemType = SystemType.linux;
dprint('Detected Linux system type for ${spi.oldId}'); dprint('Detected Linux system type for ${spi.oldId}');
@@ -42,8 +37,21 @@ class SystemDetector {
dprint('Detected BSD system type for ${spi.oldId}'); dprint('Detected BSD system type for ${spi.oldId}');
return detectedSystemType; return detectedSystemType;
} }
} catch (e) {
Loggers.app.warning('System detection failed for ${spi.oldId}: $e'); // If uname fails, try to detect Windows systems
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, stackTrace) {
Loggers.app.warning('System detection failed for ${spi.oldId}: $e\n$stackTrace');
} }
// Default fallback // Default fallback

View File

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

View File

@@ -4,6 +4,9 @@ import 'dart:io';
import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/fl_lib.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:server_box/data/provider/private_key.dart';
import 'package:server_box/data/provider/server/all.dart';
import 'package:server_box/data/provider/snippet.dart';
import 'package:server_box/data/res/misc.dart'; import 'package:server_box/data/res/misc.dart';
import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/res/store.dart';
@@ -44,17 +47,17 @@ abstract class BackupV2 with _$BackupV2 implements Mergeable {
Future<void> merge({bool force = false}) async { Future<void> merge({bool force = false}) async {
_loggerV2.info('Merging...'); _loggerV2.info('Merging...');
// Merge each store // Merge each store and check if changes were made
await Mergeable.mergeStore(backupData: spis, store: Stores.server, force: force); final serverChanged = await Mergeable.mergeStore(backupData: spis, store: Stores.server, force: force);
await Mergeable.mergeStore(backupData: snippets, store: Stores.snippet, force: force); final snippetChanged = await Mergeable.mergeStore(backupData: snippets, store: Stores.snippet, force: force);
await Mergeable.mergeStore(backupData: keys, store: Stores.key, force: force); final keyChanged = await Mergeable.mergeStore(backupData: keys, store: Stores.key, force: force);
await Mergeable.mergeStore(backupData: container, store: Stores.container, force: force); await Mergeable.mergeStore(backupData: container, store: Stores.container, force: force);
await Mergeable.mergeStore(backupData: history, store: Stores.history, force: force); await Mergeable.mergeStore(backupData: history, store: Stores.history, force: force);
await Mergeable.mergeStore(backupData: settings, store: Stores.setting, force: force); await Mergeable.mergeStore(backupData: settings, store: Stores.setting, force: force);
// Reload providers and notify listeners if (serverChanged) GlobalRef.gRef?.read(serversProvider.notifier).reload();
Provider.reload(); if (snippetChanged) GlobalRef.gRef?.read(snippetProvider.notifier).reload();
RNodes.app.notify(); if (keyChanged) GlobalRef.gRef?.read(privateKeyProvider.notifier).reload();
_loggerV2.info('Merge completed'); _loggerV2.info('Merge completed');
} }

View File

@@ -11,27 +11,10 @@ class BackupService {
/// Perform backup operation with the given source /// Perform backup operation with the given source
static Future<void> backup(BuildContext context, BackupSource source) async { static Future<void> backup(BuildContext context, BackupSource source) async {
try { try {
String? password; final saved = await SecureStoreProps.bakPwd.read();
final password = saved?.isEmpty == true ? null : saved;
if (source is ClipboardBackupSource) { final path = await BackupV2.backup(null, password?.isEmpty == true ? null : password);
// Clipboard backup: allow optional password
password = await _getClipboardPassword(context);
if (password == null) return; // canceled
} else {
// All other backups require pre-set bakPwd (SecureStore)
final saved = await SecureStoreProps.bakPwd.read();
if (saved == null || saved.isEmpty) {
// Prompt to set before proceeding
password = await _showPasswordDialog(context, hint: l10n.backupPasswordTip);
if (password == null || password.isEmpty) return; // Not set
await SecureStoreProps.bakPwd.write(password);
context.showSnackBar(l10n.backupPasswordSet);
} else {
password = saved;
}
}
final path = await BackupV2.backup(null, password.isEmpty ? null : password);
await source.saveContent(path); await source.saveContent(path);
if (source is ClipboardBackupSource) { if (source is ClipboardBackupSource) {
@@ -56,34 +39,6 @@ class BackupService {
await restoreFromText(context, text); await restoreFromText(context, text);
} }
/// Handle password dialog for backup operations
static Future<String?> _getClipboardPassword(BuildContext context) async {
// Use saved bakPwd as default for clipboard flow if exists, but allow empty/custom
final savedPassword = await SecureStoreProps.bakPwd.read();
String? password;
if (savedPassword != null && savedPassword.isNotEmpty) {
final useCustom = await context.showRoundDialog<bool>(
title: l10n.backupPassword,
child: Text(l10n.backupPasswordTip),
actions: [
Btn.cancel(),
TextButton(onPressed: () => context.pop(false), child: Text(l10n.backupPasswordSet)),
TextButton(onPressed: () => context.pop(true), child: Text(libL10n.custom)),
],
);
if (useCustom == null) return null;
if (useCustom) {
password = await _showPasswordDialog(context, initial: savedPassword);
} else {
password = savedPassword;
}
} else {
password = await _showPasswordDialog(context);
}
return password;
}
/// Handle restore from text with decryption support /// Handle restore from text with decryption support
static Future<void> restoreFromText(BuildContext context, String text) async { static Future<void> restoreFromText(BuildContext context, String text) async {
// Check if backup is encrypted // Check if backup is encrypted
@@ -119,8 +74,8 @@ class BackupService {
await _confirmAndRestore(context, backup); await _confirmAndRestore(context, backup);
return; return;
} }
} catch (e) { } catch (e, s) {
// Saved password failed, will prompt for manual input Loggers.app.warning('Failed to restore with saved password, will prompt for manual input', e, s);
} }
} }

View File

@@ -4,7 +4,7 @@ import 'package:server_box/core/extension/context/locale.dart';
enum SSHErrType { unknown, connect, auth, noPrivateKey, chdir, segements, writeScript, getStatus } enum SSHErrType { unknown, connect, auth, noPrivateKey, chdir, segements, writeScript, getStatus }
class SSHErr extends Err<SSHErrType> { class SSHErr extends Err<SSHErrType> {
SSHErr({required super.type, super.message}); const SSHErr({required super.type, super.message});
@override @override
String? get solution => switch (type) { String? get solution => switch (type) {
@@ -29,7 +29,7 @@ enum ContainerErrType {
} }
class ContainerErr extends Err<ContainerErrType> { class ContainerErr extends Err<ContainerErrType> {
ContainerErr({required super.type, super.message}); const ContainerErr({required super.type, super.message});
@override @override
String? get solution => null; String? get solution => null;
@@ -38,7 +38,7 @@ class ContainerErr extends Err<ContainerErrType> {
enum ICloudErrType { generic, notFound, multipleFiles } enum ICloudErrType { generic, notFound, multipleFiles }
class ICloudErr extends Err<ICloudErrType> { class ICloudErr extends Err<ICloudErrType> {
ICloudErr({required super.type, super.message}); const ICloudErr({required super.type, super.message});
@override @override
String? get solution => null; String? get solution => null;
@@ -47,7 +47,7 @@ class ICloudErr extends Err<ICloudErrType> {
enum WebdavErrType { generic, notFound } enum WebdavErrType { generic, notFound }
class WebdavErr extends Err<WebdavErrType> { class WebdavErr extends Err<WebdavErrType> {
WebdavErr({required super.type, super.message}); const WebdavErr({required super.type, super.message});
@override @override
String? get solution => null; String? get solution => null;
@@ -56,7 +56,7 @@ class WebdavErr extends Err<WebdavErrType> {
enum PveErrType { unknown, net, loginFailed } enum PveErrType { unknown, net, loginFailed }
class PveErr extends Err<PveErrType> { class PveErr extends Err<PveErrType> {
PveErr({required super.type, super.message}); const PveErr({required super.type, super.message});
@override @override
String? get solution => null; String? get solution => null;

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

@@ -55,8 +55,8 @@ enum StatusCmdType implements ShellCmdType {
uptime('uptime'), uptime('uptime'),
conn('cat /proc/net/snmp'), conn('cat /proc/net/snmp'),
disk( disk(
'lsblk --bytes --json --output ' '(lsblk --bytes --json --output '
'FSTYPE,PATH,NAME,KNAME,MOUNTPOINT,FSSIZE,FSUSED,FSAVAIL,FSUSE%,UUID', 'FSTYPE,PATH,NAME,KNAME,MOUNTPOINT,FSSIZE,FSUSED,FSAVAIL,FSUSE%,UUID 2>/dev/null && echo "LSBLK_SUCCESS") || df -k'
), ),
mem("cat /proc/meminfo | grep -E 'Mem|Swap'"), mem("cat /proc/meminfo | grep -E 'Mem|Swap'"),
tempType('cat /sys/class/thermal/thermal_zone*/type'), tempType('cat /sys/class/thermal/thermal_zone*/type'),
@@ -166,25 +166,34 @@ enum WindowsStatusCmdType implements ShellCmdType {
echo('echo ${SystemType.windowsSign}'), echo('echo ${SystemType.windowsSign}'),
time('[DateTimeOffset]::UtcNow.ToUnixTimeSeconds()'), 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: /// Uses WMI Win32_PerfRawData_Tcpip_NetworkInterface for cross-language compatibility:
/// - Collects bytes received and sent per second for all network interfaces
/// - Takes 2 samples with 1 second interval to calculate rates /// - 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( net(
r'Get-Counter -Counter ' r'$s1 = @(Get-WmiObject Win32_PerfRawData_Tcpip_NetworkInterface | '
r'"\\NetworkInterface(*)\\Bytes Received/sec", ' r'Select-Object Name, BytesReceivedPersec, BytesSentPersec, Timestamp_Sys100NS); '
r'"\\NetworkInterface(*)\\Bytes Sent/sec" ' r'Start-Sleep -Seconds 1; '
r'-SampleInterval 1 -MaxSamples 2 | ConvertTo-Json', 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'), sys('(Get-ComputerInfo).OsName'),
cpu( cpu(
'Get-WmiObject -Class Win32_Processor | ' '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'), conn('(netstat -an | findstr ESTABLISHED | Measure-Object -Line).Count'),
disk( disk(
'Get-WmiObject -Class Win32_LogicalDisk | ' 'Get-WmiObject -Class Win32_LogicalDisk | '
@@ -213,19 +222,19 @@ enum WindowsStatusCmdType implements ShellCmdType {
), ),
host(r'Write-Output $env:COMPUTERNAME'), 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 /// - Monitors read and write bytes per second for all physical disks
/// - Takes 2 samples with 1 second interval to calculate I/O rates /// - Takes 2 samples with 1 second interval to calculate rates
/// - Physical disk counters provide hardware-level I/O statistics /// - DiskReadBytesPersec and DiskWriteBytesPersec are cumulative counters
/// - Outputs results in JSON format for parsing
/// - Counter names use wildcard (*) to capture all disk instances
diskio( diskio(
r'Get-Counter -Counter ' r'$s1 = @(Get-WmiObject Win32_PerfRawData_PerfDisk_PhysicalDisk | '
r'"\\PhysicalDisk(*)\\Disk Read Bytes/sec", ' r'Select-Object Name, DiskReadBytesPersec, DiskWriteBytesPersec, Timestamp_Sys100NS); '
r'"\\PhysicalDisk(*)\\Disk Write Bytes/sec" ' r'Start-Sleep -Seconds 1; '
r'-SampleInterval 1 -MaxSamples 2 | ConvertTo-Json', r'$s2 = @(Get-WmiObject Win32_PerfRawData_PerfDisk_PhysicalDisk | '
r'Select-Object Name, DiskReadBytesPersec, DiskWriteBytesPersec, Timestamp_Sys100NS); '
r'@($s1, $s2) | ConvertTo-Json -Depth 5',
), ),
battery( battery(
'Get-WmiObject -Class Win32_Battery | ' 'Get-WmiObject -Class Win32_Battery | '
@@ -287,7 +296,7 @@ enum WindowsStatusCmdType implements ShellCmdType {
String get separator => ScriptConstants.getCmdSeparator(name); String get separator => ScriptConstants.getCmdSeparator(name);
@override @override
String get divider => ScriptConstants.getCmdDivider(name); String get divider => ScriptConstants.getWindowsCmdDivider(name);
@override @override
CmdTypeSys get sysType => CmdTypeSys.windows; CmdTypeSys get sysType => CmdTypeSys.windows;

View File

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

View File

@@ -1,7 +1,6 @@
import 'package:server_box/data/model/app/scripts/script_builders.dart'; import 'package:server_box/data/model/app/scripts/script_builders.dart';
import 'package:server_box/data/model/app/scripts/script_consts.dart'; import 'package:server_box/data/model/app/scripts/script_consts.dart';
import 'package:server_box/data/model/server/system.dart'; import 'package:server_box/data/model/server/system.dart';
import 'package:server_box/data/provider/server.dart';
/// Shell functions available in the ServerBox application /// Shell functions available in the ServerBox application
enum ShellFunc { enum ShellFunc {
@@ -26,8 +25,8 @@ enum ShellFunc {
}; };
/// Execute this shell function on the specified server /// Execute this shell function on the specified server
String exec(String id, {SystemType? systemType}) { String exec(String id, {SystemType? systemType, required String? customDir}) {
final scriptPath = ShellFuncManager.getScriptPath(id, systemType: systemType); final scriptPath = ShellFuncManager.getScriptPath(id, systemType: systemType, customDir: customDir);
final isWindows = systemType == SystemType.windows; final isWindows = systemType == SystemType.windows;
final builder = ScriptBuilderFactory.getBuilder(isWindows); final builder = ScriptBuilderFactory.getBuilder(isWindows);
@@ -51,11 +50,10 @@ class ShellFuncManager {
/// Get the script directory for the given [id]. /// Get the script directory for the given [id].
/// ///
/// Checks for custom script directory first, then falls back to default. /// Checks for custom script directory first, then falls back to default.
static String getScriptDir(String id, {SystemType? systemType}) { static String getScriptDir(String id, {SystemType? systemType, required String? customDir}) {
final customScriptDir = ServerProvider.pick(id: id)?.value.spi.custom?.scriptDir;
final isWindows = systemType == SystemType.windows; final isWindows = systemType == SystemType.windows;
if (customScriptDir != null) return _normalizeDir(customScriptDir, isWindows); if (customDir != null) return _normalizeDir(customDir, isWindows);
return ScriptPaths.getScriptDir(id, isWindows: isWindows); return ScriptPaths.getScriptDir(id, isWindows: isWindows);
} }
@@ -66,11 +64,10 @@ class ShellFuncManager {
} }
/// Get the full script path for the given [id] /// Get the full script path for the given [id]
static String getScriptPath(String id, {SystemType? systemType}) { static String getScriptPath(String id, {SystemType? systemType, required String? customDir}) {
final customScriptDir = ServerProvider.pick(id: id)?.value.spi.custom?.scriptDir; if (customDir != null) {
if (customScriptDir != null) {
final isWindows = systemType == SystemType.windows; final isWindows = systemType == SystemType.windows;
final normalizedDir = _normalizeDir(customScriptDir, isWindows); final normalizedDir = _normalizeDir(customDir, isWindows);
final fileName = isWindows ? ScriptConstants.scriptFileWindows : ScriptConstants.scriptFile; final fileName = isWindows ? ScriptConstants.scriptFileWindows : ScriptConstants.scriptFile;
final separator = isWindows ? ScriptConstants.windowsPathSeparator : ScriptConstants.unixPathSeparator; final separator = isWindows ? ScriptConstants.windowsPathSeparator : ScriptConstants.unixPathSeparator;
return '$normalizedDir$separator$fileName'; return '$normalizedDir$separator$fileName';
@@ -81,8 +78,8 @@ class ShellFuncManager {
} }
/// Get the installation shell command for the script /// Get the installation shell command for the script
static String getInstallShellCmd(String id, {SystemType? systemType}) { static String getInstallShellCmd(String id, {SystemType? systemType, required String? customDir}) {
final scriptDir = getScriptDir(id, systemType: systemType); final scriptDir = getScriptDir(id, systemType: systemType, customDir: customDir);
final isWindows = systemType == SystemType.windows; final isWindows = systemType == SystemType.windows;
final normalizedDir = _normalizeDir(scriptDir, isWindows); final normalizedDir = _normalizeDir(scriptDir, isWindows);
final builder = ScriptBuilderFactory.getBuilder(isWindows); final builder = ScriptBuilderFactory.getBuilder(isWindows);

View File

@@ -1,5 +1,6 @@
import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hive_ce_flutter/adapters.dart';
import 'package:icons_plus/icons_plus.dart'; import 'package:icons_plus/icons_plus.dart';
import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/view/page/server/tab/tab.dart'; import 'package:server_box/view/page/server/tab/tab.dart';
@@ -8,10 +9,17 @@ import 'package:server_box/view/page/snippet/list.dart';
import 'package:server_box/view/page/ssh/tab.dart'; import 'package:server_box/view/page/ssh/tab.dart';
import 'package:server_box/view/page/storage/local.dart'; import 'package:server_box/view/page/storage/local.dart';
part 'tab.g.dart';
@HiveType(typeId: 103)
enum AppTab { enum AppTab {
@HiveField(0)
server, server,
@HiveField(1)
ssh, ssh,
@HiveField(2)
file, file,
@HiveField(3)
snippet snippet
//settings, //settings,
; ;
@@ -93,4 +101,35 @@ enum AppTab {
static List<NavigationRailDestination> get navRailDestinations { static List<NavigationRailDestination> get navRailDestinations {
return AppTab.values.map((e) => e.navRailDestination).toList(); return AppTab.values.map((e) => e.navRailDestination).toList();
} }
/// Helper function to parse AppTab list from stored object
static List<AppTab> parseAppTabsFromObj(dynamic val) {
if (val is List) {
final tabs = <AppTab>[];
for (final e in val) {
final tab = _parseAppTabFromElement(e);
if (tab != null) {
tabs.add(tab);
}
}
if (tabs.isNotEmpty) return tabs;
}
return AppTab.values;
}
/// Helper function to parse a single AppTab from various element types
static AppTab? _parseAppTabFromElement(dynamic e) {
if (e is AppTab) {
return e;
} else if (e is String) {
return AppTab.values.firstWhereOrNull((t) => t.name == e);
} else if (e is int) {
if (e >= 0 && e < AppTab.values.length) {
return AppTab.values[e];
}
}
return null;
}
} }

View File

@@ -0,0 +1,52 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'tab.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class AppTabAdapter extends TypeAdapter<AppTab> {
@override
final typeId = 103;
@override
AppTab read(BinaryReader reader) {
switch (reader.readByte()) {
case 0:
return AppTab.server;
case 1:
return AppTab.ssh;
case 2:
return AppTab.file;
case 3:
return AppTab.snippet;
default:
return AppTab.server;
}
}
@override
void write(BinaryWriter writer, AppTab obj) {
switch (obj) {
case AppTab.server:
writer.writeByte(0);
case AppTab.ssh:
writer.writeByte(1);
case AppTab.file:
writer.writeByte(2);
case AppTab.snippet:
writer.writeByte(3);
}
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AppTabAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

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

View File

@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/data/model/container/status.dart';
import 'package:server_box/data/model/container/type.dart'; import 'package:server_box/data/model/container/type.dart';
import 'package:server_box/data/res/misc.dart'; import 'package:server_box/data/res/misc.dart';
@@ -10,7 +11,7 @@ sealed class ContainerPs {
final String? image = null; final String? image = null;
String? get name; String? get name;
String? get cmd; String? get cmd;
bool get running; ContainerStatus get status;
String? cpu; String? cpu;
String? mem; String? mem;
@@ -19,7 +20,7 @@ sealed class ContainerPs {
factory ContainerPs.fromRaw(String s, ContainerType typ) => typ.ps(s); 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 { final class PodmanPs implements ContainerPs {
@@ -51,10 +52,10 @@ final class PodmanPs implements ContainerPs {
String? get cmd => command?.firstOrNull; String? get cmd => command?.firstOrNull;
@override @override
bool get running => exited != true; ContainerStatus get status => ContainerStatus.fromPodmanExited(exited);
@override @override
void parseStats(String s) { void parseStats(String s, [String? version]) {
final stats = json.decode(s); final stats = json.decode(s);
final cpuD = (stats['CPU'] as double? ?? 0).toStringAsFixed(1); final cpuD = (stats['CPU'] as double? ?? 0).toStringAsFixed(1);
final cpuAvgD = (stats['AvgCPU'] as double? ?? 0).toStringAsFixed(1); final cpuAvgD = (stats['AvgCPU'] as double? ?? 0).toStringAsFixed(1);
@@ -62,12 +63,32 @@ final class PodmanPs implements ContainerPs {
final memLimit = (stats['MemLimit'] as int? ?? 0).bytes2Str; final memLimit = (stats['MemLimit'] as int? ?? 0).bytes2Str;
final memUsage = (stats['MemUsage'] as int? ?? 0).bytes2Str; final memUsage = (stats['MemUsage'] as int? ?? 0).bytes2Str;
mem = '$memUsage / $memLimit'; mem = '$memUsage / $memLimit';
final netIn = (stats['NetInput'] as int? ?? 0).bytes2Str;
final netOut = (stats['NetOutput'] as int? ?? 0).bytes2Str; int netIn = 0;
net = '$netIn / ↑ $netOut'; 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 diskIn = (stats['BlockInput'] as int? ?? 0).bytes2Str;
final diskOut = (stats['BlockOutput'] 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)); factory PodmanPs.fromRawJson(String str) => PodmanPs.fromJson(json.decode(str));
@@ -121,18 +142,21 @@ final class DockerPs implements ContainerPs {
String? get cmd => null; String? get cmd => null;
@override @override
bool get running { ContainerStatus get status => ContainerStatus.fromDockerState(state);
if (state?.contains('Exited') == true) return false;
return true;
}
@override @override
void parseStats(String s) { void parseStats(String s, [String? version]) {
final stats = json.decode(s); final stats = json.decode(s);
cpu = stats['CPUPerc']; cpu = stats['CPUPerc'];
mem = stats['MemUsage']; 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 /// CONTAINER ID NAMES IMAGE STATUS

View File

@@ -0,0 +1,70 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/core/extension/context/locale.dart';
/// Represents the various states a container can be in.
/// Supports both Docker and Podman container status parsing.
enum ContainerStatus {
running,
exited,
created,
paused,
restarting,
removing,
dead,
unknown;
/// Check if the container is actively running
bool get isRunning => this == ContainerStatus.running;
/// Check if the container can be started
bool get canStart =>
this == ContainerStatus.exited ||
this == ContainerStatus.created ||
this == ContainerStatus.dead;
/// Check if the container can be stopped
bool get canStop =>
this == ContainerStatus.running || this == ContainerStatus.paused;
/// Check if the container can be restarted
bool get canRestart =>
this != ContainerStatus.removing && this != ContainerStatus.unknown;
/// Parse Docker container status string to ContainerStatus
static ContainerStatus fromDockerState(String? state) {
if (state == null || state.isEmpty) return ContainerStatus.unknown;
final lowerState = state.toLowerCase();
if (lowerState.startsWith('up')) return ContainerStatus.running;
if (lowerState.contains('exited')) return ContainerStatus.exited;
if (lowerState.contains('created')) return ContainerStatus.created;
if (lowerState.contains('paused')) return ContainerStatus.paused;
if (lowerState.contains('restarting')) return ContainerStatus.restarting;
if (lowerState.contains('removing')) return ContainerStatus.removing;
if (lowerState.contains('dead')) return ContainerStatus.dead;
return ContainerStatus.unknown;
}
/// Parse Podman container status from exited boolean
static ContainerStatus fromPodmanExited(bool? exited) {
if (exited == true) return ContainerStatus.exited;
if (exited == false) return ContainerStatus.running;
return ContainerStatus.unknown;
}
/// Get display string for the status
String get displayName {
return switch (this) {
ContainerStatus.running => l10n.running,
ContainerStatus.exited => libL10n.exit,
ContainerStatus.created => 'Created',
ContainerStatus.paused => 'Paused',
ContainerStatus.restarting => 'Restarting',
ContainerStatus.removing => 'Removing',
ContainerStatus.dead => 'Dead',
ContainerStatus.unknown => libL10n.unknown,
};
}
}

View File

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

View File

@@ -0,0 +1,79 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hive_ce/hive.dart';
part 'connection_stat.freezed.dart';
part 'connection_stat.g.dart';
@freezed
@HiveType(typeId: 100)
abstract class ConnectionStat with _$ConnectionStat {
const factory ConnectionStat({
@HiveField(0) required String serverId,
@HiveField(1) required String serverName,
@HiveField(2) required DateTime timestamp,
@HiveField(3) required ConnectionResult result,
@HiveField(4) @Default('') String errorMessage,
@HiveField(5) required int durationMs,
}) = _ConnectionStat;
factory ConnectionStat.fromJson(Map<String, dynamic> json) =>
_$ConnectionStatFromJson(json);
}
@freezed
@HiveType(typeId: 101)
abstract class ServerConnectionStats with _$ServerConnectionStats {
const factory ServerConnectionStats({
@HiveField(0) required String serverId,
@HiveField(1) required String serverName,
@HiveField(2) required int totalAttempts,
@HiveField(3) required int successCount,
@HiveField(4) required int failureCount,
@HiveField(5) @Default(null) DateTime? lastSuccessTime,
@HiveField(6) @Default(null) DateTime? lastFailureTime,
@HiveField(7) @Default([]) List<ConnectionStat> recentConnections,
@HiveField(8) required double successRate,
}) = _ServerConnectionStats;
factory ServerConnectionStats.fromJson(Map<String, dynamic> json) =>
_$ServerConnectionStatsFromJson(json);
}
@HiveType(typeId: 102)
enum ConnectionResult {
@HiveField(0)
@JsonValue('success')
success,
@HiveField(1)
@JsonValue('timeout')
timeout,
@HiveField(2)
@JsonValue('auth_failed')
authFailed,
@HiveField(3)
@JsonValue('network_error')
networkError,
@HiveField(4)
@JsonValue('unknown_error')
unknownError,
}
extension ConnectionResultExtension on ConnectionResult {
String get displayName {
switch (this) {
case ConnectionResult.success:
return libL10n.success;
case ConnectionResult.timeout:
return '${libL10n.error}(timeout)';
case ConnectionResult.authFailed:
return '${libL10n.error}(auth)';
case ConnectionResult.networkError:
return '${libL10n.error}(${libL10n.network})';
case ConnectionResult.unknownError:
return '${libL10n.error}(${libL10n.unknown})';
}
}
bool get isSuccess => this == ConnectionResult.success;
}

View File

@@ -0,0 +1,585 @@
// 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 'connection_stat.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ConnectionStat {
@HiveField(0) String get serverId;@HiveField(1) String get serverName;@HiveField(2) DateTime get timestamp;@HiveField(3) ConnectionResult get result;@HiveField(4) String get errorMessage;@HiveField(5) int get durationMs;
/// Create a copy of ConnectionStat
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ConnectionStatCopyWith<ConnectionStat> get copyWith => _$ConnectionStatCopyWithImpl<ConnectionStat>(this as ConnectionStat, _$identity);
/// Serializes this ConnectionStat to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ConnectionStat&&(identical(other.serverId, serverId) || other.serverId == serverId)&&(identical(other.serverName, serverName) || other.serverName == serverName)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)&&(identical(other.result, result) || other.result == result)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.durationMs, durationMs) || other.durationMs == durationMs));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,serverId,serverName,timestamp,result,errorMessage,durationMs);
@override
String toString() {
return 'ConnectionStat(serverId: $serverId, serverName: $serverName, timestamp: $timestamp, result: $result, errorMessage: $errorMessage, durationMs: $durationMs)';
}
}
/// @nodoc
abstract mixin class $ConnectionStatCopyWith<$Res> {
factory $ConnectionStatCopyWith(ConnectionStat value, $Res Function(ConnectionStat) _then) = _$ConnectionStatCopyWithImpl;
@useResult
$Res call({
@HiveField(0) String serverId,@HiveField(1) String serverName,@HiveField(2) DateTime timestamp,@HiveField(3) ConnectionResult result,@HiveField(4) String errorMessage,@HiveField(5) int durationMs
});
}
/// @nodoc
class _$ConnectionStatCopyWithImpl<$Res>
implements $ConnectionStatCopyWith<$Res> {
_$ConnectionStatCopyWithImpl(this._self, this._then);
final ConnectionStat _self;
final $Res Function(ConnectionStat) _then;
/// Create a copy of ConnectionStat
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? serverId = null,Object? serverName = null,Object? timestamp = null,Object? result = null,Object? errorMessage = null,Object? durationMs = null,}) {
return _then(_self.copyWith(
serverId: null == serverId ? _self.serverId : serverId // ignore: cast_nullable_to_non_nullable
as String,serverName: null == serverName ? _self.serverName : serverName // ignore: cast_nullable_to_non_nullable
as String,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable
as DateTime,result: null == result ? _self.result : result // ignore: cast_nullable_to_non_nullable
as ConnectionResult,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
as String,durationMs: null == durationMs ? _self.durationMs : durationMs // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// Adds pattern-matching-related methods to [ConnectionStat].
extension ConnectionStatPatterns on ConnectionStat {
/// 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( _ConnectionStat value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ConnectionStat() 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( _ConnectionStat value) $default,){
final _that = this;
switch (_that) {
case _ConnectionStat():
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( _ConnectionStat value)? $default,){
final _that = this;
switch (_that) {
case _ConnectionStat() 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(@HiveField(0) String serverId, @HiveField(1) String serverName, @HiveField(2) DateTime timestamp, @HiveField(3) ConnectionResult result, @HiveField(4) String errorMessage, @HiveField(5) int durationMs)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ConnectionStat() when $default != null:
return $default(_that.serverId,_that.serverName,_that.timestamp,_that.result,_that.errorMessage,_that.durationMs);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(@HiveField(0) String serverId, @HiveField(1) String serverName, @HiveField(2) DateTime timestamp, @HiveField(3) ConnectionResult result, @HiveField(4) String errorMessage, @HiveField(5) int durationMs) $default,) {final _that = this;
switch (_that) {
case _ConnectionStat():
return $default(_that.serverId,_that.serverName,_that.timestamp,_that.result,_that.errorMessage,_that.durationMs);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(@HiveField(0) String serverId, @HiveField(1) String serverName, @HiveField(2) DateTime timestamp, @HiveField(3) ConnectionResult result, @HiveField(4) String errorMessage, @HiveField(5) int durationMs)? $default,) {final _that = this;
switch (_that) {
case _ConnectionStat() when $default != null:
return $default(_that.serverId,_that.serverName,_that.timestamp,_that.result,_that.errorMessage,_that.durationMs);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _ConnectionStat implements ConnectionStat {
const _ConnectionStat({@HiveField(0) required this.serverId, @HiveField(1) required this.serverName, @HiveField(2) required this.timestamp, @HiveField(3) required this.result, @HiveField(4) this.errorMessage = '', @HiveField(5) required this.durationMs});
factory _ConnectionStat.fromJson(Map<String, dynamic> json) => _$ConnectionStatFromJson(json);
@override@HiveField(0) final String serverId;
@override@HiveField(1) final String serverName;
@override@HiveField(2) final DateTime timestamp;
@override@HiveField(3) final ConnectionResult result;
@override@JsonKey()@HiveField(4) final String errorMessage;
@override@HiveField(5) final int durationMs;
/// Create a copy of ConnectionStat
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ConnectionStatCopyWith<_ConnectionStat> get copyWith => __$ConnectionStatCopyWithImpl<_ConnectionStat>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$ConnectionStatToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ConnectionStat&&(identical(other.serverId, serverId) || other.serverId == serverId)&&(identical(other.serverName, serverName) || other.serverName == serverName)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)&&(identical(other.result, result) || other.result == result)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.durationMs, durationMs) || other.durationMs == durationMs));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,serverId,serverName,timestamp,result,errorMessage,durationMs);
@override
String toString() {
return 'ConnectionStat(serverId: $serverId, serverName: $serverName, timestamp: $timestamp, result: $result, errorMessage: $errorMessage, durationMs: $durationMs)';
}
}
/// @nodoc
abstract mixin class _$ConnectionStatCopyWith<$Res> implements $ConnectionStatCopyWith<$Res> {
factory _$ConnectionStatCopyWith(_ConnectionStat value, $Res Function(_ConnectionStat) _then) = __$ConnectionStatCopyWithImpl;
@override @useResult
$Res call({
@HiveField(0) String serverId,@HiveField(1) String serverName,@HiveField(2) DateTime timestamp,@HiveField(3) ConnectionResult result,@HiveField(4) String errorMessage,@HiveField(5) int durationMs
});
}
/// @nodoc
class __$ConnectionStatCopyWithImpl<$Res>
implements _$ConnectionStatCopyWith<$Res> {
__$ConnectionStatCopyWithImpl(this._self, this._then);
final _ConnectionStat _self;
final $Res Function(_ConnectionStat) _then;
/// Create a copy of ConnectionStat
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? serverId = null,Object? serverName = null,Object? timestamp = null,Object? result = null,Object? errorMessage = null,Object? durationMs = null,}) {
return _then(_ConnectionStat(
serverId: null == serverId ? _self.serverId : serverId // ignore: cast_nullable_to_non_nullable
as String,serverName: null == serverName ? _self.serverName : serverName // ignore: cast_nullable_to_non_nullable
as String,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable
as DateTime,result: null == result ? _self.result : result // ignore: cast_nullable_to_non_nullable
as ConnectionResult,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
as String,durationMs: null == durationMs ? _self.durationMs : durationMs // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// @nodoc
mixin _$ServerConnectionStats {
@HiveField(0) String get serverId;@HiveField(1) String get serverName;@HiveField(2) int get totalAttempts;@HiveField(3) int get successCount;@HiveField(4) int get failureCount;@HiveField(5) DateTime? get lastSuccessTime;@HiveField(6) DateTime? get lastFailureTime;@HiveField(7) List<ConnectionStat> get recentConnections;@HiveField(8) double get successRate;
/// Create a copy of ServerConnectionStats
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ServerConnectionStatsCopyWith<ServerConnectionStats> get copyWith => _$ServerConnectionStatsCopyWithImpl<ServerConnectionStats>(this as ServerConnectionStats, _$identity);
/// Serializes this ServerConnectionStats to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ServerConnectionStats&&(identical(other.serverId, serverId) || other.serverId == serverId)&&(identical(other.serverName, serverName) || other.serverName == serverName)&&(identical(other.totalAttempts, totalAttempts) || other.totalAttempts == totalAttempts)&&(identical(other.successCount, successCount) || other.successCount == successCount)&&(identical(other.failureCount, failureCount) || other.failureCount == failureCount)&&(identical(other.lastSuccessTime, lastSuccessTime) || other.lastSuccessTime == lastSuccessTime)&&(identical(other.lastFailureTime, lastFailureTime) || other.lastFailureTime == lastFailureTime)&&const DeepCollectionEquality().equals(other.recentConnections, recentConnections)&&(identical(other.successRate, successRate) || other.successRate == successRate));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,serverId,serverName,totalAttempts,successCount,failureCount,lastSuccessTime,lastFailureTime,const DeepCollectionEquality().hash(recentConnections),successRate);
@override
String toString() {
return 'ServerConnectionStats(serverId: $serverId, serverName: $serverName, totalAttempts: $totalAttempts, successCount: $successCount, failureCount: $failureCount, lastSuccessTime: $lastSuccessTime, lastFailureTime: $lastFailureTime, recentConnections: $recentConnections, successRate: $successRate)';
}
}
/// @nodoc
abstract mixin class $ServerConnectionStatsCopyWith<$Res> {
factory $ServerConnectionStatsCopyWith(ServerConnectionStats value, $Res Function(ServerConnectionStats) _then) = _$ServerConnectionStatsCopyWithImpl;
@useResult
$Res call({
@HiveField(0) String serverId,@HiveField(1) String serverName,@HiveField(2) int totalAttempts,@HiveField(3) int successCount,@HiveField(4) int failureCount,@HiveField(5) DateTime? lastSuccessTime,@HiveField(6) DateTime? lastFailureTime,@HiveField(7) List<ConnectionStat> recentConnections,@HiveField(8) double successRate
});
}
/// @nodoc
class _$ServerConnectionStatsCopyWithImpl<$Res>
implements $ServerConnectionStatsCopyWith<$Res> {
_$ServerConnectionStatsCopyWithImpl(this._self, this._then);
final ServerConnectionStats _self;
final $Res Function(ServerConnectionStats) _then;
/// Create a copy of ServerConnectionStats
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? serverId = null,Object? serverName = null,Object? totalAttempts = null,Object? successCount = null,Object? failureCount = null,Object? lastSuccessTime = freezed,Object? lastFailureTime = freezed,Object? recentConnections = null,Object? successRate = null,}) {
return _then(_self.copyWith(
serverId: null == serverId ? _self.serverId : serverId // ignore: cast_nullable_to_non_nullable
as String,serverName: null == serverName ? _self.serverName : serverName // ignore: cast_nullable_to_non_nullable
as String,totalAttempts: null == totalAttempts ? _self.totalAttempts : totalAttempts // ignore: cast_nullable_to_non_nullable
as int,successCount: null == successCount ? _self.successCount : successCount // ignore: cast_nullable_to_non_nullable
as int,failureCount: null == failureCount ? _self.failureCount : failureCount // ignore: cast_nullable_to_non_nullable
as int,lastSuccessTime: freezed == lastSuccessTime ? _self.lastSuccessTime : lastSuccessTime // ignore: cast_nullable_to_non_nullable
as DateTime?,lastFailureTime: freezed == lastFailureTime ? _self.lastFailureTime : lastFailureTime // ignore: cast_nullable_to_non_nullable
as DateTime?,recentConnections: null == recentConnections ? _self.recentConnections : recentConnections // ignore: cast_nullable_to_non_nullable
as List<ConnectionStat>,successRate: null == successRate ? _self.successRate : successRate // ignore: cast_nullable_to_non_nullable
as double,
));
}
}
/// Adds pattern-matching-related methods to [ServerConnectionStats].
extension ServerConnectionStatsPatterns on ServerConnectionStats {
/// 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( _ServerConnectionStats value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ServerConnectionStats() 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( _ServerConnectionStats value) $default,){
final _that = this;
switch (_that) {
case _ServerConnectionStats():
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( _ServerConnectionStats value)? $default,){
final _that = this;
switch (_that) {
case _ServerConnectionStats() 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(@HiveField(0) String serverId, @HiveField(1) String serverName, @HiveField(2) int totalAttempts, @HiveField(3) int successCount, @HiveField(4) int failureCount, @HiveField(5) DateTime? lastSuccessTime, @HiveField(6) DateTime? lastFailureTime, @HiveField(7) List<ConnectionStat> recentConnections, @HiveField(8) double successRate)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ServerConnectionStats() when $default != null:
return $default(_that.serverId,_that.serverName,_that.totalAttempts,_that.successCount,_that.failureCount,_that.lastSuccessTime,_that.lastFailureTime,_that.recentConnections,_that.successRate);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(@HiveField(0) String serverId, @HiveField(1) String serverName, @HiveField(2) int totalAttempts, @HiveField(3) int successCount, @HiveField(4) int failureCount, @HiveField(5) DateTime? lastSuccessTime, @HiveField(6) DateTime? lastFailureTime, @HiveField(7) List<ConnectionStat> recentConnections, @HiveField(8) double successRate) $default,) {final _that = this;
switch (_that) {
case _ServerConnectionStats():
return $default(_that.serverId,_that.serverName,_that.totalAttempts,_that.successCount,_that.failureCount,_that.lastSuccessTime,_that.lastFailureTime,_that.recentConnections,_that.successRate);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(@HiveField(0) String serverId, @HiveField(1) String serverName, @HiveField(2) int totalAttempts, @HiveField(3) int successCount, @HiveField(4) int failureCount, @HiveField(5) DateTime? lastSuccessTime, @HiveField(6) DateTime? lastFailureTime, @HiveField(7) List<ConnectionStat> recentConnections, @HiveField(8) double successRate)? $default,) {final _that = this;
switch (_that) {
case _ServerConnectionStats() when $default != null:
return $default(_that.serverId,_that.serverName,_that.totalAttempts,_that.successCount,_that.failureCount,_that.lastSuccessTime,_that.lastFailureTime,_that.recentConnections,_that.successRate);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _ServerConnectionStats implements ServerConnectionStats {
const _ServerConnectionStats({@HiveField(0) required this.serverId, @HiveField(1) required this.serverName, @HiveField(2) required this.totalAttempts, @HiveField(3) required this.successCount, @HiveField(4) required this.failureCount, @HiveField(5) this.lastSuccessTime = null, @HiveField(6) this.lastFailureTime = null, @HiveField(7) final List<ConnectionStat> recentConnections = const [], @HiveField(8) required this.successRate}): _recentConnections = recentConnections;
factory _ServerConnectionStats.fromJson(Map<String, dynamic> json) => _$ServerConnectionStatsFromJson(json);
@override@HiveField(0) final String serverId;
@override@HiveField(1) final String serverName;
@override@HiveField(2) final int totalAttempts;
@override@HiveField(3) final int successCount;
@override@HiveField(4) final int failureCount;
@override@JsonKey()@HiveField(5) final DateTime? lastSuccessTime;
@override@JsonKey()@HiveField(6) final DateTime? lastFailureTime;
final List<ConnectionStat> _recentConnections;
@override@JsonKey()@HiveField(7) List<ConnectionStat> get recentConnections {
if (_recentConnections is EqualUnmodifiableListView) return _recentConnections;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_recentConnections);
}
@override@HiveField(8) final double successRate;
/// Create a copy of ServerConnectionStats
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ServerConnectionStatsCopyWith<_ServerConnectionStats> get copyWith => __$ServerConnectionStatsCopyWithImpl<_ServerConnectionStats>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$ServerConnectionStatsToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ServerConnectionStats&&(identical(other.serverId, serverId) || other.serverId == serverId)&&(identical(other.serverName, serverName) || other.serverName == serverName)&&(identical(other.totalAttempts, totalAttempts) || other.totalAttempts == totalAttempts)&&(identical(other.successCount, successCount) || other.successCount == successCount)&&(identical(other.failureCount, failureCount) || other.failureCount == failureCount)&&(identical(other.lastSuccessTime, lastSuccessTime) || other.lastSuccessTime == lastSuccessTime)&&(identical(other.lastFailureTime, lastFailureTime) || other.lastFailureTime == lastFailureTime)&&const DeepCollectionEquality().equals(other._recentConnections, _recentConnections)&&(identical(other.successRate, successRate) || other.successRate == successRate));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,serverId,serverName,totalAttempts,successCount,failureCount,lastSuccessTime,lastFailureTime,const DeepCollectionEquality().hash(_recentConnections),successRate);
@override
String toString() {
return 'ServerConnectionStats(serverId: $serverId, serverName: $serverName, totalAttempts: $totalAttempts, successCount: $successCount, failureCount: $failureCount, lastSuccessTime: $lastSuccessTime, lastFailureTime: $lastFailureTime, recentConnections: $recentConnections, successRate: $successRate)';
}
}
/// @nodoc
abstract mixin class _$ServerConnectionStatsCopyWith<$Res> implements $ServerConnectionStatsCopyWith<$Res> {
factory _$ServerConnectionStatsCopyWith(_ServerConnectionStats value, $Res Function(_ServerConnectionStats) _then) = __$ServerConnectionStatsCopyWithImpl;
@override @useResult
$Res call({
@HiveField(0) String serverId,@HiveField(1) String serverName,@HiveField(2) int totalAttempts,@HiveField(3) int successCount,@HiveField(4) int failureCount,@HiveField(5) DateTime? lastSuccessTime,@HiveField(6) DateTime? lastFailureTime,@HiveField(7) List<ConnectionStat> recentConnections,@HiveField(8) double successRate
});
}
/// @nodoc
class __$ServerConnectionStatsCopyWithImpl<$Res>
implements _$ServerConnectionStatsCopyWith<$Res> {
__$ServerConnectionStatsCopyWithImpl(this._self, this._then);
final _ServerConnectionStats _self;
final $Res Function(_ServerConnectionStats) _then;
/// Create a copy of ServerConnectionStats
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? serverId = null,Object? serverName = null,Object? totalAttempts = null,Object? successCount = null,Object? failureCount = null,Object? lastSuccessTime = freezed,Object? lastFailureTime = freezed,Object? recentConnections = null,Object? successRate = null,}) {
return _then(_ServerConnectionStats(
serverId: null == serverId ? _self.serverId : serverId // ignore: cast_nullable_to_non_nullable
as String,serverName: null == serverName ? _self.serverName : serverName // ignore: cast_nullable_to_non_nullable
as String,totalAttempts: null == totalAttempts ? _self.totalAttempts : totalAttempts // ignore: cast_nullable_to_non_nullable
as int,successCount: null == successCount ? _self.successCount : successCount // ignore: cast_nullable_to_non_nullable
as int,failureCount: null == failureCount ? _self.failureCount : failureCount // ignore: cast_nullable_to_non_nullable
as int,lastSuccessTime: freezed == lastSuccessTime ? _self.lastSuccessTime : lastSuccessTime // ignore: cast_nullable_to_non_nullable
as DateTime?,lastFailureTime: freezed == lastFailureTime ? _self.lastFailureTime : lastFailureTime // ignore: cast_nullable_to_non_nullable
as DateTime?,recentConnections: null == recentConnections ? _self._recentConnections : recentConnections // ignore: cast_nullable_to_non_nullable
as List<ConnectionStat>,successRate: null == successRate ? _self.successRate : successRate // ignore: cast_nullable_to_non_nullable
as double,
));
}
}
// dart format on

View File

@@ -0,0 +1,233 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'connection_stat.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ConnectionStatAdapter extends TypeAdapter<ConnectionStat> {
@override
final typeId = 100;
@override
ConnectionStat read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ConnectionStat(
serverId: fields[0] as String,
serverName: fields[1] as String,
timestamp: fields[2] as DateTime,
result: fields[3] as ConnectionResult,
errorMessage: fields[4] == null ? '' : fields[4] as String,
durationMs: (fields[5] as num).toInt(),
);
}
@override
void write(BinaryWriter writer, ConnectionStat obj) {
writer
..writeByte(6)
..writeByte(0)
..write(obj.serverId)
..writeByte(1)
..write(obj.serverName)
..writeByte(2)
..write(obj.timestamp)
..writeByte(3)
..write(obj.result)
..writeByte(4)
..write(obj.errorMessage)
..writeByte(5)
..write(obj.durationMs);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ConnectionStatAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class ServerConnectionStatsAdapter extends TypeAdapter<ServerConnectionStats> {
@override
final typeId = 101;
@override
ServerConnectionStats read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ServerConnectionStats(
serverId: fields[0] as String,
serverName: fields[1] as String,
totalAttempts: (fields[2] as num).toInt(),
successCount: (fields[3] as num).toInt(),
failureCount: (fields[4] as num).toInt(),
lastSuccessTime: fields[5] == null ? null : fields[5] as DateTime?,
lastFailureTime: fields[6] == null ? null : fields[6] as DateTime?,
recentConnections: fields[7] == null
? []
: (fields[7] as List).cast<ConnectionStat>(),
successRate: (fields[8] as num).toDouble(),
);
}
@override
void write(BinaryWriter writer, ServerConnectionStats obj) {
writer
..writeByte(9)
..writeByte(0)
..write(obj.serverId)
..writeByte(1)
..write(obj.serverName)
..writeByte(2)
..write(obj.totalAttempts)
..writeByte(3)
..write(obj.successCount)
..writeByte(4)
..write(obj.failureCount)
..writeByte(5)
..write(obj.lastSuccessTime)
..writeByte(6)
..write(obj.lastFailureTime)
..writeByte(7)
..write(obj.recentConnections)
..writeByte(8)
..write(obj.successRate);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ServerConnectionStatsAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class ConnectionResultAdapter extends TypeAdapter<ConnectionResult> {
@override
final typeId = 102;
@override
ConnectionResult read(BinaryReader reader) {
switch (reader.readByte()) {
case 0:
return ConnectionResult.success;
case 1:
return ConnectionResult.timeout;
case 2:
return ConnectionResult.authFailed;
case 3:
return ConnectionResult.networkError;
case 4:
return ConnectionResult.unknownError;
default:
return ConnectionResult.success;
}
}
@override
void write(BinaryWriter writer, ConnectionResult obj) {
switch (obj) {
case ConnectionResult.success:
writer.writeByte(0);
case ConnectionResult.timeout:
writer.writeByte(1);
case ConnectionResult.authFailed:
writer.writeByte(2);
case ConnectionResult.networkError:
writer.writeByte(3);
case ConnectionResult.unknownError:
writer.writeByte(4);
}
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ConnectionResultAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_ConnectionStat _$ConnectionStatFromJson(Map<String, dynamic> json) =>
_ConnectionStat(
serverId: json['serverId'] as String,
serverName: json['serverName'] as String,
timestamp: DateTime.parse(json['timestamp'] as String),
result: $enumDecode(_$ConnectionResultEnumMap, json['result']),
errorMessage: json['errorMessage'] as String? ?? '',
durationMs: (json['durationMs'] as num).toInt(),
);
Map<String, dynamic> _$ConnectionStatToJson(_ConnectionStat instance) =>
<String, dynamic>{
'serverId': instance.serverId,
'serverName': instance.serverName,
'timestamp': instance.timestamp.toIso8601String(),
'result': _$ConnectionResultEnumMap[instance.result]!,
'errorMessage': instance.errorMessage,
'durationMs': instance.durationMs,
};
const _$ConnectionResultEnumMap = {
ConnectionResult.success: 'success',
ConnectionResult.timeout: 'timeout',
ConnectionResult.authFailed: 'auth_failed',
ConnectionResult.networkError: 'network_error',
ConnectionResult.unknownError: 'unknown_error',
};
_ServerConnectionStats _$ServerConnectionStatsFromJson(
Map<String, dynamic> json,
) => _ServerConnectionStats(
serverId: json['serverId'] as String,
serverName: json['serverName'] as String,
totalAttempts: (json['totalAttempts'] as num).toInt(),
successCount: (json['successCount'] as num).toInt(),
failureCount: (json['failureCount'] as num).toInt(),
lastSuccessTime: json['lastSuccessTime'] == null
? null
: DateTime.parse(json['lastSuccessTime'] as String),
lastFailureTime: json['lastFailureTime'] == null
? null
: DateTime.parse(json['lastFailureTime'] as String),
recentConnections:
(json['recentConnections'] as List<dynamic>?)
?.map((e) => ConnectionStat.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
successRate: (json['successRate'] as num).toDouble(),
);
Map<String, dynamic> _$ServerConnectionStatsToJson(
_ServerConnectionStats instance,
) => <String, dynamic>{
'serverId': instance.serverId,
'serverName': instance.serverName,
'totalAttempts': instance.totalAttempts,
'successCount': instance.successCount,
'failureCount': instance.failureCount,
'lastSuccessTime': instance.lastSuccessTime?.toIso8601String(),
'lastFailureTime': instance.lastFailureTime?.toIso8601String(),
'recentConnections': instance.recentConnections,
'successRate': instance.successRate,
};

View File

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

View File

@@ -0,0 +1,49 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'discovery_result.freezed.dart';
part 'discovery_result.g.dart';
@freezed
abstract class SshDiscoveryResult with _$SshDiscoveryResult {
const factory SshDiscoveryResult({
required String ip,
required int port,
String? banner,
@Default(false) bool isSelected,
}) = _SshDiscoveryResult;
factory SshDiscoveryResult.fromJson(Map<String, dynamic> json) => _$SshDiscoveryResultFromJson(json);
}
@freezed
abstract class SshDiscoveryReport with _$SshDiscoveryReport {
const factory SshDiscoveryReport({
required String generatedAt,
required int durationMs,
required int count,
required List<SshDiscoveryResult> items,
}) = _SshDiscoveryReport;
factory SshDiscoveryReport.fromJson(Map<String, dynamic> json) => _$SshDiscoveryReportFromJson(json);
}
@freezed
abstract class SshDiscoveryConfig with _$SshDiscoveryConfig {
const factory SshDiscoveryConfig({
@Default(700) int timeoutMs,
@Default(128) int maxConcurrency,
@Default(false) bool enableMdns,
@Default(4096) int hostEnumerationLimit,
}) = _SshDiscoveryConfig;
}
extension SshDiscoveryConfigX on SshDiscoveryConfig {
List<String> toArgs() {
final args = <String>[];
args.add('--timeout-ms=$timeoutMs');
args.add('--max-concurrency=$maxConcurrency');
args.add('--host-enumeration-limit=$hostEnumerationLimit');
if (enableMdns) args.add('--enable-mdns');
return args;
}
}

View File

@@ -0,0 +1,830 @@
// 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 'discovery_result.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$SshDiscoveryResult {
String get ip; int get port; String? get banner; bool get isSelected;
/// Create a copy of SshDiscoveryResult
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SshDiscoveryResultCopyWith<SshDiscoveryResult> get copyWith => _$SshDiscoveryResultCopyWithImpl<SshDiscoveryResult>(this as SshDiscoveryResult, _$identity);
/// Serializes this SshDiscoveryResult to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SshDiscoveryResult&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.port, port) || other.port == port)&&(identical(other.banner, banner) || other.banner == banner)&&(identical(other.isSelected, isSelected) || other.isSelected == isSelected));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,ip,port,banner,isSelected);
@override
String toString() {
return 'SshDiscoveryResult(ip: $ip, port: $port, banner: $banner, isSelected: $isSelected)';
}
}
/// @nodoc
abstract mixin class $SshDiscoveryResultCopyWith<$Res> {
factory $SshDiscoveryResultCopyWith(SshDiscoveryResult value, $Res Function(SshDiscoveryResult) _then) = _$SshDiscoveryResultCopyWithImpl;
@useResult
$Res call({
String ip, int port, String? banner, bool isSelected
});
}
/// @nodoc
class _$SshDiscoveryResultCopyWithImpl<$Res>
implements $SshDiscoveryResultCopyWith<$Res> {
_$SshDiscoveryResultCopyWithImpl(this._self, this._then);
final SshDiscoveryResult _self;
final $Res Function(SshDiscoveryResult) _then;
/// Create a copy of SshDiscoveryResult
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? ip = null,Object? port = null,Object? banner = freezed,Object? isSelected = null,}) {
return _then(_self.copyWith(
ip: null == ip ? _self.ip : ip // ignore: cast_nullable_to_non_nullable
as String,port: null == port ? _self.port : port // ignore: cast_nullable_to_non_nullable
as int,banner: freezed == banner ? _self.banner : banner // ignore: cast_nullable_to_non_nullable
as String?,isSelected: null == isSelected ? _self.isSelected : isSelected // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// Adds pattern-matching-related methods to [SshDiscoveryResult].
extension SshDiscoveryResultPatterns on SshDiscoveryResult {
/// 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( _SshDiscoveryResult value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _SshDiscoveryResult() 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( _SshDiscoveryResult value) $default,){
final _that = this;
switch (_that) {
case _SshDiscoveryResult():
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( _SshDiscoveryResult value)? $default,){
final _that = this;
switch (_that) {
case _SshDiscoveryResult() 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 ip, int port, String? banner, bool isSelected)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SshDiscoveryResult() when $default != null:
return $default(_that.ip,_that.port,_that.banner,_that.isSelected);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 ip, int port, String? banner, bool isSelected) $default,) {final _that = this;
switch (_that) {
case _SshDiscoveryResult():
return $default(_that.ip,_that.port,_that.banner,_that.isSelected);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 ip, int port, String? banner, bool isSelected)? $default,) {final _that = this;
switch (_that) {
case _SshDiscoveryResult() when $default != null:
return $default(_that.ip,_that.port,_that.banner,_that.isSelected);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _SshDiscoveryResult implements SshDiscoveryResult {
const _SshDiscoveryResult({required this.ip, required this.port, this.banner, this.isSelected = false});
factory _SshDiscoveryResult.fromJson(Map<String, dynamic> json) => _$SshDiscoveryResultFromJson(json);
@override final String ip;
@override final int port;
@override final String? banner;
@override@JsonKey() final bool isSelected;
/// Create a copy of SshDiscoveryResult
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SshDiscoveryResultCopyWith<_SshDiscoveryResult> get copyWith => __$SshDiscoveryResultCopyWithImpl<_SshDiscoveryResult>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SshDiscoveryResultToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SshDiscoveryResult&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.port, port) || other.port == port)&&(identical(other.banner, banner) || other.banner == banner)&&(identical(other.isSelected, isSelected) || other.isSelected == isSelected));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,ip,port,banner,isSelected);
@override
String toString() {
return 'SshDiscoveryResult(ip: $ip, port: $port, banner: $banner, isSelected: $isSelected)';
}
}
/// @nodoc
abstract mixin class _$SshDiscoveryResultCopyWith<$Res> implements $SshDiscoveryResultCopyWith<$Res> {
factory _$SshDiscoveryResultCopyWith(_SshDiscoveryResult value, $Res Function(_SshDiscoveryResult) _then) = __$SshDiscoveryResultCopyWithImpl;
@override @useResult
$Res call({
String ip, int port, String? banner, bool isSelected
});
}
/// @nodoc
class __$SshDiscoveryResultCopyWithImpl<$Res>
implements _$SshDiscoveryResultCopyWith<$Res> {
__$SshDiscoveryResultCopyWithImpl(this._self, this._then);
final _SshDiscoveryResult _self;
final $Res Function(_SshDiscoveryResult) _then;
/// Create a copy of SshDiscoveryResult
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? ip = null,Object? port = null,Object? banner = freezed,Object? isSelected = null,}) {
return _then(_SshDiscoveryResult(
ip: null == ip ? _self.ip : ip // ignore: cast_nullable_to_non_nullable
as String,port: null == port ? _self.port : port // ignore: cast_nullable_to_non_nullable
as int,banner: freezed == banner ? _self.banner : banner // ignore: cast_nullable_to_non_nullable
as String?,isSelected: null == isSelected ? _self.isSelected : isSelected // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// @nodoc
mixin _$SshDiscoveryReport {
String get generatedAt; int get durationMs; int get count; List<SshDiscoveryResult> get items;
/// Create a copy of SshDiscoveryReport
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SshDiscoveryReportCopyWith<SshDiscoveryReport> get copyWith => _$SshDiscoveryReportCopyWithImpl<SshDiscoveryReport>(this as SshDiscoveryReport, _$identity);
/// Serializes this SshDiscoveryReport to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SshDiscoveryReport&&(identical(other.generatedAt, generatedAt) || other.generatedAt == generatedAt)&&(identical(other.durationMs, durationMs) || other.durationMs == durationMs)&&(identical(other.count, count) || other.count == count)&&const DeepCollectionEquality().equals(other.items, items));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,generatedAt,durationMs,count,const DeepCollectionEquality().hash(items));
@override
String toString() {
return 'SshDiscoveryReport(generatedAt: $generatedAt, durationMs: $durationMs, count: $count, items: $items)';
}
}
/// @nodoc
abstract mixin class $SshDiscoveryReportCopyWith<$Res> {
factory $SshDiscoveryReportCopyWith(SshDiscoveryReport value, $Res Function(SshDiscoveryReport) _then) = _$SshDiscoveryReportCopyWithImpl;
@useResult
$Res call({
String generatedAt, int durationMs, int count, List<SshDiscoveryResult> items
});
}
/// @nodoc
class _$SshDiscoveryReportCopyWithImpl<$Res>
implements $SshDiscoveryReportCopyWith<$Res> {
_$SshDiscoveryReportCopyWithImpl(this._self, this._then);
final SshDiscoveryReport _self;
final $Res Function(SshDiscoveryReport) _then;
/// Create a copy of SshDiscoveryReport
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? generatedAt = null,Object? durationMs = null,Object? count = null,Object? items = null,}) {
return _then(_self.copyWith(
generatedAt: null == generatedAt ? _self.generatedAt : generatedAt // ignore: cast_nullable_to_non_nullable
as String,durationMs: null == durationMs ? _self.durationMs : durationMs // ignore: cast_nullable_to_non_nullable
as int,count: null == count ? _self.count : count // ignore: cast_nullable_to_non_nullable
as int,items: null == items ? _self.items : items // ignore: cast_nullable_to_non_nullable
as List<SshDiscoveryResult>,
));
}
}
/// Adds pattern-matching-related methods to [SshDiscoveryReport].
extension SshDiscoveryReportPatterns on SshDiscoveryReport {
/// 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( _SshDiscoveryReport value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _SshDiscoveryReport() 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( _SshDiscoveryReport value) $default,){
final _that = this;
switch (_that) {
case _SshDiscoveryReport():
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( _SshDiscoveryReport value)? $default,){
final _that = this;
switch (_that) {
case _SshDiscoveryReport() 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 generatedAt, int durationMs, int count, List<SshDiscoveryResult> items)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SshDiscoveryReport() when $default != null:
return $default(_that.generatedAt,_that.durationMs,_that.count,_that.items);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 generatedAt, int durationMs, int count, List<SshDiscoveryResult> items) $default,) {final _that = this;
switch (_that) {
case _SshDiscoveryReport():
return $default(_that.generatedAt,_that.durationMs,_that.count,_that.items);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 generatedAt, int durationMs, int count, List<SshDiscoveryResult> items)? $default,) {final _that = this;
switch (_that) {
case _SshDiscoveryReport() when $default != null:
return $default(_that.generatedAt,_that.durationMs,_that.count,_that.items);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _SshDiscoveryReport implements SshDiscoveryReport {
const _SshDiscoveryReport({required this.generatedAt, required this.durationMs, required this.count, required final List<SshDiscoveryResult> items}): _items = items;
factory _SshDiscoveryReport.fromJson(Map<String, dynamic> json) => _$SshDiscoveryReportFromJson(json);
@override final String generatedAt;
@override final int durationMs;
@override final int count;
final List<SshDiscoveryResult> _items;
@override List<SshDiscoveryResult> get items {
if (_items is EqualUnmodifiableListView) return _items;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_items);
}
/// Create a copy of SshDiscoveryReport
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SshDiscoveryReportCopyWith<_SshDiscoveryReport> get copyWith => __$SshDiscoveryReportCopyWithImpl<_SshDiscoveryReport>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SshDiscoveryReportToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SshDiscoveryReport&&(identical(other.generatedAt, generatedAt) || other.generatedAt == generatedAt)&&(identical(other.durationMs, durationMs) || other.durationMs == durationMs)&&(identical(other.count, count) || other.count == count)&&const DeepCollectionEquality().equals(other._items, _items));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,generatedAt,durationMs,count,const DeepCollectionEquality().hash(_items));
@override
String toString() {
return 'SshDiscoveryReport(generatedAt: $generatedAt, durationMs: $durationMs, count: $count, items: $items)';
}
}
/// @nodoc
abstract mixin class _$SshDiscoveryReportCopyWith<$Res> implements $SshDiscoveryReportCopyWith<$Res> {
factory _$SshDiscoveryReportCopyWith(_SshDiscoveryReport value, $Res Function(_SshDiscoveryReport) _then) = __$SshDiscoveryReportCopyWithImpl;
@override @useResult
$Res call({
String generatedAt, int durationMs, int count, List<SshDiscoveryResult> items
});
}
/// @nodoc
class __$SshDiscoveryReportCopyWithImpl<$Res>
implements _$SshDiscoveryReportCopyWith<$Res> {
__$SshDiscoveryReportCopyWithImpl(this._self, this._then);
final _SshDiscoveryReport _self;
final $Res Function(_SshDiscoveryReport) _then;
/// Create a copy of SshDiscoveryReport
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? generatedAt = null,Object? durationMs = null,Object? count = null,Object? items = null,}) {
return _then(_SshDiscoveryReport(
generatedAt: null == generatedAt ? _self.generatedAt : generatedAt // ignore: cast_nullable_to_non_nullable
as String,durationMs: null == durationMs ? _self.durationMs : durationMs // ignore: cast_nullable_to_non_nullable
as int,count: null == count ? _self.count : count // ignore: cast_nullable_to_non_nullable
as int,items: null == items ? _self._items : items // ignore: cast_nullable_to_non_nullable
as List<SshDiscoveryResult>,
));
}
}
/// @nodoc
mixin _$SshDiscoveryConfig {
int get timeoutMs; int get maxConcurrency; bool get enableMdns; int get hostEnumerationLimit;
/// Create a copy of SshDiscoveryConfig
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SshDiscoveryConfigCopyWith<SshDiscoveryConfig> get copyWith => _$SshDiscoveryConfigCopyWithImpl<SshDiscoveryConfig>(this as SshDiscoveryConfig, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SshDiscoveryConfig&&(identical(other.timeoutMs, timeoutMs) || other.timeoutMs == timeoutMs)&&(identical(other.maxConcurrency, maxConcurrency) || other.maxConcurrency == maxConcurrency)&&(identical(other.enableMdns, enableMdns) || other.enableMdns == enableMdns)&&(identical(other.hostEnumerationLimit, hostEnumerationLimit) || other.hostEnumerationLimit == hostEnumerationLimit));
}
@override
int get hashCode => Object.hash(runtimeType,timeoutMs,maxConcurrency,enableMdns,hostEnumerationLimit);
@override
String toString() {
return 'SshDiscoveryConfig(timeoutMs: $timeoutMs, maxConcurrency: $maxConcurrency, enableMdns: $enableMdns, hostEnumerationLimit: $hostEnumerationLimit)';
}
}
/// @nodoc
abstract mixin class $SshDiscoveryConfigCopyWith<$Res> {
factory $SshDiscoveryConfigCopyWith(SshDiscoveryConfig value, $Res Function(SshDiscoveryConfig) _then) = _$SshDiscoveryConfigCopyWithImpl;
@useResult
$Res call({
int timeoutMs, int maxConcurrency, bool enableMdns, int hostEnumerationLimit
});
}
/// @nodoc
class _$SshDiscoveryConfigCopyWithImpl<$Res>
implements $SshDiscoveryConfigCopyWith<$Res> {
_$SshDiscoveryConfigCopyWithImpl(this._self, this._then);
final SshDiscoveryConfig _self;
final $Res Function(SshDiscoveryConfig) _then;
/// Create a copy of SshDiscoveryConfig
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? timeoutMs = null,Object? maxConcurrency = null,Object? enableMdns = null,Object? hostEnumerationLimit = null,}) {
return _then(_self.copyWith(
timeoutMs: null == timeoutMs ? _self.timeoutMs : timeoutMs // ignore: cast_nullable_to_non_nullable
as int,maxConcurrency: null == maxConcurrency ? _self.maxConcurrency : maxConcurrency // ignore: cast_nullable_to_non_nullable
as int,enableMdns: null == enableMdns ? _self.enableMdns : enableMdns // ignore: cast_nullable_to_non_nullable
as bool,hostEnumerationLimit: null == hostEnumerationLimit ? _self.hostEnumerationLimit : hostEnumerationLimit // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// Adds pattern-matching-related methods to [SshDiscoveryConfig].
extension SshDiscoveryConfigPatterns on SshDiscoveryConfig {
/// 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( _SshDiscoveryConfig value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _SshDiscoveryConfig() 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( _SshDiscoveryConfig value) $default,){
final _that = this;
switch (_that) {
case _SshDiscoveryConfig():
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( _SshDiscoveryConfig value)? $default,){
final _that = this;
switch (_that) {
case _SshDiscoveryConfig() 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( int timeoutMs, int maxConcurrency, bool enableMdns, int hostEnumerationLimit)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SshDiscoveryConfig() when $default != null:
return $default(_that.timeoutMs,_that.maxConcurrency,_that.enableMdns,_that.hostEnumerationLimit);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( int timeoutMs, int maxConcurrency, bool enableMdns, int hostEnumerationLimit) $default,) {final _that = this;
switch (_that) {
case _SshDiscoveryConfig():
return $default(_that.timeoutMs,_that.maxConcurrency,_that.enableMdns,_that.hostEnumerationLimit);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( int timeoutMs, int maxConcurrency, bool enableMdns, int hostEnumerationLimit)? $default,) {final _that = this;
switch (_that) {
case _SshDiscoveryConfig() when $default != null:
return $default(_that.timeoutMs,_that.maxConcurrency,_that.enableMdns,_that.hostEnumerationLimit);case _:
return null;
}
}
}
/// @nodoc
class _SshDiscoveryConfig implements SshDiscoveryConfig {
const _SshDiscoveryConfig({this.timeoutMs = 700, this.maxConcurrency = 128, this.enableMdns = false, this.hostEnumerationLimit = 4096});
@override@JsonKey() final int timeoutMs;
@override@JsonKey() final int maxConcurrency;
@override@JsonKey() final bool enableMdns;
@override@JsonKey() final int hostEnumerationLimit;
/// Create a copy of SshDiscoveryConfig
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SshDiscoveryConfigCopyWith<_SshDiscoveryConfig> get copyWith => __$SshDiscoveryConfigCopyWithImpl<_SshDiscoveryConfig>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SshDiscoveryConfig&&(identical(other.timeoutMs, timeoutMs) || other.timeoutMs == timeoutMs)&&(identical(other.maxConcurrency, maxConcurrency) || other.maxConcurrency == maxConcurrency)&&(identical(other.enableMdns, enableMdns) || other.enableMdns == enableMdns)&&(identical(other.hostEnumerationLimit, hostEnumerationLimit) || other.hostEnumerationLimit == hostEnumerationLimit));
}
@override
int get hashCode => Object.hash(runtimeType,timeoutMs,maxConcurrency,enableMdns,hostEnumerationLimit);
@override
String toString() {
return 'SshDiscoveryConfig(timeoutMs: $timeoutMs, maxConcurrency: $maxConcurrency, enableMdns: $enableMdns, hostEnumerationLimit: $hostEnumerationLimit)';
}
}
/// @nodoc
abstract mixin class _$SshDiscoveryConfigCopyWith<$Res> implements $SshDiscoveryConfigCopyWith<$Res> {
factory _$SshDiscoveryConfigCopyWith(_SshDiscoveryConfig value, $Res Function(_SshDiscoveryConfig) _then) = __$SshDiscoveryConfigCopyWithImpl;
@override @useResult
$Res call({
int timeoutMs, int maxConcurrency, bool enableMdns, int hostEnumerationLimit
});
}
/// @nodoc
class __$SshDiscoveryConfigCopyWithImpl<$Res>
implements _$SshDiscoveryConfigCopyWith<$Res> {
__$SshDiscoveryConfigCopyWithImpl(this._self, this._then);
final _SshDiscoveryConfig _self;
final $Res Function(_SshDiscoveryConfig) _then;
/// Create a copy of SshDiscoveryConfig
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? timeoutMs = null,Object? maxConcurrency = null,Object? enableMdns = null,Object? hostEnumerationLimit = null,}) {
return _then(_SshDiscoveryConfig(
timeoutMs: null == timeoutMs ? _self.timeoutMs : timeoutMs // ignore: cast_nullable_to_non_nullable
as int,maxConcurrency: null == maxConcurrency ? _self.maxConcurrency : maxConcurrency // ignore: cast_nullable_to_non_nullable
as int,enableMdns: null == enableMdns ? _self.enableMdns : enableMdns // ignore: cast_nullable_to_non_nullable
as bool,hostEnumerationLimit: null == hostEnumerationLimit ? _self.hostEnumerationLimit : hostEnumerationLimit // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
// dart format on

View File

@@ -0,0 +1,41 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'discovery_result.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_SshDiscoveryResult _$SshDiscoveryResultFromJson(Map<String, dynamic> json) =>
_SshDiscoveryResult(
ip: json['ip'] as String,
port: (json['port'] as num).toInt(),
banner: json['banner'] as String?,
isSelected: json['isSelected'] as bool? ?? false,
);
Map<String, dynamic> _$SshDiscoveryResultToJson(_SshDiscoveryResult instance) =>
<String, dynamic>{
'ip': instance.ip,
'port': instance.port,
'banner': instance.banner,
'isSelected': instance.isSelected,
};
_SshDiscoveryReport _$SshDiscoveryReportFromJson(Map<String, dynamic> json) =>
_SshDiscoveryReport(
generatedAt: json['generatedAt'] as String,
durationMs: (json['durationMs'] as num).toInt(),
count: (json['count'] as num).toInt(),
items: (json['items'] as List<dynamic>)
.map((e) => SshDiscoveryResult.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$SshDiscoveryReportToJson(_SshDiscoveryReport instance) =>
<String, dynamic>{
'generatedAt': instance.generatedAt,
'durationMs': instance.durationMs,
'count': instance.count,
'items': instance.items,
};

View File

@@ -44,22 +44,49 @@ class Disk with EquatableMixin {
static List<Disk> parse(String raw) { static List<Disk> parse(String raw) {
final list = <Disk>[]; final list = <Disk>[];
raw = raw.trim(); raw = raw.trim();
try {
if (raw.startsWith('{')) { if (raw.isEmpty) {
// Parse JSON output from lsblk command dprint('Empty disk info data received');
final Map<String, dynamic> jsonData = json.decode(raw); return list;
final List<dynamic> blockdevices = jsonData['blockdevices'] ?? []; }
for (final device in blockdevices) { try {
// Process each device // Check if we have lsblk JSON output with success marker
_processTopLevelDevice(device, list); if (raw.startsWith('{')) {
// Extract JSON part (excluding the success marker if present)
final jsonEnd = raw.indexOf('\nLSBLK_SUCCESS');
final jsonPart = jsonEnd > 0 ? raw.substring(0, jsonEnd) : raw;
try {
final Map<String, dynamic> jsonData = json.decode(jsonPart);
final List<dynamic> blockdevices = jsonData['blockdevices'] ?? [];
for (final device in blockdevices) {
// Process each device
_processTopLevelDevice(device, list);
}
// If we successfully parsed JSON and have valid disks, return them
if (list.isNotEmpty) {
return list;
}
} on FormatException catch (e) {
Loggers.app.warning('JSON parsing failed, falling back to df -k output: $e');
} catch (e) {
Loggers.app.warning('Error processing JSON disk data, falling back to df -k output: $e', e);
} }
} else { }
// Fallback to the old parsing method in case of non-JSON output
// Check if we have df -k output (fallback case)
if (raw.contains('Filesystem') && raw.contains('Mounted on')) {
return _parseWithOldMethod(raw); return _parseWithOldMethod(raw);
} }
// If we reach here, both parsing methods failed
Loggers.app.warning('Unable to parse disk info with any method');
} catch (e) { } catch (e) {
Loggers.app.warning('Failed to parse disk info: $e', e); Loggers.app.warning('Failed to parse disk info with both methods: $e', e);
} }
return list; return list;
} }
@@ -88,6 +115,32 @@ class Disk with EquatableMixin {
} }
} }
/// Parse filesystem fields from device data
static ({BigInt size, BigInt used, BigInt avail, int usedPercent}) _parseFilesystemFields(Map<String, dynamic> device) {
// Helper function to parse size strings safely
BigInt parseSize(String? sizeStr) {
if (sizeStr == null || sizeStr.isEmpty || sizeStr == 'null' || sizeStr == '0') {
return BigInt.zero;
}
return (BigInt.tryParse(sizeStr) ?? BigInt.zero) ~/ BigInt.from(1024);
}
// Helper function to parse percentage strings
int parsePercent(String? percentStr) {
if (percentStr == null || percentStr.isEmpty || percentStr == 'null') {
return 0;
}
return int.tryParse(percentStr.replaceAll('%', '')) ?? 0;
}
return (
size: parseSize(device['fssize']?.toString()),
used: parseSize(device['fsused']?.toString()),
avail: parseSize(device['fsavail']?.toString()),
usedPercent: parsePercent(device['fsuse%']?.toString()),
);
}
/// Process a single device without recursively processing its children /// Process a single device without recursively processing its children
static Disk? _processSingleDevice(Map<String, dynamic> device) { static Disk? _processSingleDevice(Map<String, dynamic> device) {
final fstype = device['fstype']?.toString(); final fstype = device['fstype']?.toString();
@@ -102,20 +155,7 @@ class Disk with EquatableMixin {
return null; return null;
} }
final sizeStr = device['fssize']?.toString() ?? '0'; final fsFields = _parseFilesystemFields(device);
final size = (BigInt.tryParse(sizeStr) ?? BigInt.zero) ~/ BigInt.from(1024);
final usedStr = device['fsused']?.toString() ?? '0';
final used = (BigInt.tryParse(usedStr) ?? BigInt.zero) ~/ BigInt.from(1024);
final availStr = device['fsavail']?.toString() ?? '0';
final avail = (BigInt.tryParse(availStr) ?? BigInt.zero) ~/ BigInt.from(1024);
// Parse fsuse% which is usually in the format "45%"
String usePercentStr = device['fsuse%']?.toString() ?? '0';
usePercentStr = usePercentStr.replaceAll('%', '');
final usedPercent = int.tryParse(usePercentStr) ?? 0;
final name = device['name']?.toString(); final name = device['name']?.toString();
final kname = device['kname']?.toString(); final kname = device['kname']?.toString();
final uuid = device['uuid']?.toString(); final uuid = device['uuid']?.toString();
@@ -124,10 +164,10 @@ class Disk with EquatableMixin {
path: path, path: path,
fsTyp: fstype, fsTyp: fstype,
mount: mountpoint, mount: mountpoint,
usedPercent: usedPercent, usedPercent: fsFields.usedPercent,
used: used, used: fsFields.used,
size: size, size: fsFields.size,
avail: avail, avail: fsFields.avail,
name: name, name: name,
kname: kname, kname: kname,
uuid: uuid, uuid: uuid,
@@ -155,20 +195,7 @@ class Disk with EquatableMixin {
// Handle common filesystem cases or parent devices with children // Handle common filesystem cases or parent devices with children
if ((fstype != null && _shouldCalc(fstype, mount)) || (childDisks.isNotEmpty && path.isNotEmpty)) { if ((fstype != null && _shouldCalc(fstype, mount)) || (childDisks.isNotEmpty && path.isNotEmpty)) {
final sizeStr = device['fssize']?.toString() ?? '0'; final fsFields = _parseFilesystemFields(device);
final size = (BigInt.tryParse(sizeStr) ?? BigInt.zero) ~/ BigInt.from(1024);
final usedStr = device['fsused']?.toString() ?? '0';
final used = (BigInt.tryParse(usedStr) ?? BigInt.zero) ~/ BigInt.from(1024);
final availStr = device['fsavail']?.toString() ?? '0';
final avail = (BigInt.tryParse(availStr) ?? BigInt.zero) ~/ BigInt.from(1024);
// Parse fsuse% which is usually in the format "45%"
String usePercentStr = device['fsuse%']?.toString() ?? '0';
usePercentStr = usePercentStr.replaceAll('%', '');
final usedPercent = int.tryParse(usePercentStr) ?? 0;
final name = device['name']?.toString(); final name = device['name']?.toString();
final kname = device['kname']?.toString(); final kname = device['kname']?.toString();
final uuid = device['uuid']?.toString(); final uuid = device['uuid']?.toString();
@@ -177,10 +204,10 @@ class Disk with EquatableMixin {
path: path, path: path,
fsTyp: fstype, fsTyp: fstype,
mount: mount, mount: mount,
usedPercent: usedPercent, usedPercent: fsFields.usedPercent,
used: used, used: fsFields.used,
size: size, size: fsFields.size,
avail: avail, avail: fsFields.avail,
name: name, name: name,
kname: kname, kname: kname,
uuid: uuid, uuid: uuid,
@@ -253,7 +280,7 @@ class Disk with EquatableMixin {
]; ];
} }
class DiskIO extends TimeSeq<List<DiskIOPiece>> { class DiskIO extends TimeSeq<DiskIOPiece> {
DiskIO(super.init1, super.init2); DiskIO(super.init1, super.init2);
@override @override

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
import 'package:dartssh2/dartssh2.dart';
import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/data/model/app/scripts/cmd_types.dart'; import 'package:server_box/data/model/app/scripts/cmd_types.dart';
import 'package:server_box/data/model/server/amd.dart'; import 'package:server_box/data/model/server/amd.dart';
@@ -11,25 +10,9 @@ import 'package:server_box/data/model/server/memory.dart';
import 'package:server_box/data/model/server/net_speed.dart'; import 'package:server_box/data/model/server/net_speed.dart';
import 'package:server_box/data/model/server/nvdia.dart'; import 'package:server_box/data/model/server/nvdia.dart';
import 'package:server_box/data/model/server/sensors.dart'; import 'package:server_box/data/model/server/sensors.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/system.dart';
import 'package:server_box/data/model/server/temp.dart'; import 'package:server_box/data/model/server/temp.dart';
class Server {
Spi spi;
ServerStatus status;
SSHClient? client;
ServerConn conn;
Server(this.spi, this.status, this.conn, {this.client});
bool get needGenClient => conn < ServerConn.connecting;
bool get canViewDetails => conn == ServerConn.finished;
String get id => spi.id;
}
class ServerStatus { class ServerStatus {
Cpus cpu; Cpus cpu;
Memory mem; Memory mem;

View File

@@ -1,13 +1,12 @@
import 'dart:convert'; import 'dart:convert';
import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:server_box/data/model/app/error.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/custom.dart';
import 'package:server_box/data/model/server/server.dart';
import 'package:server_box/data/model/server/system.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/model/server/wol_cfg.dart';
import 'package:server_box/data/provider/server.dart';
import 'package:server_box/data/store/server.dart'; import 'package:server_box/data/store/server.dart';
part 'server_private_info.freezed.dart'; part 'server_private_info.freezed.dart';
@@ -37,8 +36,15 @@ abstract class Spi with _$Spi {
String? alterUrl, String? alterUrl,
@Default(true) bool autoConnect, @Default(true) bool autoConnect,
/// [id] of the jump server /// [id] of the jump server (legacy, single hop)
///
/// Migrated to [jumpChainIds].
String? jumpId, String? jumpId,
/// Jump chain hop ids (nearest -> farthest)
///
/// Preferred over [jumpId].
@JsonKey(includeIfNull: false) List<String>? jumpChainIds,
ServerCustom? custom, ServerCustom? custom,
WakeOnLanCfg? wolCfg, WakeOnLanCfg? wolCfg,
@@ -58,6 +64,7 @@ abstract class Spi with _$Spi {
@override @override
String toString() => 'Spi<$oldId>'; String toString() => 'Spi<$oldId>';
/// Parse the [id], if it's null or empty, generate a new one.
static String parseId(Object? id) { static String parseId(Object? id) {
if (id == null || id is! String || id.isEmpty) return ShortId.generate(); if (id == null || id is! String || id.isEmpty) return ShortId.generate();
return id; return id;
@@ -80,28 +87,35 @@ extension Spix on Spi {
String? migrateId() { String? migrateId() {
if (id.isNotEmpty) return null; if (id.isNotEmpty) return null;
ServerStore.instance.delete(oldId); ServerStore.instance.delete(oldId);
final newSpi = copyWith(id: ShortId.generate()); final newSpi = copyWith(
id: ShortId.generate(),
jumpChainIds: jumpChainIds ?? (jumpId == null ? null : [jumpId!]),
);
newSpi.save(); newSpi.save();
return newSpi.id; return newSpi.id;
} }
/// Json encode to string.
String toJsonString() => json.encode(toJson()); String toJsonString() => json.encode(toJson());
VNode<Server>? get server => ServerProvider.pick(spi: this); /// Returns true if the connection info is the same as [other].
VNode<Server>? get jumpServer => ServerProvider.pick(id: jumpId); bool isSameAs(Spi other) {
return user == other.user &&
bool shouldReconnect(Spi old) { ip == other.ip &&
return user != old.user || port == other.port &&
ip != old.ip || pwd == other.pwd &&
port != old.port || keyId == other.keyId &&
pwd != old.pwd || jumpId == other.jumpId &&
keyId != old.keyId || listEquals(jumpChainIds, other.jumpChainIds);
alterUrl != old.alterUrl ||
jumpId != old.jumpId ||
custom?.cmds != old.custom?.cmds;
} }
(String ip, String usr, int port) fromStringUrl() { /// Returns true if the connection should be re-established.
bool shouldReconnect(Spi old) {
return !isSameAs(old) || alterUrl != old.alterUrl || custom?.cmds != old.custom?.cmds;
}
/// Parse the [alterUrl] to (ip, user, port).
(String ip, String usr, int port) parseAlterUrl() {
if (alterUrl == null) { if (alterUrl == null) {
throw SSHErr(type: SSHErrType.connect, message: 'alterUrl is null'); throw SSHErr(type: SSHErrType.connect, message: 'alterUrl is null');
} }
@@ -135,7 +149,7 @@ extension Spix on Spi {
tags: ['tag1', 'tag2'], tags: ['tag1', 'tag2'],
alterUrl: 'user@ip:port', alterUrl: 'user@ip:port',
autoConnect: true, autoConnect: true,
jumpId: 'jump_server_id', jumpChainIds: ['jump_server_id'],
custom: ServerCustom( custom: ServerCustom(
pveAddr: 'http://localhost:8006', pveAddr: 'http://localhost:8006',
pveIgnoreCert: false, pveIgnoreCert: false,
@@ -146,5 +160,6 @@ extension Spix on Spi {
id: 'id', id: 'id',
); );
/// Returns true if the user is 'root'.
bool get isRoot => user == 'root'; bool get isRoot => user == 'root';
} }

View File

@@ -16,8 +16,13 @@ T _$identity<T>(T value) => value;
mixin _$Spi { mixin _$Spi {
String get name; String get ip; int get port; String get user; String? get pwd;/// [id] of private key 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 @JsonKey(name: 'pubKeyId') String? get keyId; List<String>? get tags; String? get alterUrl; bool get autoConnect;/// [id] of the jump server (legacy, single hop)
String? get jumpId; ServerCustom? get custom; WakeOnLanCfg? get wolCfg;/// It only applies to SSH terminal. ///
/// 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. Map<String, String>? get envs;@JsonKey(fromJson: Spi.parseId) String get id;/// Custom system type (unix or windows). If set, skip auto-detection.
@JsonKey(includeIfNull: false) SystemType? get customSystemType;/// Disabled command types for this server @JsonKey(includeIfNull: false) SystemType? get customSystemType;/// Disabled command types for this server
@JsonKey(includeIfNull: false) List<String>? get disabledCmdTypes; @JsonKey(includeIfNull: false) List<String>? get disabledCmdTypes;
@@ -33,12 +38,12 @@ $SpiCopyWith<Spi> get copyWith => _$SpiCopyWithImpl<Spi>(this as Spi, _$identity
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is Spi&&(identical(other.name, name) || other.name == name)&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.port, port) || other.port == port)&&(identical(other.user, user) || other.user == user)&&(identical(other.pwd, pwd) || other.pwd == pwd)&&(identical(other.keyId, keyId) || other.keyId == keyId)&&const DeepCollectionEquality().equals(other.tags, tags)&&(identical(other.alterUrl, alterUrl) || other.alterUrl == alterUrl)&&(identical(other.autoConnect, autoConnect) || other.autoConnect == autoConnect)&&(identical(other.jumpId, jumpId) || other.jumpId == jumpId)&&(identical(other.custom, custom) || other.custom == custom)&&(identical(other.wolCfg, wolCfg) || other.wolCfg == wolCfg)&&const DeepCollectionEquality().equals(other.envs, envs)&&(identical(other.id, id) || other.id == id)&&(identical(other.customSystemType, customSystemType) || other.customSystemType == customSystemType)&&const DeepCollectionEquality().equals(other.disabledCmdTypes, disabledCmdTypes)); return identical(this, other) || (other.runtimeType == runtimeType&&other is Spi&&(identical(other.name, name) || other.name == name)&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.port, port) || other.port == port)&&(identical(other.user, user) || other.user == user)&&(identical(other.pwd, pwd) || other.pwd == pwd)&&(identical(other.keyId, keyId) || other.keyId == keyId)&&const DeepCollectionEquality().equals(other.tags, tags)&&(identical(other.alterUrl, alterUrl) || other.alterUrl == alterUrl)&&(identical(other.autoConnect, autoConnect) || other.autoConnect == autoConnect)&&(identical(other.jumpId, jumpId) || other.jumpId == jumpId)&&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) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,name,ip,port,user,pwd,keyId,const DeepCollectionEquality().hash(tags),alterUrl,autoConnect,jumpId,custom,wolCfg,const DeepCollectionEquality().hash(envs),id,customSystemType,const DeepCollectionEquality().hash(disabledCmdTypes)); int get hashCode => Object.hash(runtimeType,name,ip,port,user,pwd,keyId,const DeepCollectionEquality().hash(tags),alterUrl,autoConnect,jumpId,const DeepCollectionEquality().hash(jumpChainIds),custom,wolCfg,const DeepCollectionEquality().hash(envs),id,customSystemType,const DeepCollectionEquality().hash(disabledCmdTypes));
@@ -49,7 +54,7 @@ abstract mixin class $SpiCopyWith<$Res> {
factory $SpiCopyWith(Spi value, $Res Function(Spi) _then) = _$SpiCopyWithImpl; factory $SpiCopyWith(Spi value, $Res Function(Spi) _then) = _$SpiCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
String name, String ip, int port, String user, String? pwd,@JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs,@JsonKey(fromJson: Spi.parseId) String id,@JsonKey(includeIfNull: false) SystemType? customSystemType,@JsonKey(includeIfNull: false) List<String>? disabledCmdTypes String name, String ip, int port, String user, String? pwd,@JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId,@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
}); });
@@ -66,7 +71,7 @@ class _$SpiCopyWithImpl<$Res>
/// Create a copy of Spi /// Create a copy of Spi
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? ip = null,Object? port = null,Object? user = null,Object? pwd = freezed,Object? keyId = freezed,Object? tags = freezed,Object? alterUrl = freezed,Object? autoConnect = null,Object? jumpId = freezed,Object? custom = freezed,Object? wolCfg = freezed,Object? envs = freezed,Object? id = null,Object? customSystemType = freezed,Object? disabledCmdTypes = freezed,}) { @pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? ip = null,Object? port = null,Object? user = null,Object? pwd = freezed,Object? keyId = freezed,Object? tags = freezed,Object? alterUrl = freezed,Object? autoConnect = null,Object? jumpId = freezed,Object? jumpChainIds = freezed,Object? custom = freezed,Object? wolCfg = freezed,Object? envs = freezed,Object? id = null,Object? customSystemType = freezed,Object? disabledCmdTypes = freezed,}) {
return _then(_self.copyWith( return _then(_self.copyWith(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable 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 as String,ip: null == ip ? _self.ip : ip // ignore: cast_nullable_to_non_nullable
@@ -78,7 +83,8 @@ 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 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 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 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 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 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 Map<String, String>?,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
@@ -169,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, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List<String>? disabledCmdTypes)? $default,{required TResult orElse(),}) {final _that = this; @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, @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) { switch (_that) {
case _Spi() when $default != null: case _Spi() when $default != null:
return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes);case _: return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.jumpChainIds,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes);case _:
return orElse(); return orElse();
} }
@@ -190,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, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List<String>? disabledCmdTypes) $default,) {final _that = this; @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, @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) { switch (_that) {
case _Spi(): case _Spi():
return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes);case _: return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.jumpChainIds,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes);case _:
throw StateError('Unexpected subclass'); throw StateError('Unexpected subclass');
} }
@@ -210,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, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List<String>? disabledCmdTypes)? $default,) {final _that = this; @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, @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) { switch (_that) {
case _Spi() when $default != null: case _Spi() when $default != null:
return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes);case _: return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.jumpChainIds,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes);case _:
return null; return null;
} }
@@ -225,7 +231,7 @@ return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,
@JsonSerializable(includeIfNull: false) @JsonSerializable(includeIfNull: false)
class _Spi extends Spi { class _Spi extends Spi {
const _Spi({required this.name, required this.ip, required this.port, required this.user, this.pwd, @JsonKey(name: 'pubKeyId') this.keyId, final List<String>? tags, this.alterUrl, this.autoConnect = true, this.jumpId, this.custom, this.wolCfg, final Map<String, String>? envs, @JsonKey(fromJson: Spi.parseId) this.id = '', @JsonKey(includeIfNull: false) this.customSystemType, @JsonKey(includeIfNull: false) final List<String>? disabledCmdTypes}): _tags = tags,_envs = envs,_disabledCmdTypes = disabledCmdTypes,super._(); const _Spi({required this.name, required this.ip, required this.port, required this.user, this.pwd, @JsonKey(name: 'pubKeyId') this.keyId, final List<String>? tags, this.alterUrl, this.autoConnect = true, this.jumpId, @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); factory _Spi.fromJson(Map<String, dynamic> json) => _$SpiFromJson(json);
@override final String name; @override final String name;
@@ -246,8 +252,25 @@ class _Spi extends Spi {
@override final String? alterUrl; @override final String? alterUrl;
@override@JsonKey() final bool autoConnect; @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; @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 ServerCustom? custom;
@override final WakeOnLanCfg? wolCfg; @override final WakeOnLanCfg? wolCfg;
/// It only applies to SSH terminal. /// It only applies to SSH terminal.
@@ -289,12 +312,12 @@ Map<String, dynamic> toJson() {
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Spi&&(identical(other.name, name) || other.name == name)&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.port, port) || other.port == port)&&(identical(other.user, user) || other.user == user)&&(identical(other.pwd, pwd) || other.pwd == pwd)&&(identical(other.keyId, keyId) || other.keyId == keyId)&&const DeepCollectionEquality().equals(other._tags, _tags)&&(identical(other.alterUrl, alterUrl) || other.alterUrl == alterUrl)&&(identical(other.autoConnect, autoConnect) || other.autoConnect == autoConnect)&&(identical(other.jumpId, jumpId) || other.jumpId == jumpId)&&(identical(other.custom, custom) || other.custom == custom)&&(identical(other.wolCfg, wolCfg) || other.wolCfg == wolCfg)&&const DeepCollectionEquality().equals(other._envs, _envs)&&(identical(other.id, id) || other.id == id)&&(identical(other.customSystemType, customSystemType) || other.customSystemType == customSystemType)&&const DeepCollectionEquality().equals(other._disabledCmdTypes, _disabledCmdTypes)); return identical(this, other) || (other.runtimeType == runtimeType&&other is _Spi&&(identical(other.name, name) || other.name == name)&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.port, port) || other.port == port)&&(identical(other.user, user) || other.user == user)&&(identical(other.pwd, pwd) || other.pwd == pwd)&&(identical(other.keyId, keyId) || other.keyId == keyId)&&const DeepCollectionEquality().equals(other._tags, _tags)&&(identical(other.alterUrl, alterUrl) || other.alterUrl == alterUrl)&&(identical(other.autoConnect, autoConnect) || other.autoConnect == autoConnect)&&(identical(other.jumpId, jumpId) || other.jumpId == jumpId)&&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) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,name,ip,port,user,pwd,keyId,const DeepCollectionEquality().hash(_tags),alterUrl,autoConnect,jumpId,custom,wolCfg,const DeepCollectionEquality().hash(_envs),id,customSystemType,const DeepCollectionEquality().hash(_disabledCmdTypes)); int get hashCode => Object.hash(runtimeType,name,ip,port,user,pwd,keyId,const DeepCollectionEquality().hash(_tags),alterUrl,autoConnect,jumpId,const DeepCollectionEquality().hash(_jumpChainIds),custom,wolCfg,const DeepCollectionEquality().hash(_envs),id,customSystemType,const DeepCollectionEquality().hash(_disabledCmdTypes));
@@ -305,7 +328,7 @@ abstract mixin class _$SpiCopyWith<$Res> implements $SpiCopyWith<$Res> {
factory _$SpiCopyWith(_Spi value, $Res Function(_Spi) _then) = __$SpiCopyWithImpl; factory _$SpiCopyWith(_Spi value, $Res Function(_Spi) _then) = __$SpiCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
String name, String ip, int port, String user, String? pwd,@JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs,@JsonKey(fromJson: Spi.parseId) String id,@JsonKey(includeIfNull: false) SystemType? customSystemType,@JsonKey(includeIfNull: false) List<String>? disabledCmdTypes String name, String ip, int port, String user, String? pwd,@JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId,@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
}); });
@@ -322,7 +345,7 @@ class __$SpiCopyWithImpl<$Res>
/// Create a copy of Spi /// Create a copy of Spi
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? ip = null,Object? port = null,Object? user = null,Object? pwd = freezed,Object? keyId = freezed,Object? tags = freezed,Object? alterUrl = freezed,Object? autoConnect = null,Object? jumpId = freezed,Object? custom = freezed,Object? wolCfg = freezed,Object? envs = freezed,Object? id = null,Object? customSystemType = freezed,Object? disabledCmdTypes = freezed,}) { @override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? ip = null,Object? port = null,Object? user = null,Object? pwd = freezed,Object? keyId = freezed,Object? tags = freezed,Object? alterUrl = freezed,Object? autoConnect = null,Object? jumpId = freezed,Object? jumpChainIds = freezed,Object? custom = freezed,Object? wolCfg = freezed,Object? envs = freezed,Object? id = null,Object? customSystemType = freezed,Object? disabledCmdTypes = freezed,}) {
return _then(_Spi( return _then(_Spi(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable 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 as String,ip: null == ip ? _self.ip : ip // ignore: cast_nullable_to_non_nullable
@@ -334,7 +357,8 @@ 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 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 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 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 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 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 Map<String, String>?,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable

View File

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

View File

@@ -104,6 +104,11 @@ Future<ServerStatus> _getLinuxStatus(ServerStatusUpdateReq req) async {
try { try {
req.ss.disk = Disk.parse(StatusCmdType.disk.findInMap(parsedOutput)); req.ss.disk = Disk.parse(StatusCmdType.disk.findInMap(parsedOutput));
} catch (e, s) {
Loggers.app.warning(e, s);
}
try {
req.ss.diskUsage = DiskUsage.parse(req.ss.disk); req.ss.diskUsage = DiskUsage.parse(req.ss.disk);
} catch (e, s) { } catch (e, s) {
Loggers.app.warning(e, s); Loggers.app.warning(e, s);
@@ -294,7 +299,9 @@ String? _parseSysVer(String raw) {
String? _parseHostName(String raw) { String? _parseHostName(String raw) {
if (raw.isEmpty) return null; if (raw.isEmpty) return null;
if (raw.contains(ScriptConstants.scriptFile)) return null; if (raw.contains(ScriptConstants.scriptFile)) return null;
return raw; final trimmed = raw.trim();
if (trimmed.isEmpty) return null;
return trimmed;
} }
// Windows status parsing implementation // Windows status parsing implementation
@@ -371,18 +378,27 @@ void _parseWindowsCpuData(ServerStatusUpdateReq req, Map<String, String> parsedO
// Windows CPU parsing - JSON format from PowerShell // Windows CPU parsing - JSON format from PowerShell
final cpuRaw = WindowsStatusCmdType.cpu.findInMap(parsedOutput); final cpuRaw = WindowsStatusCmdType.cpu.findInMap(parsedOutput);
if (cpuRaw.isNotEmpty && cpuRaw != 'null' && !cpuRaw.contains('error') && !cpuRaw.contains('Exception')) { if (cpuRaw.isNotEmpty && cpuRaw != 'null' && !cpuRaw.contains('error') && !cpuRaw.contains('Exception')) {
final cpus = WindowsParser.parseCpu(cpuRaw, req.ss); final cpuResult = WindowsParser.parseCpu(cpuRaw, req.ss);
if (cpus.isNotEmpty) { if (cpuResult.cores.isNotEmpty) {
req.ss.cpu.update(cpus); 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) { } catch (e, s) {
Loggers.app.warning('Windows CPU parsing failed: $e', s); Loggers.app.warning('Windows CPU parsing failed: $e', s);
} }
@@ -420,8 +436,11 @@ void _parseWindowsDiskData(ServerStatusUpdateReq req, Map<String, String> parsed
/// Parse Windows uptime data /// Parse Windows uptime data
void _parseWindowsUptimeData(ServerStatusUpdateReq req, Map<String, String> parsedOutput) { void _parseWindowsUptimeData(ServerStatusUpdateReq req, Map<String, String> parsedOutput) {
try { try {
final uptime = WindowsParser.parseUpTime(WindowsStatusCmdType.uptime.findInMap(parsedOutput)); final uptimeRaw = WindowsStatusCmdType.uptime.findInMap(parsedOutput);
if (uptime != null) { 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; req.ss.more[StatusCmdType.uptime] = uptime;
} }
} catch (e, s) { } catch (e, s) {
@@ -534,38 +553,36 @@ List<NetSpeedPart> _parseWindowsNetwork(String raw, int currentTime) {
final dynamic jsonData = json.decode(raw); final dynamic jsonData = json.decode(raw);
final List<NetSpeedPart> netParts = []; final List<NetSpeedPart> netParts = [];
// PowerShell Get-Counter returns a structure with CounterSamples if (jsonData is List && jsonData.length >= 2) {
if (jsonData is Map && jsonData.containsKey('CounterSamples')) { var sample1 = jsonData[jsonData.length - 2];
final samples = jsonData['CounterSamples'] as List?; var sample2 = jsonData[jsonData.length - 1];
if (samples != null && samples.length >= 2) { if (sample1 is Map && sample1.containsKey('value')) {
// We need 2 samples to calculate speed (interval between them) sample1 = sample1['value'];
final Map<String, double> interfaceRx = {}; }
final Map<String, double> interfaceTx = {}; if (sample2 is Map && sample2.containsKey('value')) {
sample2 = sample2['value'];
for (final sample in samples) { }
final path = sample['Path']?.toString() ?? ''; if (sample1 is List && sample2 is List && sample1.length == sample2.length) {
final cookedValue = sample['CookedValue'] as num? ?? 0; for (int i = 0; i < sample1.length; i++) {
final s1 = sample1[i];
if (path.contains('Bytes Received/sec')) { final s2 = sample2[i];
final interfaceName = _extractInterfaceName(path); final name = s1['Name']?.toString() ?? '';
if (interfaceName.isNotEmpty) { if (name.isEmpty || name == '_Total') continue;
interfaceRx[interfaceName] = cookedValue.toDouble(); final rx1 = (s1['BytesReceivedPersec'] as num?)?.toDouble() ?? 0;
} final rx2 = (s2['BytesReceivedPersec'] as num?)?.toDouble() ?? 0;
} else if (path.contains('Bytes Sent/sec')) { final tx1 = (s1['BytesSentPersec'] as num?)?.toDouble() ?? 0;
final interfaceName = _extractInterfaceName(path); final tx2 = (s2['BytesSentPersec'] as num?)?.toDouble() ?? 0;
if (interfaceName.isNotEmpty) { final time1 = (s1['Timestamp_Sys100NS'] as num?)?.toDouble() ?? 0;
interfaceTx[interfaceName] = cookedValue.toDouble(); 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;
// Create NetSpeedPart for each interface if (rxDelta < 0 || txDelta < 0) continue;
for (final interfaceName in interfaceRx.keys) { final rxSpeed = rxDelta / timeDelta;
final rx = interfaceRx[interfaceName] ?? 0; final txSpeed = txDelta / timeDelta;
final tx = interfaceTx[interfaceName] ?? 0;
netParts.add( netParts.add(
NetSpeedPart(interfaceName, BigInt.from(rx.toInt()), BigInt.from(tx.toInt()), currentTime), NetSpeedPart(name, BigInt.from(rxSpeed.toInt()), BigInt.from(txSpeed.toInt()), currentTime),
); );
} }
} }
@@ -577,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) { List<DiskIOPiece> _parseWindowsDiskIO(String raw, int currentTime) {
try { try {
final dynamic jsonData = json.decode(raw); final dynamic jsonData = json.decode(raw);
final List<DiskIOPiece> diskParts = []; final List<DiskIOPiece> diskParts = [];
// PowerShell Get-Counter returns a structure with CounterSamples if (jsonData is List && jsonData.length >= 2) {
if (jsonData is Map && jsonData.containsKey('CounterSamples')) { var sample1 = jsonData[jsonData.length - 2];
final samples = jsonData['CounterSamples'] as List?; var sample2 = jsonData[jsonData.length - 1];
if (samples != null) { if (sample1 is Map && sample1.containsKey('value')) {
final Map<String, double> diskReads = {}; sample1 = sample1['value'];
final Map<String, double> diskWrites = {}; }
if (sample2 is Map && sample2.containsKey('value')) {
for (final sample in samples) { sample2 = sample2['value'];
final path = sample['Path']?.toString() ?? ''; }
final cookedValue = sample['CookedValue'] as num? ?? 0; if (sample1 is List && sample2 is List && sample1.length == sample2.length) {
for (int i = 0; i < sample1.length; i++) {
if (path.contains('Disk Read Bytes/sec')) { final s1 = sample1[i];
final diskName = _extractDiskName(path); final s2 = sample2[i];
if (diskName.isNotEmpty) { final name = s1['Name']?.toString() ?? '';
diskReads[diskName] = cookedValue.toDouble(); if (name.isEmpty || name == '_Total') continue;
} final read1 = (s1['DiskReadBytesPersec'] as num?)?.toDouble() ?? 0;
} else if (path.contains('Disk Write Bytes/sec')) { final read2 = (s2['DiskReadBytesPersec'] as num?)?.toDouble() ?? 0;
final diskName = _extractDiskName(path); final write1 = (s1['DiskWriteBytesPersec'] as num?)?.toDouble() ?? 0;
if (diskName.isNotEmpty) { final write2 = (s2['DiskWriteBytesPersec'] as num?)?.toDouble() ?? 0;
diskWrites[diskName] = cookedValue.toDouble(); 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;
// Create DiskIOPiece for each disk - convert bytes to sectors final writeDelta = write2 - write1;
// (assuming 512 bytes per sector) if (readDelta < 0 || writeDelta < 0) continue;
for (final diskName in diskReads.keys) { final readSpeed = readDelta / timeDelta;
final readBytes = diskReads[diskName] ?? 0; final writeSpeed = writeDelta / timeDelta;
final writeBytes = diskWrites[diskName] ?? 0; final sectorsRead = (readSpeed / 512).round();
final sectorsRead = (readBytes / 512).round(); final sectorsWrite = (writeSpeed / 512).round();
final sectorsWrite = (writeBytes / 512).round();
diskParts.add( diskParts.add(
DiskIOPiece( DiskIOPiece(
dev: diskName, dev: name,
sectorsRead: sectorsRead, sectorsRead: sectorsRead,
sectorsWrite: sectorsWrite, sectorsWrite: sectorsWrite,
time: currentTime, time: currentTime,
@@ -639,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) { void _parseWindowsTemperatures(Temperatures temps, String raw) {
try { try {
// Handle error output // Handle error output
@@ -677,7 +679,7 @@ void _parseWindowsTemperatures(Temperatures temps, String raw) {
if (typeLines.isNotEmpty && valueLines.isNotEmpty) { if (typeLines.isNotEmpty && valueLines.isNotEmpty) {
temps.parse(typeLines.join('\n'), valueLines.join('\n')); temps.parse(typeLines.join('\n'), valueLines.join('\n'));
} }
} catch (e) { } catch (e, s) {
// If JSON parsing fails, ignore temperature data 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] / /// Due to the design, at least two elements are required, otherwise [pre] /
/// [now] will throw. /// [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]; return _list[length - 2];
} }
T get now { List<T> get now {
return _list[length - 1]; return _list[length - 1];
} }
void onUpdate(); void onUpdate();
void update(T new_) { void update(List<T> new_) {
add(new_); add(new_);
if (pre.length != now.length) { if (pre.length != now.length) {
pre.removeWhere((e) => now.any((el) => e.same(el))); final previous = pre.toList(growable: false);
pre.addAll(now.where((e) => pre.every((el) => !e.same(el)))); 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(); 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/memory.dart';
import 'package:server_box/data/model/server/server.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 /// Windows-specific status parsing utilities
/// ///
/// This module handles parsing of Windows PowerShell command outputs /// This module handles parsing of Windows PowerShell command outputs
@@ -94,30 +101,75 @@ class WindowsParser {
} }
/// Parse Windows CPU information from PowerShell output /// 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 { try {
final dynamic jsonData = json.decode(raw); final dynamic jsonData = json.decode(raw);
final List<SingleCpuCore> cpus = []; final List<SingleCpuCore> cpus = [];
int totalCoreCount = 1;
if (jsonData is List) { if (jsonData is List) {
for (int i = 0; i < jsonData.length; i++) { // Multiple physical processors
final cpu = jsonData[i]; totalCoreCount = 0; // Reset to sum up
final loadPercentage = cpu['LoadPercentage'] ?? 0; var logicalProcessorOffset = 0;
final usage = loadPercentage as int; 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; final idle = 100 - usage;
// Get previous CPU data to calculate cumulative values // Create a SingleCpuCore entry for each logical processor
final prevCpus = serverStatus.cpu.now; // Windows only reports overall CPU load, so we distribute it evenly
final prevCpu = i < prevCpus.length ? prevCpus[i] : null; 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 // LIMITATION: Windows CPU counters approach
// PowerShell provides LoadPercentage as instantaneous percentage, not cumulative time. // PowerShell provides LoadPercentage as instantaneous percentage, not cumulative time.
// We simulate cumulative counters by adding current percentages to previous totals. // We simulate cumulative counters by adding current percentages to previous totals.
// This approach has limitations: // Additionally, Windows only provides overall CPU load, not per-core load.
// 1. Not as accurate as true cumulative time counters (Linux /proc/stat) // We distribute the load evenly across all logical processors.
// 2. May drift over time with variable polling intervals final newUser = (prevCpu?.user ?? 0) + usage;
// 3. Results depend on consistent polling frequency final newIdle = (prevCpu?.idle ?? 0) + idle;
// However, this allows compatibility with existing delta-based CPU calculation logic.
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 newUser = (prevCpu?.user ?? 0) + usage;
final newIdle = (prevCpu?.idle ?? 0) + idle; final newIdle = (prevCpu?.idle ?? 0) + idle;
@@ -125,46 +177,43 @@ class WindowsParser {
SingleCpuCore( SingleCpuCore(
'cpu$i', 'cpu$i',
newUser, // cumulative user time newUser, // cumulative user time
0, // sys (not available) 0, // sys
0, // nice (not available) 0, // nice
newIdle, // cumulative idle time newIdle, // cumulative idle time
0, // iowait (not available) 0, // iowait
0, // irq (not available) 0, // irq
0, // softirq (not available) 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; // Add a summary entry at the beginning (like Linux 'cpu' line)
} catch (e) { // This is the aggregate of all logical processors
return []; 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 String localPath;
final SftpReqType type; final SftpReqType type;
String? privateKey; String? privateKey;
Spi? jumpSpi; List<Spi>? jumpChain;
String? jumpPrivateKey; List<String?>? jumpPrivateKeys;
Map<String, String>? knownHostFingerprints;
SftpReq(this.spi, this.remotePath, this.localPath, this.type) { SftpReq(this.spi, this.remotePath, this.localPath, this.type) {
final keyId = spi.keyId; final keyId = spi.keyId;
if (keyId != null) { if (keyId != null) {
privateKey = getPrivateKey(keyId); privateKey = getPrivateKey(keyId);
} }
if (spi.jumpId != null) { if (spi.jumpChainIds != null || spi.jumpId != null) {
jumpSpi = Stores.server.box.get(spi.jumpId); // Use resolveMergedJumpChain to recursively expand nested hop chains
jumpPrivateKey = Stores.key.fetchOne(jumpSpi?.keyId)?.key; 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; late SftpWorker worker;
final Completer? completer; final Completer? completer;
String get fileName => req.localPath.split('/').last; String get fileName => req.localPath.split(Pfs.seperator).last;
// status of the download // status of the download
double? progress; 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( final client = await genClient(
req.spi, req.spi,
privateKey: req.privateKey, privateKey: req.privateKey,
jumpSpi: req.jumpSpi, jumpChain: req.jumpChain,
jumpPrivateKey: req.jumpPrivateKey, jumpPrivateKeys: req.jumpPrivateKeys,
knownHostFingerprints: req.knownHostFingerprints,
); );
mainSendPort.send(SftpWorkerStatus.sshConnectted); mainSendPort.send(SftpWorkerStatus.sshConnected);
/// Create the directory if not exists /// 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); await Directory(dirPath).create(recursive: true);
/// Use [FileMode.write] to overwrite the file /// Use [FileMode.write] to overwrite the file
@@ -119,10 +120,11 @@ Future<void> _upload(SftpReq req, SendPort mainSendPort, SendErrorFunction sendE
final client = await genClient( final client = await genClient(
req.spi, req.spi,
privateKey: req.privateKey, privateKey: req.privateKey,
jumpSpi: req.jumpSpi, jumpChain: req.jumpChain,
jumpPrivateKey: req.jumpPrivateKey, jumpPrivateKeys: req.jumpPrivateKeys,
knownHostFingerprints: req.knownHostFingerprints,
); );
mainSendPort.send(SftpWorkerStatus.sshConnectted); mainSendPort.send(SftpWorkerStatus.sshConnected);
final local = File(req.localPath); final local = File(req.localPath);
if (!await local.exists()) { if (!await local.exists()) {

View File

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

View File

@@ -1,21 +0,0 @@
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'app.g.dart';
part 'app.freezed.dart';
@freezed
abstract class AppState with _$AppState {
const factory AppState() = _AppState;
}
@Riverpod(keepAlive: true)
class AppStates extends _$AppStates {
static BuildContext? ctx;
@override
AppState build() {
return const AppState();
}
}

View File

@@ -1,206 +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 'app.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$AppState {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is AppState);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AppState()';
}
}
/// @nodoc
class $AppStateCopyWith<$Res> {
$AppStateCopyWith(AppState _, $Res Function(AppState) __);
}
/// Adds pattern-matching-related methods to [AppState].
extension AppStatePatterns on AppState {
/// 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( _AppState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _AppState() 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( _AppState value) $default,){
final _that = this;
switch (_that) {
case _AppState():
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( _AppState value)? $default,){
final _that = this;
switch (_that) {
case _AppState() 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()? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _AppState() when $default != null:
return $default();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() $default,) {final _that = this;
switch (_that) {
case _AppState():
return $default();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()? $default,) {final _that = this;
switch (_that) {
case _AppState() when $default != null:
return $default();case _:
return null;
}
}
}
/// @nodoc
class _AppState implements AppState {
const _AppState();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppState);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AppState()';
}
}
// dart format on

View File

@@ -1,62 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
@ProviderFor(AppStates)
const appStatesProvider = AppStatesProvider._();
final class AppStatesProvider extends $NotifierProvider<AppStates, AppState> {
const AppStatesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'appStatesProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$appStatesHash();
@$internal
@override
AppStates create() => AppStates();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AppState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AppState>(value),
);
}
}
String _$appStatesHash() => r'ef96f10f6fff0f3dd6d3128ebf070ad79cbc8bc9';
abstract class _$AppStates extends $Notifier<AppState> {
AppState build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<AppState, AppState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AppState, AppState>,
AppState,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -4,6 +4,8 @@ import 'dart:convert';
import 'package:dartssh2/dartssh2.dart'; import 'package:dartssh2/dartssh2.dart';
import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.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/ssh_client.dart'; import 'package:server_box/core/extension/ssh_client.dart';
import 'package:server_box/data/model/app/error.dart'; import 'package:server_box/data/model/app/error.dart';
import 'package:server_box/data/model/app/scripts/script_consts.dart'; import 'package:server_box/data/model/app/scripts/script_consts.dart';
@@ -12,63 +14,54 @@ import 'package:server_box/data/model/container/ps.dart';
import 'package:server_box/data/model/container/type.dart'; import 'package:server_box/data/model/container/type.dart';
import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/res/store.dart';
part 'container.freezed.dart';
part 'container.g.dart';
final _dockerNotFound = RegExp(r"command not found|Unknown command|Command '\w+' not found"); final _dockerNotFound = RegExp(r"command not found|Unknown command|Command '\w+' not found");
class ContainerProvider extends ChangeNotifier { @freezed
final SSHClient? client; abstract class ContainerState with _$ContainerState {
final String userName; const factory ContainerState({
final String hostId; @Default(null) List<ContainerPs>? items,
final BuildContext context; @Default(null) List<ContainerImg>? images,
List<ContainerPs>? items; @Default(null) String? version,
List<ContainerImg>? images; @Default(null) ContainerErr? error,
String? version; @Default(null) String? runLog,
ContainerErr? error; @Default(ContainerType.docker) ContainerType type,
String? runLog; @Default(false) bool isBusy,
ContainerType type; }) = _ContainerState;
var sudoCompleter = Completer<bool>(); }
bool isBusy = false;
ContainerProvider({ @riverpod
required this.client, class ContainerNotifier extends _$ContainerNotifier {
required this.userName, var sudoCompleter = Completer<bool>();
required this.hostId,
required this.context, @override
}) : type = Stores.container.getType(hostId) { ContainerState build(SSHClient? client, String userName, String hostId, BuildContext context) {
refresh(); final type = Stores.container.getType(hostId);
final initialState = ContainerState(type: type);
// Async initialization
Future.microtask(() => refresh());
return initialState;
} }
Future<void> setType(ContainerType type) async { Future<void> setType(ContainerType type) async {
this.type = type; state = state.copyWith(type: type, error: null, runLog: null, items: null, images: null, version: null);
Stores.container.setType(type, hostId); Stores.container.setType(type, hostId);
error = runLog = items = images = version = null;
sudoCompleter = Completer<bool>(); sudoCompleter = Completer<bool>();
notifyListeners();
await refresh(); await refresh();
} }
// Future<bool> _checkDockerInstalled(SSHClient client) async {
// final session = await client.execute("docker");
// await session.done;
// // debugPrint('docker code: ${session.exitCode}');
// return session.exitCode == 0;
// }
// String _removeSudoPrompts(String value) {
// final regex = RegExp(r"\[sudo\] password for \w+:");
// if (value.startsWith(regex)) {
// return value.replaceFirstMapped(regex, (match) => "");
// }
// return value;
// }
void _requiresSudo() async { void _requiresSudo() async {
/// Podman is rootless /// Podman is rootless
if (type == ContainerType.podman) return sudoCompleter.complete(false); if (state.type == ContainerType.podman) return sudoCompleter.complete(false);
if (!Stores.setting.containerTrySudo.fetch()) { if (!Stores.setting.containerTrySudo.fetch()) {
return sudoCompleter.complete(false); return sudoCompleter.complete(false);
} }
final res = await client?.run(_wrap(ContainerCmdType.images.exec(type))); final res = await client?.run(_wrap(ContainerCmdType.images.exec(state.type)));
if (res?.string.toLowerCase().contains('permission denied') ?? false) { if (res?.string.toLowerCase().contains('permission denied') ?? false) {
return sudoCompleter.complete(true); return sudoCompleter.complete(true);
} }
@@ -76,8 +69,8 @@ class ContainerProvider extends ChangeNotifier {
} }
Future<void> refresh({bool isAuto = false}) async { Future<void> refresh({bool isAuto = false}) async {
if (isBusy) return; if (state.isBusy) return;
isBusy = true; state = state.copyWith(isBusy: true);
if (!sudoCompleter.isCompleted) _requiresSudo(); if (!sudoCompleter.isCompleted) _requiresSudo();
@@ -85,11 +78,14 @@ class ContainerProvider extends ChangeNotifier {
/// If sudo is required and auto refresh is enabled, skip the refresh. /// If sudo is required and auto refresh is enabled, skip the refresh.
/// Or this will ask for pwd again and again. /// Or this will ask for pwd again and again.
if (sudo && isAuto) return; if (sudo && isAuto) {
state = state.copyWith(isBusy: false);
return;
}
final includeStats = Stores.setting.containerParseStat.fetch(); final includeStats = Stores.setting.containerParseStat.fetch();
var raw = ''; var raw = '';
final cmd = _wrap(ContainerCmdType.execAll(type, sudo: sudo, includeStats: includeStats)); final cmd = _wrap(ContainerCmdType.execAll(state.type, sudo: sudo, includeStats: includeStats));
final code = await client?.execWithPwd( final code = await client?.execWithPwd(
cmd, cmd,
context: context, context: context,
@@ -97,75 +93,79 @@ class ContainerProvider extends ChangeNotifier {
id: hostId, id: hostId,
); );
isBusy = false; state = state.copyWith(isBusy: false);
if (!context.mounted) return; if (!context.mounted) return;
/// Code 127 means command not found /// Code 127 means command not found
if (code == 127 || raw.contains(_dockerNotFound)) { if (code == 127 || raw.contains(_dockerNotFound)) {
error = ContainerErr(type: ContainerErrType.notInstalled); state = state.copyWith(error: ContainerErr(type: ContainerErrType.notInstalled));
notifyListeners();
return; return;
} }
// Check result segments count // Check result segments count
final segments = raw.split(ScriptConstants.separator); final segments = raw.split(ScriptConstants.separator);
if (segments.length != ContainerCmdType.values.length) { if (segments.length != ContainerCmdType.values.length) {
error = ContainerErr( state = state.copyWith(
type: ContainerErrType.segmentsNotMatch, error: ContainerErr(
message: 'Container segments: ${segments.length}', type: ContainerErrType.segmentsNotMatch,
message: 'Container segments: ${segments.length}',
),
); );
Loggers.app.warning('Container segments: ${segments.length}\n$raw'); Loggers.app.warning('Container segments: ${segments.length}\n$raw');
notifyListeners();
return; return;
} }
// Parse version // Parse version
final verRaw = ContainerCmdType.version.find(segments); final verRaw = ContainerCmdType.version.find(segments);
try { try {
version = json.decode(verRaw)['Client']['Version']; final version = json.decode(verRaw)['Client']['Version'];
state = state.copyWith(version: version, error: null);
} catch (e, trace) { } catch (e, trace) {
error = ContainerErr(type: ContainerErrType.invalidVersion, message: '$e'); state = state.copyWith(
error: ContainerErr(type: ContainerErrType.invalidVersion, message: '$e'),
);
Loggers.app.warning('Container version failed', e, trace); Loggers.app.warning('Container version failed', e, trace);
} finally {
notifyListeners();
} }
// Parse ps // Parse ps
final psRaw = ContainerCmdType.ps.find(segments); final psRaw = ContainerCmdType.ps.find(segments);
try { try {
final lines = psRaw.split('\n'); final lines = psRaw.split('\n');
if (type == ContainerType.docker) { if (state.type == ContainerType.docker) {
/// Due to the fetched data is not in json format, skip table header /// Due to the fetched data is not in json format, skip table header
lines.removeWhere((element) => element.contains('CONTAINER ID')); lines.removeWhere((element) => element.contains('CONTAINER ID'));
} }
lines.removeWhere((element) => element.isEmpty); lines.removeWhere((element) => element.isEmpty);
items = lines.map((e) => ContainerPs.fromRaw(e, type)).toList(); final items = lines.map((e) => ContainerPs.fromRaw(e, state.type)).toList();
state = state.copyWith(items: items);
} catch (e, trace) { } catch (e, trace) {
error = ContainerErr(type: ContainerErrType.parsePs, message: '$e'); state = state.copyWith(
error: ContainerErr(type: ContainerErrType.parsePs, message: '$e'),
);
Loggers.app.warning('Container ps failed', e, trace); Loggers.app.warning('Container ps failed', e, trace);
} finally {
notifyListeners();
} }
// Parse images // Parse images
final imageRaw = ContainerCmdType.images.find(segments).trim(); final imageRaw = ContainerCmdType.images.find(segments).trim();
final isEntireJson = imageRaw.startsWith('[') && imageRaw.endsWith(']'); final isEntireJson = imageRaw.startsWith('[') && imageRaw.endsWith(']');
try { try {
List<ContainerImg> images;
if (isEntireJson) { if (isEntireJson) {
images = (json.decode(imageRaw) as List) images = (json.decode(imageRaw) as List)
.map((e) => ContainerImg.fromRawJson(json.encode(e), type)) .map((e) => ContainerImg.fromRawJson(json.encode(e), state.type))
.toList(); .toList();
} else { } else {
final lines = imageRaw.split('\n'); final lines = imageRaw.split('\n');
lines.removeWhere((element) => element.isEmpty); lines.removeWhere((element) => element.isEmpty);
images = lines.map((e) => ContainerImg.fromRawJson(e, type)).toList(); images = lines.map((e) => ContainerImg.fromRawJson(e, state.type)).toList();
} }
state = state.copyWith(images: images);
} catch (e, trace) { } catch (e, trace) {
error = ContainerErr(type: ContainerErrType.parseImages, message: '$e'); state = state.copyWith(
error: ContainerErr(type: ContainerErrType.parseImages, message: '$e'),
);
Loggers.app.warning('Container images failed', e, trace); Loggers.app.warning('Container images failed', e, trace);
} finally {
notifyListeners();
} }
// Parse stats // Parse stats
@@ -173,22 +173,26 @@ class ContainerProvider extends ChangeNotifier {
try { try {
final statsLines = statsRaw.split('\n'); final statsLines = statsRaw.split('\n');
statsLines.removeWhere((element) => element.isEmpty); statsLines.removeWhere((element) => element.isEmpty);
for (var item in items!) { final items = state.items;
if (items == null) return;
for (var item in items) {
final id = item.id; final id = item.id;
if (id == null) continue; if (id == null) continue;
if (id.length < 5) continue;
final statsLine = statsLines.firstWhereOrNull( final statsLine = statsLines.firstWhereOrNull(
/// Use 5 characters to match the container id, possibility of mismatch /// Use 5 characters to match the container id, possibility of mismatch
/// is very low. /// is very low.
(element) => element.contains(id.substring(0, 5)), (element) => element.contains(id.substring(0, 5)),
); );
if (statsLine == null) continue; if (statsLine == null) continue;
item.parseStats(statsLine); item.parseStats(statsLine, state.version);
} }
} catch (e, trace) { } catch (e, trace) {
error = ContainerErr(type: ContainerErrType.parseStats, message: '$e'); state = state.copyWith(
error: ContainerErr(type: ContainerErrType.parseStats, message: '$e'),
);
Loggers.app.warning('Parse docker stats: $statsRaw', e, trace); Loggers.app.warning('Parse docker stats: $statsRaw', e, trace);
} finally {
notifyListeners();
} }
} }
@@ -223,25 +227,23 @@ class ContainerProvider extends ChangeNotifier {
} }
Future<ContainerErr?> run(String cmd, {bool autoRefresh = true}) async { Future<ContainerErr?> run(String cmd, {bool autoRefresh = true}) async {
cmd = switch (type) { cmd = switch (state.type) {
ContainerType.docker => 'docker $cmd', ContainerType.docker => 'docker $cmd',
ContainerType.podman => 'podman $cmd', ContainerType.podman => 'podman $cmd',
}; };
runLog = ''; state = state.copyWith(runLog: '');
final errs = <String>[]; final errs = <String>[];
final code = await client?.execWithPwd( final code = await client?.execWithPwd(
_wrap((await sudoCompleter.future) ? 'sudo -S $cmd' : cmd), _wrap((await sudoCompleter.future) ? 'sudo -S $cmd' : cmd),
context: context, context: context,
onStdout: (data, _) { onStdout: (data, _) {
runLog = '$runLog$data'; state = state.copyWith(runLog: '${state.runLog}$data');
notifyListeners();
}, },
onStderr: (data, _) => errs.add(data), onStderr: (data, _) => errs.add(data),
id: hostId, id: hostId,
); );
runLog = null; state = state.copyWith(runLog: null);
notifyListeners();
if (code != 0) { if (code != 0) {
return ContainerErr(type: ContainerErrType.unknown, message: errs.join('\n').trim()); return ContainerErr(type: ContainerErrType.unknown, message: errs.join('\n').trim());
@@ -278,7 +280,7 @@ enum ContainerCmdType {
return switch (this) { return switch (this) {
ContainerCmdType.version => '$prefix version $_jsonFmt', ContainerCmdType.version => '$prefix version $_jsonFmt',
ContainerCmdType.ps => switch (type) { 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. /// Use [_jsonFmt] in Docker will cause the operation to slow down.
ContainerType.docker => ContainerType.docker =>
'$prefix ps -a --format "table {{printf \\"' '$prefix ps -a --format "table {{printf \\"'

View File

@@ -0,0 +1,305 @@
// 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 'container.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ContainerState {
List<ContainerPs>? get items; List<ContainerImg>? get images; String? get version; ContainerErr? get error; String? get runLog; ContainerType get type; bool get isBusy;
/// Create a copy of ContainerState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ContainerStateCopyWith<ContainerState> get copyWith => _$ContainerStateCopyWithImpl<ContainerState>(this as ContainerState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ContainerState&&const DeepCollectionEquality().equals(other.items, items)&&const DeepCollectionEquality().equals(other.images, images)&&(identical(other.version, version) || other.version == version)&&(identical(other.error, error) || other.error == error)&&(identical(other.runLog, runLog) || other.runLog == runLog)&&(identical(other.type, type) || other.type == type)&&(identical(other.isBusy, isBusy) || other.isBusy == isBusy));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(items),const DeepCollectionEquality().hash(images),version,error,runLog,type,isBusy);
@override
String toString() {
return 'ContainerState(items: $items, images: $images, version: $version, error: $error, runLog: $runLog, type: $type, isBusy: $isBusy)';
}
}
/// @nodoc
abstract mixin class $ContainerStateCopyWith<$Res> {
factory $ContainerStateCopyWith(ContainerState value, $Res Function(ContainerState) _then) = _$ContainerStateCopyWithImpl;
@useResult
$Res call({
List<ContainerPs>? items, List<ContainerImg>? images, String? version, ContainerErr? error, String? runLog, ContainerType type, bool isBusy
});
}
/// @nodoc
class _$ContainerStateCopyWithImpl<$Res>
implements $ContainerStateCopyWith<$Res> {
_$ContainerStateCopyWithImpl(this._self, this._then);
final ContainerState _self;
final $Res Function(ContainerState) _then;
/// Create a copy of ContainerState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? items = freezed,Object? images = freezed,Object? version = freezed,Object? error = freezed,Object? runLog = freezed,Object? type = null,Object? isBusy = null,}) {
return _then(_self.copyWith(
items: freezed == items ? _self.items : items // ignore: cast_nullable_to_non_nullable
as List<ContainerPs>?,images: freezed == images ? _self.images : images // ignore: cast_nullable_to_non_nullable
as List<ContainerImg>?,version: freezed == version ? _self.version : version // ignore: cast_nullable_to_non_nullable
as String?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as ContainerErr?,runLog: freezed == runLog ? _self.runLog : runLog // ignore: cast_nullable_to_non_nullable
as String?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as ContainerType,isBusy: null == isBusy ? _self.isBusy : isBusy // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// Adds pattern-matching-related methods to [ContainerState].
extension ContainerStatePatterns on ContainerState {
/// 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( _ContainerState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ContainerState() 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( _ContainerState value) $default,){
final _that = this;
switch (_that) {
case _ContainerState():
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( _ContainerState value)? $default,){
final _that = this;
switch (_that) {
case _ContainerState() 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( List<ContainerPs>? items, List<ContainerImg>? images, String? version, ContainerErr? error, String? runLog, ContainerType type, bool isBusy)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ContainerState() when $default != null:
return $default(_that.items,_that.images,_that.version,_that.error,_that.runLog,_that.type,_that.isBusy);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( List<ContainerPs>? items, List<ContainerImg>? images, String? version, ContainerErr? error, String? runLog, ContainerType type, bool isBusy) $default,) {final _that = this;
switch (_that) {
case _ContainerState():
return $default(_that.items,_that.images,_that.version,_that.error,_that.runLog,_that.type,_that.isBusy);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( List<ContainerPs>? items, List<ContainerImg>? images, String? version, ContainerErr? error, String? runLog, ContainerType type, bool isBusy)? $default,) {final _that = this;
switch (_that) {
case _ContainerState() when $default != null:
return $default(_that.items,_that.images,_that.version,_that.error,_that.runLog,_that.type,_that.isBusy);case _:
return null;
}
}
}
/// @nodoc
class _ContainerState implements ContainerState {
const _ContainerState({final List<ContainerPs>? items = null, final List<ContainerImg>? images = null, this.version = null, this.error = null, this.runLog = null, this.type = ContainerType.docker, this.isBusy = false}): _items = items,_images = images;
final List<ContainerPs>? _items;
@override@JsonKey() List<ContainerPs>? get items {
final value = _items;
if (value == null) return null;
if (_items is EqualUnmodifiableListView) return _items;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(value);
}
final List<ContainerImg>? _images;
@override@JsonKey() List<ContainerImg>? get images {
final value = _images;
if (value == null) return null;
if (_images is EqualUnmodifiableListView) return _images;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(value);
}
@override@JsonKey() final String? version;
@override@JsonKey() final ContainerErr? error;
@override@JsonKey() final String? runLog;
@override@JsonKey() final ContainerType type;
@override@JsonKey() final bool isBusy;
/// Create a copy of ContainerState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ContainerStateCopyWith<_ContainerState> get copyWith => __$ContainerStateCopyWithImpl<_ContainerState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ContainerState&&const DeepCollectionEquality().equals(other._items, _items)&&const DeepCollectionEquality().equals(other._images, _images)&&(identical(other.version, version) || other.version == version)&&(identical(other.error, error) || other.error == error)&&(identical(other.runLog, runLog) || other.runLog == runLog)&&(identical(other.type, type) || other.type == type)&&(identical(other.isBusy, isBusy) || other.isBusy == isBusy));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_items),const DeepCollectionEquality().hash(_images),version,error,runLog,type,isBusy);
@override
String toString() {
return 'ContainerState(items: $items, images: $images, version: $version, error: $error, runLog: $runLog, type: $type, isBusy: $isBusy)';
}
}
/// @nodoc
abstract mixin class _$ContainerStateCopyWith<$Res> implements $ContainerStateCopyWith<$Res> {
factory _$ContainerStateCopyWith(_ContainerState value, $Res Function(_ContainerState) _then) = __$ContainerStateCopyWithImpl;
@override @useResult
$Res call({
List<ContainerPs>? items, List<ContainerImg>? images, String? version, ContainerErr? error, String? runLog, ContainerType type, bool isBusy
});
}
/// @nodoc
class __$ContainerStateCopyWithImpl<$Res>
implements _$ContainerStateCopyWith<$Res> {
__$ContainerStateCopyWithImpl(this._self, this._then);
final _ContainerState _self;
final $Res Function(_ContainerState) _then;
/// Create a copy of ContainerState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? items = freezed,Object? images = freezed,Object? version = freezed,Object? error = freezed,Object? runLog = freezed,Object? type = null,Object? isBusy = null,}) {
return _then(_ContainerState(
items: freezed == items ? _self._items : items // ignore: cast_nullable_to_non_nullable
as List<ContainerPs>?,images: freezed == images ? _self._images : images // ignore: cast_nullable_to_non_nullable
as List<ContainerImg>?,version: freezed == version ? _self.version : version // ignore: cast_nullable_to_non_nullable
as String?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as ContainerErr?,runLog: freezed == runLog ? _self.runLog : runLog // ignore: cast_nullable_to_non_nullable
as String?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as ContainerType,isBusy: null == isBusy ? _self.isBusy : isBusy // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
// dart format on

View File

@@ -0,0 +1,123 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'container.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ContainerNotifier)
const containerProvider = ContainerNotifierFamily._();
final class ContainerNotifierProvider
extends $NotifierProvider<ContainerNotifier, ContainerState> {
const ContainerNotifierProvider._({
required ContainerNotifierFamily super.from,
required (SSHClient?, String, String, BuildContext) super.argument,
}) : super(
retry: null,
name: r'containerProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$containerNotifierHash();
@override
String toString() {
return r'containerProvider'
''
'$argument';
}
@$internal
@override
ContainerNotifier create() => ContainerNotifier();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ContainerState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ContainerState>(value),
);
}
@override
bool operator ==(Object other) {
return other is ContainerNotifierProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$containerNotifierHash() => r'85457ec75264199c284572ee45beeaccba2044a1';
final class ContainerNotifierFamily extends $Family
with
$ClassFamilyOverride<
ContainerNotifier,
ContainerState,
ContainerState,
ContainerState,
(SSHClient?, String, String, BuildContext)
> {
const ContainerNotifierFamily._()
: super(
retry: null,
name: r'containerProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
ContainerNotifierProvider call(
SSHClient? client,
String userName,
String hostId,
BuildContext context,
) => ContainerNotifierProvider._(
argument: (client, userName, hostId, context),
from: this,
);
@override
String toString() => r'containerProvider';
}
abstract class _$ContainerNotifier extends $Notifier<ContainerState> {
late final _$args = ref.$arg as (SSHClient?, String, String, BuildContext);
SSHClient? get client => _$args.$1;
String get userName => _$args.$2;
String get hostId => _$args.$3;
BuildContext get context => _$args.$4;
ContainerState build(
SSHClient? client,
String userName,
String hostId,
BuildContext context,
);
@$mustCallSuper
@override
void runBuild() {
final created = build(_$args.$1, _$args.$2, _$args.$3, _$args.$4);
final ref = this.ref as $Ref<ContainerState, ContainerState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<ContainerState, ContainerState>,
ContainerState,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -1,45 +1,61 @@
import 'package:fl_lib/fl_lib.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:server_box/core/sync.dart'; import 'package:server_box/core/sync.dart';
import 'package:server_box/data/model/server/private_key_info.dart'; import 'package:server_box/data/model/server/private_key_info.dart';
import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/res/store.dart';
class PrivateKeyProvider extends Provider { part 'private_key.freezed.dart';
const PrivateKeyProvider._(); part 'private_key.g.dart';
static const instance = PrivateKeyProvider._();
static final pkis = <PrivateKeyInfo>[].vn; @freezed
abstract class PrivateKeyState with _$PrivateKeyState {
const factory PrivateKeyState({@Default(<PrivateKeyInfo>[]) List<PrivateKeyInfo> keys}) = _PrivateKeyState;
}
@Riverpod(keepAlive: true)
class PrivateKeyNotifier extends _$PrivateKeyNotifier {
@override @override
void load() { PrivateKeyState build() {
super.load(); return _load();
pkis.value = Stores.key.fetch();
} }
static void add(PrivateKeyInfo info) { void reload() {
pkis.value.add(info); final newState = _load();
pkis.notify(); if (newState == state) return;
state = newState;
}
PrivateKeyState _load() {
final keys = Stores.key.fetch();
return stateOrNull?.copyWith(keys: keys) ?? PrivateKeyState(keys: keys);
}
void add(PrivateKeyInfo info) {
final newKeys = [...state.keys, info];
state = state.copyWith(keys: newKeys);
Stores.key.put(info); Stores.key.put(info);
bakSync.sync(milliDelay: 1000); bakSync.sync(milliDelay: 1000);
} }
static void delete(PrivateKeyInfo info) { void delete(PrivateKeyInfo info) {
pkis.value.removeWhere((e) => e.id == info.id); final newKeys = state.keys.where((e) => e.id != info.id).toList();
pkis.notify(); state = state.copyWith(keys: newKeys);
Stores.key.delete(info); Stores.key.delete(info);
bakSync.sync(milliDelay: 1000); bakSync.sync(milliDelay: 1000);
} }
static void update(PrivateKeyInfo old, PrivateKeyInfo newInfo) { void update(PrivateKeyInfo old, PrivateKeyInfo newInfo) {
final idx = pkis.value.indexWhere((e) => e.id == old.id); final keys = [...state.keys];
final idx = keys.indexWhere((e) => e.id == old.id);
if (idx == -1) { if (idx == -1) {
pkis.value.add(newInfo); keys.add(newInfo);
Stores.key.put(newInfo); Stores.key.put(newInfo);
Stores.key.delete(old); Stores.key.delete(old);
} else { } else {
pkis.value[idx] = newInfo; keys[idx] = newInfo;
Stores.key.put(newInfo); Stores.key.put(newInfo);
} }
pkis.notify(); state = state.copyWith(keys: keys);
bakSync.sync(milliDelay: 1000); bakSync.sync(milliDelay: 1000);
} }
} }

View File

@@ -0,0 +1,277 @@
// 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 'private_key.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$PrivateKeyState {
List<PrivateKeyInfo> get keys;
/// Create a copy of PrivateKeyState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$PrivateKeyStateCopyWith<PrivateKeyState> get copyWith => _$PrivateKeyStateCopyWithImpl<PrivateKeyState>(this as PrivateKeyState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is PrivateKeyState&&const DeepCollectionEquality().equals(other.keys, keys));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(keys));
@override
String toString() {
return 'PrivateKeyState(keys: $keys)';
}
}
/// @nodoc
abstract mixin class $PrivateKeyStateCopyWith<$Res> {
factory $PrivateKeyStateCopyWith(PrivateKeyState value, $Res Function(PrivateKeyState) _then) = _$PrivateKeyStateCopyWithImpl;
@useResult
$Res call({
List<PrivateKeyInfo> keys
});
}
/// @nodoc
class _$PrivateKeyStateCopyWithImpl<$Res>
implements $PrivateKeyStateCopyWith<$Res> {
_$PrivateKeyStateCopyWithImpl(this._self, this._then);
final PrivateKeyState _self;
final $Res Function(PrivateKeyState) _then;
/// Create a copy of PrivateKeyState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? keys = null,}) {
return _then(_self.copyWith(
keys: null == keys ? _self.keys : keys // ignore: cast_nullable_to_non_nullable
as List<PrivateKeyInfo>,
));
}
}
/// Adds pattern-matching-related methods to [PrivateKeyState].
extension PrivateKeyStatePatterns on PrivateKeyState {
/// 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( _PrivateKeyState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _PrivateKeyState() 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( _PrivateKeyState value) $default,){
final _that = this;
switch (_that) {
case _PrivateKeyState():
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( _PrivateKeyState value)? $default,){
final _that = this;
switch (_that) {
case _PrivateKeyState() 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( List<PrivateKeyInfo> keys)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _PrivateKeyState() when $default != null:
return $default(_that.keys);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( List<PrivateKeyInfo> keys) $default,) {final _that = this;
switch (_that) {
case _PrivateKeyState():
return $default(_that.keys);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( List<PrivateKeyInfo> keys)? $default,) {final _that = this;
switch (_that) {
case _PrivateKeyState() when $default != null:
return $default(_that.keys);case _:
return null;
}
}
}
/// @nodoc
class _PrivateKeyState implements PrivateKeyState {
const _PrivateKeyState({final List<PrivateKeyInfo> keys = const <PrivateKeyInfo>[]}): _keys = keys;
final List<PrivateKeyInfo> _keys;
@override@JsonKey() List<PrivateKeyInfo> get keys {
if (_keys is EqualUnmodifiableListView) return _keys;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_keys);
}
/// Create a copy of PrivateKeyState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$PrivateKeyStateCopyWith<_PrivateKeyState> get copyWith => __$PrivateKeyStateCopyWithImpl<_PrivateKeyState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _PrivateKeyState&&const DeepCollectionEquality().equals(other._keys, _keys));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_keys));
@override
String toString() {
return 'PrivateKeyState(keys: $keys)';
}
}
/// @nodoc
abstract mixin class _$PrivateKeyStateCopyWith<$Res> implements $PrivateKeyStateCopyWith<$Res> {
factory _$PrivateKeyStateCopyWith(_PrivateKeyState value, $Res Function(_PrivateKeyState) _then) = __$PrivateKeyStateCopyWithImpl;
@override @useResult
$Res call({
List<PrivateKeyInfo> keys
});
}
/// @nodoc
class __$PrivateKeyStateCopyWithImpl<$Res>
implements _$PrivateKeyStateCopyWith<$Res> {
__$PrivateKeyStateCopyWithImpl(this._self, this._then);
final _PrivateKeyState _self;
final $Res Function(_PrivateKeyState) _then;
/// Create a copy of PrivateKeyState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? keys = null,}) {
return _then(_PrivateKeyState(
keys: null == keys ? _self._keys : keys // ignore: cast_nullable_to_non_nullable
as List<PrivateKeyInfo>,
));
}
}
// dart format on

View File

@@ -0,0 +1,64 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'private_key.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(PrivateKeyNotifier)
const privateKeyProvider = PrivateKeyNotifierProvider._();
final class PrivateKeyNotifierProvider
extends $NotifierProvider<PrivateKeyNotifier, PrivateKeyState> {
const PrivateKeyNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'privateKeyProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$privateKeyNotifierHash();
@$internal
@override
PrivateKeyNotifier create() => PrivateKeyNotifier();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(PrivateKeyState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<PrivateKeyState>(value),
);
}
}
String _$privateKeyNotifierHash() =>
r'12edd05dca29d1cbc9e2a3e047c3d417d22f7bb7';
abstract class _$PrivateKeyNotifier extends $Notifier<PrivateKeyState> {
PrivateKeyState build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<PrivateKeyState, PrivateKeyState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<PrivateKeyState, PrivateKeyState>,
PrivateKeyState,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -0,0 +1,83 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart';
import 'package:server_box/data/provider/private_key.dart';
import 'package:server_box/data/provider/server/all.dart';
import 'package:server_box/data/provider/sftp.dart';
import 'package:server_box/data/provider/snippet.dart';
/// !library;
/// ref.useNotifier, ref.readProvider, ref.watchProvider
///
/// Usage:
/// - `providers.read.server` -> `ref.read(serversProvider)`
/// - `providers.use.snippet` -> `ref.read(snippetsNotifierProvider.notifier)`
extension RiverpodNotifiers on ConsumerState {
T useNotifier<T extends Notifier<Object?>>(NotifierProvider<T, Object?> provider) {
return ref.read(provider.notifier);
}
T readProvider<T>(ProviderBase<T> provider) {
return ref.read(provider);
}
T watchProvider<T>(ProviderBase<T> provider) {
return ref.watch(provider);
}
MyProviders get providers => MyProviders(ref);
}
final class MyProviders {
final WidgetRef ref;
const MyProviders(this.ref);
ReadMyProvider get read => ReadMyProvider(ref);
WatchMyProvider get watch => WatchMyProvider(ref);
UseNotifierMyProvider get use => UseNotifierMyProvider(ref);
}
final class ReadMyProvider {
final WidgetRef ref;
const ReadMyProvider(this.ref);
T call<T>(ProviderBase<T> provider) => ref.read(provider);
// Specific provider getters
ServersState get server => ref.read(serversProvider);
SnippetState get snippet => ref.read(snippetProvider);
AppState get app => ref.read(appStatesProvider);
PrivateKeyState get privateKey => ref.read(privateKeyProvider);
SftpState get sftp => ref.read(sftpProvider);
}
final class WatchMyProvider {
final WidgetRef ref;
const WatchMyProvider(this.ref);
T call<T>(ProviderBase<T> provider) => ref.watch(provider);
// Specific provider getters
ServersState get server => ref.watch(serversProvider);
SnippetState get snippet => ref.watch(snippetProvider);
AppState get app => ref.watch(appStatesProvider);
PrivateKeyState get privateKey => ref.watch(privateKeyProvider);
SftpState get sftp => ref.watch(sftpProvider);
}
final class UseNotifierMyProvider {
final WidgetRef ref;
const UseNotifierMyProvider(this.ref);
T call<T extends Notifier<Object?>>(NotifierProvider<T, Object?> provider) =>
ref.read(provider.notifier);
// Specific provider notifier getters
ServersNotifier get server => ref.read(serversProvider.notifier);
SnippetNotifier get snippet => ref.read(snippetProvider.notifier);
AppStates get app => ref.read(appStatesProvider.notifier);
PrivateKeyNotifier get privateKey => ref.read(privateKeyProvider.notifier);
SftpNotifier get sftp => ref.read(sftpProvider.notifier);
}

View File

@@ -6,72 +6,90 @@ import 'package:dartssh2/dartssh2.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:dio/io.dart'; import 'package:dio/io.dart';
import 'package:fl_lib/fl_lib.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'; import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/data/model/app/error.dart'; import 'package:server_box/data/model/app/error.dart';
import 'package:server_box/data/model/server/pve.dart'; import 'package:server_box/data/model/server/pve.dart';
import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/provider/server/single.dart';
part 'pve.freezed.dart';
part 'pve.g.dart';
typedef PveCtrlFunc = Future<bool> Function(String node, String id); typedef PveCtrlFunc = Future<bool> Function(String node, String id);
final class PveProvider extends ChangeNotifier { @freezed
final Spi spi; abstract class PveState with _$PveState {
const factory PveState({
@Default(null) PveErr? error,
@Default(null) PveRes? data,
@Default(null) String? release,
@Default(false) bool isBusy,
@Default(false) bool isConnected,
}) = _PveState;
}
@riverpod
class PveNotifier extends _$PveNotifier {
late final Spi spi;
late String addr; late String addr;
late final SSHClient _client; late final SSHClient _client;
late final ServerSocket _serverSocket; late final ServerSocket _serverSocket;
final List<SSHForwardChannel> _forwards = []; final List<SSHForwardChannel> _forwards = [];
int _localPort = 0; int _localPort = 0;
late final Dio session;
late final bool _ignoreCert;
PveProvider({required this.spi}) { @override
final client = spi.server?.value.client; PveState build(Spi spiParam) {
spi = spiParam;
final serverState = ref.watch(serverProvider(spi.id));
final client = serverState.client;
if (client == null) { if (client == null) {
throw Exception('Server client is null'); return const PveState(error: PveErr(type: PveErrType.net, message: 'Server client is null'));
} }
_client = client; _client = client;
final addr = spi.custom?.pveAddr; final addr = spi.custom?.pveAddr;
if (addr == null) { if (addr == null) {
err.value = 'PVE address is null'; return PveState(error: PveErr(type: PveErrType.net, message: 'PVE address is null'));
return;
} }
this.addr = addr; this.addr = addr;
_init(); _ignoreCert = spi.custom?.pveIgnoreCert ?? false;
_initSession();
// Async initialization
Future.microtask(() => _init());
return const PveState();
} }
final err = ValueNotifier<String?>(null); void _initSession() {
final connected = Completer<void>(); session = Dio()
..httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
final client = HttpClient();
client.connectionFactory = cf;
if (_ignoreCert) {
client.badCertificateCallback = (_, _, _) => true;
}
return client;
},
validateCertificate: _ignoreCert ? (_, _, _) => true : null,
);
}
late final _ignoreCert = spi.custom?.pveIgnoreCert ?? false; bool get onlyOneNode => state.data?.nodes.length == 1;
late final session = Dio()
..httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
final client = HttpClient();
client.connectionFactory = cf;
if (_ignoreCert) {
client.badCertificateCallback = (_, _, _) => true;
}
return client;
},
validateCertificate: _ignoreCert ? (_, _, _) => true : null,
);
final data = ValueNotifier<PveRes?>(null);
bool get onlyOneNode => data.value?.nodes.length == 1;
String? release;
bool isBusy = false;
Future<void> _init() async { Future<void> _init() async {
try { try {
await _forward(); await _forward();
await _login(); await _login();
await _getRelease(); await _getRelease();
state = state.copyWith(isConnected: true);
} on PveErr { } on PveErr {
err.value = l10n.pveLoginFailed; state = state.copyWith(error: PveErr(type: PveErrType.loginFailed, message: l10n.pveLoginFailed));
} catch (e, s) { } catch (e, s) {
Loggers.app.warning('PVE init failed', e, s); Loggers.app.warning('PVE init failed', e, s);
err.value = e.toString(); state = state.copyWith(error: PveErr(type: PveErrType.unknown, message: e.toString()));
} finally {
connected.complete();
} }
} }
@@ -89,7 +107,7 @@ final class PveProvider extends ChangeNotifier {
final newUrl = Uri.parse( final newUrl = Uri.parse(
addr, addr,
).replace(host: 'localhost', port: _localPort).toString(); ).replace(host: 'localhost', port: _localPort).toString();
debugPrint('Forwarding $newUrl to $addr'); dprint('Forwarding $newUrl to $addr');
} }
} }
@@ -146,72 +164,85 @@ final class PveProvider extends ChangeNotifier {
final resp = await session.get('$addr/api2/extjs/version'); final resp = await session.get('$addr/api2/extjs/version');
final version = resp.data['data']['release'] as String?; final version = resp.data['data']['release'] as String?;
if (version != null) { if (version != null) {
release = version; state = state.copyWith(release: version);
} }
} }
Future<void> list() async { Future<void> list() async {
await connected.future; if (!state.isConnected || state.isBusy) return;
if (isBusy) return; state = state.copyWith(isBusy: true);
isBusy = true;
try { try {
final resp = await session.get('$addr/api2/json/cluster/resources'); final resp = await session.get('$addr/api2/json/cluster/resources');
final res = resp.data['data'] as List; final res = resp.data['data'] as List;
final result = await Computer.shared.start(PveRes.parse, ( final result = await Computer.shared.start(PveRes.parse, (
res, res,
data.value, state.data,
)); ));
data.value = result; state = state.copyWith(data: result, error: null);
} catch (e) { } catch (e) {
Loggers.app.warning('PVE list failed', e); Loggers.app.warning('PVE list failed', e);
err.value = e.toString(); state = state.copyWith(error: PveErr(type: PveErrType.unknown, message: e.toString()));
} finally { } finally {
isBusy = false; state = state.copyWith(isBusy: false);
} }
} }
Future<bool> reboot(String node, String id) async { Future<bool> reboot(String node, String id) async {
await connected.future; if (!state.isConnected) return false;
final resp = await session.post( final resp = await session.post(
'$addr/api2/json/nodes/$node/$id/status/reboot', '$addr/api2/json/nodes/$node/$id/status/reboot',
); );
return _isCtrlSuc(resp); final success = _isCtrlSuc(resp);
if (success) await list(); // Refresh data
return success;
} }
Future<bool> start(String node, String id) async { Future<bool> start(String node, String id) async {
await connected.future; if (!state.isConnected) return false;
final resp = await session.post( final resp = await session.post(
'$addr/api2/json/nodes/$node/$id/status/start', '$addr/api2/json/nodes/$node/$id/status/start',
); );
return _isCtrlSuc(resp); final success = _isCtrlSuc(resp);
if (success) await list(); // Refresh data
return success;
} }
Future<bool> stop(String node, String id) async { Future<bool> stop(String node, String id) async {
await connected.future; if (!state.isConnected) return false;
final resp = await session.post( final resp = await session.post(
'$addr/api2/json/nodes/$node/$id/status/stop', '$addr/api2/json/nodes/$node/$id/status/stop',
); );
return _isCtrlSuc(resp); final success = _isCtrlSuc(resp);
if (success) await list(); // Refresh data
return success;
} }
Future<bool> shutdown(String node, String id) async { Future<bool> shutdown(String node, String id) async {
await connected.future; if (!state.isConnected) return false;
final resp = await session.post( final resp = await session.post(
'$addr/api2/json/nodes/$node/$id/status/shutdown', '$addr/api2/json/nodes/$node/$id/status/shutdown',
); );
return _isCtrlSuc(resp); final success = _isCtrlSuc(resp);
if (success) await list(); // Refresh data
return success;
} }
bool _isCtrlSuc(Response resp) { bool _isCtrlSuc(Response resp) {
return resp.statusCode == 200; return resp.statusCode == 200;
} }
@override
Future<void> dispose() async { Future<void> dispose() async {
super.dispose(); try {
await _serverSocket.close(); await _serverSocket.close();
} catch (e, s) {
Loggers.app.warning('Failed to close server socket', e, s);
}
for (final forward in _forwards) { for (final forward in _forwards) {
forward.close(); try {
forward.close();
} catch (e, s) {
Loggers.app.warning('Failed to close forward', e, s);
}
} }
} }
} }

View File

@@ -0,0 +1,283 @@
// 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 'pve.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$PveState {
PveErr? get error; PveRes? get data; String? get release; bool get isBusy; bool get isConnected;
/// Create a copy of PveState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$PveStateCopyWith<PveState> get copyWith => _$PveStateCopyWithImpl<PveState>(this as PveState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is PveState&&(identical(other.error, error) || other.error == error)&&(identical(other.data, data) || other.data == data)&&(identical(other.release, release) || other.release == release)&&(identical(other.isBusy, isBusy) || other.isBusy == isBusy)&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected));
}
@override
int get hashCode => Object.hash(runtimeType,error,data,release,isBusy,isConnected);
@override
String toString() {
return 'PveState(error: $error, data: $data, release: $release, isBusy: $isBusy, isConnected: $isConnected)';
}
}
/// @nodoc
abstract mixin class $PveStateCopyWith<$Res> {
factory $PveStateCopyWith(PveState value, $Res Function(PveState) _then) = _$PveStateCopyWithImpl;
@useResult
$Res call({
PveErr? error, PveRes? data, String? release, bool isBusy, bool isConnected
});
}
/// @nodoc
class _$PveStateCopyWithImpl<$Res>
implements $PveStateCopyWith<$Res> {
_$PveStateCopyWithImpl(this._self, this._then);
final PveState _self;
final $Res Function(PveState) _then;
/// Create a copy of PveState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? error = freezed,Object? data = freezed,Object? release = freezed,Object? isBusy = null,Object? isConnected = null,}) {
return _then(_self.copyWith(
error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as PveErr?,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
as PveRes?,release: freezed == release ? _self.release : release // ignore: cast_nullable_to_non_nullable
as String?,isBusy: null == isBusy ? _self.isBusy : isBusy // ignore: cast_nullable_to_non_nullable
as bool,isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// Adds pattern-matching-related methods to [PveState].
extension PveStatePatterns on PveState {
/// 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( _PveState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _PveState() 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( _PveState value) $default,){
final _that = this;
switch (_that) {
case _PveState():
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( _PveState value)? $default,){
final _that = this;
switch (_that) {
case _PveState() 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( PveErr? error, PveRes? data, String? release, bool isBusy, bool isConnected)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _PveState() when $default != null:
return $default(_that.error,_that.data,_that.release,_that.isBusy,_that.isConnected);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( PveErr? error, PveRes? data, String? release, bool isBusy, bool isConnected) $default,) {final _that = this;
switch (_that) {
case _PveState():
return $default(_that.error,_that.data,_that.release,_that.isBusy,_that.isConnected);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( PveErr? error, PveRes? data, String? release, bool isBusy, bool isConnected)? $default,) {final _that = this;
switch (_that) {
case _PveState() when $default != null:
return $default(_that.error,_that.data,_that.release,_that.isBusy,_that.isConnected);case _:
return null;
}
}
}
/// @nodoc
class _PveState implements PveState {
const _PveState({this.error = null, this.data = null, this.release = null, this.isBusy = false, this.isConnected = false});
@override@JsonKey() final PveErr? error;
@override@JsonKey() final PveRes? data;
@override@JsonKey() final String? release;
@override@JsonKey() final bool isBusy;
@override@JsonKey() final bool isConnected;
/// Create a copy of PveState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$PveStateCopyWith<_PveState> get copyWith => __$PveStateCopyWithImpl<_PveState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _PveState&&(identical(other.error, error) || other.error == error)&&(identical(other.data, data) || other.data == data)&&(identical(other.release, release) || other.release == release)&&(identical(other.isBusy, isBusy) || other.isBusy == isBusy)&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected));
}
@override
int get hashCode => Object.hash(runtimeType,error,data,release,isBusy,isConnected);
@override
String toString() {
return 'PveState(error: $error, data: $data, release: $release, isBusy: $isBusy, isConnected: $isConnected)';
}
}
/// @nodoc
abstract mixin class _$PveStateCopyWith<$Res> implements $PveStateCopyWith<$Res> {
factory _$PveStateCopyWith(_PveState value, $Res Function(_PveState) _then) = __$PveStateCopyWithImpl;
@override @useResult
$Res call({
PveErr? error, PveRes? data, String? release, bool isBusy, bool isConnected
});
}
/// @nodoc
class __$PveStateCopyWithImpl<$Res>
implements _$PveStateCopyWith<$Res> {
__$PveStateCopyWithImpl(this._self, this._then);
final _PveState _self;
final $Res Function(_PveState) _then;
/// Create a copy of PveState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? error = freezed,Object? data = freezed,Object? release = freezed,Object? isBusy = null,Object? isConnected = null,}) {
return _then(_PveState(
error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as PveErr?,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
as PveRes?,release: freezed == release ? _self.release : release // ignore: cast_nullable_to_non_nullable
as String?,isBusy: null == isBusy ? _self.isBusy : isBusy // ignore: cast_nullable_to_non_nullable
as bool,isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
// dart format on

View File

@@ -0,0 +1,101 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'pve.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(PveNotifier)
const pveProvider = PveNotifierFamily._();
final class PveNotifierProvider
extends $NotifierProvider<PveNotifier, PveState> {
const PveNotifierProvider._({
required PveNotifierFamily super.from,
required Spi super.argument,
}) : super(
retry: null,
name: r'pveProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$pveNotifierHash();
@override
String toString() {
return r'pveProvider'
''
'($argument)';
}
@$internal
@override
PveNotifier create() => PveNotifier();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(PveState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<PveState>(value),
);
}
@override
bool operator ==(Object other) {
return other is PveNotifierProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$pveNotifierHash() => r'1e71faadee074b9c07bee731ef4ae6505e791967';
final class PveNotifierFamily extends $Family
with $ClassFamilyOverride<PveNotifier, PveState, PveState, PveState, Spi> {
const PveNotifierFamily._()
: super(
retry: null,
name: r'pveProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
PveNotifierProvider call(Spi spiParam) =>
PveNotifierProvider._(argument: spiParam, from: this);
@override
String toString() => r'pveProvider';
}
abstract class _$PveNotifier extends $Notifier<PveState> {
late final _$args = ref.$arg as Spi;
Spi get spiParam => _$args;
PveState build(Spi spiParam);
@$mustCallSuper
@override
void runBuild() {
final created = build(_$args);
final ref = this.ref as $Ref<PveState, PveState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<PveState, PveState>,
PveState,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -1,505 +0,0 @@
import 'dart:async';
// import 'dart:io';
import 'package:computer/computer.dart';
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/core/sync.dart';
import 'package:server_box/core/utils/server.dart';
import 'package:server_box/core/utils/ssh_auth.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';
import 'package:server_box/data/model/app/scripts/shell_func.dart';
import 'package:server_box/data/model/server/server.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/server_status_update_req.dart';
import 'package:server_box/data/model/server/system.dart';
import 'package:server_box/data/model/server/try_limiter.dart';
import 'package:server_box/data/res/status.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/data/ssh/session_manager.dart';
class ServerProvider extends Provider {
const ServerProvider._();
static const instance = ServerProvider._();
static final Map<String, VNode<Server>> servers = {};
static final serverOrder = <String>[].vn;
static final _tags = <String>{}.vn;
static VNode<Set<String>> get tags => _tags;
static Timer? _timer;
static final _manualDisconnectedIds = <String>{};
static final _serverIdsUpdating = <String, Future<void>?>{};
@override
Future<void> load() async {
super.load();
// #147
// Clear all servers because of restarting app will cause duplicate servers
final oldServers = Map<String, VNode<Server>>.from(servers);
servers.clear();
serverOrder.value.clear();
final spis = Stores.server.fetch();
for (int idx = 0; idx < spis.length; idx++) {
final spi = spis[idx];
final originServer = oldServers[spi.id];
/// #258
/// If not [shouldReconnect], then keep the old state.
if (originServer != null && !originServer.value.spi.shouldReconnect(spi)) {
originServer.value.spi = spi;
servers[spi.id] = originServer;
} else {
final newServer = genServer(spi);
servers[spi.id] = newServer.vn;
}
}
final serverOrder_ = Stores.setting.serverOrder.fetch();
if (serverOrder_.isNotEmpty) {
spis.reorder(order: serverOrder_, finder: (n, id) => n.id == id);
serverOrder.value.addAll(spis.map((e) => e.id));
} else {
serverOrder.value.addAll(servers.keys);
}
// Must use [equals] to compare [Order] here.
if (!serverOrder.value.equals(serverOrder_)) {
Stores.setting.serverOrder.put(serverOrder.value);
}
_updateTags();
// Must notify here, or the UI will not be updated.
serverOrder.notify();
}
/// Get a [Server] by [spi] or [id].
///
/// Priority: [spi] > [id]
static VNode<Server>? pick({Spi? spi, String? id}) {
if (spi != null) {
return servers[spi.id];
}
if (id != null) {
return servers[id];
}
return null;
}
static void _updateTags() {
final tags = <String>{};
for (final s in servers.values) {
final spiTags = s.value.spi.tags;
if (spiTags == null) continue;
for (final t in spiTags) {
tags.add(t);
}
}
_tags.value = tags;
}
static Server genServer(Spi spi) {
return Server(spi, InitStatus.status, ServerConn.disconnected);
}
/// if [spi] is specificed then only refresh this server
/// [onlyFailed] only refresh failed servers
static Future<void> refresh({Spi? spi, bool onlyFailed = false}) async {
if (spi != null) {
_manualDisconnectedIds.remove(spi.id);
await _getData(spi);
return;
}
await Future.wait(
servers.values.map((val) async {
final s = val.value;
if (onlyFailed) {
if (s.conn != ServerConn.failed) return;
TryLimiter.reset(s.spi.id);
}
if (_manualDisconnectedIds.contains(s.spi.id)) return;
if (s.conn == ServerConn.disconnected && !s.spi.autoConnect) {
return;
}
// Check if already updating, and if so, wait for it to complete
final existingUpdate = _serverIdsUpdating[s.spi.id];
if (existingUpdate != null) {
// Already updating, wait for the existing update to complete
try {
await existingUpdate;
} catch (e) {
// Ignore errors from the existing update, we'll try our own
}
return;
}
// Start a new update operation
final updateFuture = _updateServer(s.spi);
_serverIdsUpdating[s.spi.id] = updateFuture;
try {
await updateFuture;
} finally {
_serverIdsUpdating.remove(s.spi.id);
}
}),
);
}
static Future<void> _updateServer(Spi spi) async {
await _getData(spi);
}
static Future<void> startAutoRefresh() async {
var duration = Stores.setting.serverStatusUpdateInterval.fetch();
stopAutoRefresh();
if (duration == 0) return;
if (duration < 0 || duration > 10 || duration == 1) {
duration = 3;
Loggers.app.warning('Invalid duration: $duration, use default 3');
}
_timer = Timer.periodic(Duration(seconds: duration), (_) async {
await refresh();
});
}
static void stopAutoRefresh() {
if (_timer != null) {
_timer!.cancel();
_timer = null;
}
}
static bool get isAutoRefreshOn => _timer != null;
static void setDisconnected() {
for (final s in servers.values) {
s.value.conn = ServerConn.disconnected;
s.notify();
// Update SSH session status to disconnected
final sessionId = 'ssh_${s.value.spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
}
//TryLimiter.clear();
}
static void closeServer({String? id}) {
if (id == null) {
for (final s in servers.values) {
_closeOneServer(s.value.spi.id);
}
return;
}
_closeOneServer(id);
}
static void _closeOneServer(String id) {
final s = servers[id];
if (s == null) {
Loggers.app.warning('Server with id $id not found');
return;
}
final item = s.value;
item.client?.close();
item.client = null;
item.conn = ServerConn.disconnected;
_manualDisconnectedIds.add(id);
s.notify();
// Remove SSH session when server is manually closed
final sessionId = 'ssh_$id';
TermSessionManager.remove(sessionId);
}
static void addServer(Spi spi) {
servers[spi.id] = genServer(spi).vn;
Stores.server.put(spi);
serverOrder.value.add(spi.id);
serverOrder.notify();
Stores.setting.serverOrder.put(serverOrder.value);
_updateTags();
refresh(spi: spi);
bakSync.sync(milliDelay: 1000);
}
static void delServer(String id) {
servers.remove(id);
serverOrder.value.remove(id);
serverOrder.notify();
Stores.setting.serverOrder.put(serverOrder.value);
Stores.server.delete(id);
_updateTags();
// Remove SSH session when server is deleted
final sessionId = 'ssh_$id';
TermSessionManager.remove(sessionId);
bakSync.sync(milliDelay: 1000);
}
static void deleteAll() {
// Remove all SSH sessions before clearing servers
for (final id in servers.keys) {
final sessionId = 'ssh_$id';
TermSessionManager.remove(sessionId);
}
servers.clear();
serverOrder.value.clear();
serverOrder.notify();
Stores.setting.serverOrder.put(serverOrder.value);
Stores.server.clear();
_updateTags();
bakSync.sync(milliDelay: 1000);
}
static Future<void> updateServer(Spi old, Spi newSpi) async {
if (old != newSpi) {
Stores.server.update(old, newSpi);
servers[old.id]?.value.spi = newSpi;
if (newSpi.id != old.id) {
servers[newSpi.id] = servers[old.id]!;
servers.remove(old.id);
serverOrder.value.update(old.id, newSpi.id);
Stores.setting.serverOrder.put(serverOrder.value);
serverOrder.notify();
// Update SSH session ID when server ID changes
final oldSessionId = 'ssh_${old.id}';
TermSessionManager.remove(oldSessionId);
// Session will be re-added when reconnecting if necessary
}
// Only reconnect if neccessary
if (newSpi.shouldReconnect(old)) {
// Use [newSpi.id] instead of [old.id] because [old.id] may be changed
TryLimiter.reset(newSpi.id);
refresh(spi: newSpi);
}
}
_updateTags();
bakSync.sync(milliDelay: 1000);
}
static void _setServerState(VNode<Server> s, ServerConn ss) {
s.value.conn = ss;
s.notify();
}
static Future<void> _getData(Spi spi) async {
final sid = spi.id;
final s = servers[sid];
if (s == null) return;
final sv = s.value;
if (!TryLimiter.canTry(sid)) {
if (sv.conn != ServerConn.failed) {
_setServerState(s, ServerConn.failed);
}
return;
}
sv.status.err = null;
if (sv.needGenClient || (sv.client?.isClosed ?? true)) {
_setServerState(s, ServerConn.connecting);
final wol = spi.wolCfg;
if (wol != null) {
try {
await wol.wake();
} catch (e) {
// TryLimiter.inc(sid);
// s.status.err = SSHErr(
// type: SSHErrType.connect,
// message: 'Wake on lan failed: $e',
// );
// _setServerState(s, ServerConn.failed);
Loggers.app.warning('Wake on lan failed', e);
// return;
}
}
try {
final time1 = DateTime.now();
sv.client = await genClient(
spi,
timeout: Duration(seconds: Stores.setting.timeout.fetch()),
onKeyboardInteractive: (_) => KeybordInteractive.defaultHandle(spi),
);
final time2 = DateTime.now();
final spentTime = time2.difference(time1).inMilliseconds;
if (spi.jumpId == null) {
Loggers.app.info('Connected to ${spi.name} in $spentTime ms.');
} else {
Loggers.app.info('Jump to ${spi.name} in $spentTime ms.');
}
// Add SSH session to TermSessionManager
final sessionId = 'ssh_${spi.id}';
TermSessionManager.add(
id: sessionId,
spi: spi,
startTimeMs: time1.millisecondsSinceEpoch,
disconnect: () => _closeOneServer(spi.id),
status: TermSessionStatus.connecting,
);
TermSessionManager.setActive(sessionId, hasTerminal: false);
} catch (e) {
TryLimiter.inc(sid);
sv.status.err = SSHErr(type: SSHErrType.connect, message: e.toString());
_setServerState(s, ServerConn.failed);
// Remove SSH session on connection failure
final sessionId = 'ssh_${spi.id}';
TermSessionManager.remove(sessionId);
/// In order to keep privacy, print [spi.name] instead of [spi.id]
Loggers.app.warning('Connect to ${spi.name} failed', e);
return;
}
_setServerState(s, ServerConn.connected);
// Update SSH session status to connected
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.connected);
try {
// Detect system type using helper
final detectedSystemType = await SystemDetector.detect(sv.client!, spi);
sv.status.system = detectedSystemType;
final (_, writeScriptResult) = await sv.client!.exec((session) async {
final scriptRaw = ShellFuncManager.allScript(
spi.custom?.cmds,
systemType: detectedSystemType,
disabledCmdTypes: spi.disabledCmdTypes,
).uint8List;
session.stdin.add(scriptRaw);
session.stdin.close();
}, entry: ShellFuncManager.getInstallShellCmd(spi.id, systemType: detectedSystemType));
if (writeScriptResult.isNotEmpty && detectedSystemType != SystemType.windows) {
ShellFuncManager.switchScriptDir(spi.id, systemType: detectedSystemType);
throw writeScriptResult;
}
} on SSHAuthAbortError catch (e) {
TryLimiter.inc(sid);
final err = SSHErr(type: SSHErrType.auth, message: e.toString());
sv.status.err = err;
Loggers.app.warning(err);
_setServerState(s, ServerConn.failed);
// Update SSH session status to disconnected
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
return;
} on SSHAuthFailError catch (e) {
TryLimiter.inc(sid);
final err = SSHErr(type: SSHErrType.auth, message: e.toString());
sv.status.err = err;
Loggers.app.warning(err);
_setServerState(s, ServerConn.failed);
// Update SSH session status to disconnected
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
return;
} catch (e) {
// If max try times < 2 and can't write script, this will stop the status getting and etc.
// TryLimiter.inc(sid);
final err = SSHErr(type: SSHErrType.writeScript, message: e.toString());
sv.status.err = err;
Loggers.app.warning(err);
_setServerState(s, ServerConn.failed);
// Update SSH session status to disconnected
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
}
}
if (sv.conn == ServerConn.connecting) return;
/// Keep [finished] state, or the UI will be refreshed to [loading] state
/// instead of the '$Temp | $Uptime'.
/// eg: '32C | 7 days'
if (sv.conn != ServerConn.finished) {
_setServerState(s, ServerConn.loading);
}
List<String>? segments;
String? raw;
try {
raw = await sv.client?.run(ShellFunc.status.exec(spi.id, systemType: sv.status.system)).string;
//dprint('Get status from ${spi.name}:\n$raw');
segments = raw?.split(ScriptConstants.separator).map((e) => e.trim()).toList();
if (raw == null || raw.isEmpty || segments == null || segments.isEmpty) {
if (Stores.setting.keepStatusWhenErr.fetch()) {
// Keep previous server status when err occurs
if (sv.conn != ServerConn.failed && sv.status.more.isNotEmpty) {
return;
}
}
TryLimiter.inc(sid);
sv.status.err = SSHErr(type: SSHErrType.segements, message: 'Seperate segments failed, raw:\n$raw');
_setServerState(s, ServerConn.failed);
// Update SSH session status to disconnected on segments error
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
return;
}
} catch (e) {
TryLimiter.inc(sid);
sv.status.err = SSHErr(type: SSHErrType.getStatus, message: e.toString());
_setServerState(s, ServerConn.failed);
Loggers.app.warning('Get status from ${spi.name} failed', e);
// Update SSH session status to disconnected on status error
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
return;
}
try {
// Parse script output into command-specific map
final parsedOutput = ScriptConstants.parseScriptOutput(raw);
final req = ServerStatusUpdateReq(
ss: sv.status,
parsedOutput: parsedOutput,
system: sv.status.system,
customCmds: spi.custom?.cmds ?? {},
);
sv.status = await Computer.shared.start(getStatus, req, taskName: 'StatusUpdateReq<${sv.id}>');
} catch (e, trace) {
TryLimiter.inc(sid);
sv.status.err = SSHErr(type: SSHErrType.getStatus, message: 'Parse failed: $e\n\n$raw');
_setServerState(s, ServerConn.failed);
Loggers.app.warning('Server status', e, trace);
// Update SSH session status to disconnected on parse error
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
return;
}
/// Call this every time for setting [Server.isBusy] to false
_setServerState(s, ServerConn.finished);
// reset try times only after prepared successfully
TryLimiter.reset(sid);
}
}

View File

@@ -0,0 +1,329 @@
import 'dart:async';
import 'package:fl_lib/fl_lib.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:server_box/core/sync.dart';
import 'package:server_box/data/model/server/server.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/try_limiter.dart';
import 'package:server_box/data/provider/server/single.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/data/ssh/session_manager.dart';
part 'all.freezed.dart';
part 'all.g.dart';
@freezed
abstract class ServersState with _$ServersState {
const factory ServersState({
@Default({}) Map<String, Spi> servers,
@Default([]) List<String> serverOrder,
@Default(<String>{}) Set<String> tags,
@Default(<String>{}) Set<String> manualDisconnectedIds,
Timer? autoRefreshTimer,
}) = _ServersState;
}
@Riverpod(keepAlive: true)
class ServersNotifier extends _$ServersNotifier {
@override
ServersState build() {
return _load();
}
Future<void> reload() async {
final newState = _load();
if (newState == state) return;
state = newState;
await refresh();
}
ServersState _load() {
final spis = Stores.server.fetch();
final newServers = <String, Spi>{};
final newServerOrder = <String>[];
for (final spi in spis) {
newServers[spi.id] = spi;
}
final serverOrder_ = Stores.setting.serverOrder.fetch();
if (serverOrder_.isNotEmpty) {
spis.reorder(order: serverOrder_, finder: (n, id) => n.id == id);
newServerOrder.addAll(spis.map((e) => e.id));
} else {
newServerOrder.addAll(newServers.keys);
}
// Must use [equals] to compare [Order] here.
if (!newServerOrder.equals(serverOrder_)) {
Stores.setting.serverOrder.put(newServerOrder);
}
final newTags = _calculateTags(newServers);
return stateOrNull?.copyWith(servers: newServers, serverOrder: newServerOrder, tags: newTags) ??
ServersState(servers: newServers, serverOrder: newServerOrder, tags: newTags);
}
Set<String> _calculateTags(Map<String, Spi> servers) {
final tags = <String>{};
for (final spi in servers.values) {
final spiTags = spi.tags;
if (spiTags == null) continue;
for (final t in spiTags) {
tags.add(t);
}
}
return tags;
}
/// Get a [Spi] by [spi] or [id].
///
/// Priority: [spi] > [id]
Spi? pick({Spi? spi, String? id}) {
if (spi != null) {
return state.servers[spi.id];
}
if (id != null) {
return state.servers[id];
}
return null;
}
/// if [spi] is specificed then only refresh this server
/// [onlyFailed] only refresh failed servers
Future<void> refresh({Spi? spi, bool onlyFailed = false}) async {
if (spi != null) {
final newManualDisconnected = Set<String>.from(state.manualDisconnectedIds)..remove(spi.id);
state = state.copyWith(manualDisconnectedIds: newManualDisconnected);
final serverNotifier = ref.read(serverProvider(spi.id).notifier);
await serverNotifier.refresh();
return;
}
final serversToRefresh = <MapEntry<String, Spi>>[];
final idsToResetLimiter = <String>[];
for (final entry in state.servers.entries) {
final serverId = entry.key;
final spi = entry.value;
if (state.manualDisconnectedIds.contains(serverId)) continue;
final serverState = ref.read(serverProvider(serverId));
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 <= 1 || duration > 10) {
Loggers.app.warning('Invalid duration: $duration, use default 3');
duration = 3;
}
final timer = Timer.periodic(Duration(seconds: duration), (_) async {
await refresh();
});
state = state.copyWith(autoRefreshTimer: timer);
}
void stopAutoRefresh() {
final timer = state.autoRefreshTimer;
if (timer != null) {
timer.cancel();
}
state = state.copyWith(autoRefreshTimer: null);
}
bool get isAutoRefreshOn => state.autoRefreshTimer != null;
void setDisconnected() {
for (final serverId in state.servers.keys) {
final serverNotifier = ref.read(serverProvider(serverId).notifier);
serverNotifier.updateConnection(ServerConn.disconnected);
// Update SSH session status to disconnected
final sessionId = 'ssh_$serverId';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
}
//TryLimiter.clear();
}
void closeServer({String? id}) {
if (id == null) {
for (final serverId in state.servers.keys) {
closeOneServer(serverId);
}
return;
}
closeOneServer(id);
}
void closeOneServer(String id) {
final spi = state.servers[id];
if (spi == null) {
Loggers.app.warning('Server with id $id not found');
return;
}
final serverNotifier = ref.read(serverProvider(id).notifier);
serverNotifier.closeConnection();
final newManualDisconnected = Set<String>.from(state.manualDisconnectedIds)..add(id);
state = state.copyWith(manualDisconnectedIds: newManualDisconnected);
// Remove SSH session when server is manually closed
final sessionId = 'ssh_$id';
TermSessionManager.remove(sessionId);
}
void addServer(Spi spi) {
final newServers = Map<String, Spi>.from(state.servers);
newServers[spi.id] = spi;
final newOrder = List<String>.from(state.serverOrder)..add(spi.id);
final newTags = _calculateTags(newServers);
state = state.copyWith(servers: newServers, serverOrder: newOrder, tags: newTags);
Stores.server.put(spi);
Stores.setting.serverOrder.put(newOrder);
refresh(spi: spi);
bakSync.sync(milliDelay: 1000);
}
void delServer(String id) {
final newServers = Map<String, Spi>.from(state.servers);
newServers.remove(id);
final newOrder = List<String>.from(state.serverOrder)..remove(id);
final newTags = _calculateTags(newServers);
state = state.copyWith(servers: newServers, serverOrder: newOrder, tags: newTags);
Stores.setting.serverOrder.put(newOrder);
Stores.server.delete(id);
// Remove SSH session when server is deleted
final sessionId = 'ssh_$id';
TermSessionManager.remove(sessionId);
bakSync.sync(milliDelay: 1000);
}
void deleteAll() {
// Remove all SSH sessions before clearing servers
for (final id in state.servers.keys) {
final sessionId = 'ssh_$id';
TermSessionManager.remove(sessionId);
}
state = const ServersState();
Stores.setting.serverOrder.put([]);
Stores.server.clear();
bakSync.sync(milliDelay: 1000);
}
void updateServerOrder(List<String> order) {
final seen = <String>{};
final newOrder = <String>[];
for (final id in order) {
if (!state.servers.containsKey(id)) {
continue;
}
if (!seen.add(id)) {
continue;
}
newOrder.add(id);
}
for (final id in state.servers.keys) {
if (seen.add(id)) {
newOrder.add(id);
}
}
if (_isSameOrder(newOrder, state.serverOrder)) {
return;
}
state = state.copyWith(serverOrder: newOrder);
Stores.setting.serverOrder.put(newOrder);
bakSync.sync(milliDelay: 1000);
}
bool _isSameOrder(List<String> a, List<String> b) {
if (identical(a, b)) {
return true;
}
if (a.length != b.length) {
return false;
}
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) {
return false;
}
}
return true;
}
Future<void> updateServer(Spi old, Spi newSpi) async {
if (old != newSpi) {
Stores.server.update(old, newSpi);
final newServers = Map<String, Spi>.from(state.servers);
final newOrder = List<String>.from(state.serverOrder);
if (newSpi.id != old.id) {
newServers[newSpi.id] = newSpi;
newServers.remove(old.id);
newOrder.update(old.id, newSpi.id);
Stores.setting.serverOrder.put(newOrder);
// Update SSH session ID when server ID changes
final oldSessionId = 'ssh_${old.id}';
TermSessionManager.remove(oldSessionId);
// Session will be re-added when reconnecting if necessary
} else {
newServers[old.id] = newSpi;
// Update SPI in the corresponding IndividualServerNotifier
final serverNotifier = ref.read(serverProvider(old.id).notifier);
serverNotifier.updateSpi(newSpi);
}
final newTags = _calculateTags(newServers);
state = state.copyWith(servers: newServers, serverOrder: newOrder, tags: newTags);
// Only reconnect if neccessary
if (newSpi.shouldReconnect(old)) {
// Use [newSpi.id] instead of [old.id] because [old.id] may be changed
TryLimiter.reset(newSpi.id);
refresh(spi: newSpi);
}
}
bakSync.sync(milliDelay: 1000);
}
}

View File

@@ -0,0 +1,307 @@
// 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 'all.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ServersState {
Map<String, Spi> get servers; List<String> get serverOrder; Set<String> get tags; Set<String> get manualDisconnectedIds; Timer? get autoRefreshTimer;
/// Create a copy of ServersState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ServersStateCopyWith<ServersState> get copyWith => _$ServersStateCopyWithImpl<ServersState>(this as ServersState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ServersState&&const DeepCollectionEquality().equals(other.servers, servers)&&const DeepCollectionEquality().equals(other.serverOrder, serverOrder)&&const DeepCollectionEquality().equals(other.tags, tags)&&const DeepCollectionEquality().equals(other.manualDisconnectedIds, manualDisconnectedIds)&&(identical(other.autoRefreshTimer, autoRefreshTimer) || other.autoRefreshTimer == autoRefreshTimer));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(servers),const DeepCollectionEquality().hash(serverOrder),const DeepCollectionEquality().hash(tags),const DeepCollectionEquality().hash(manualDisconnectedIds),autoRefreshTimer);
@override
String toString() {
return 'ServersState(servers: $servers, serverOrder: $serverOrder, tags: $tags, manualDisconnectedIds: $manualDisconnectedIds, autoRefreshTimer: $autoRefreshTimer)';
}
}
/// @nodoc
abstract mixin class $ServersStateCopyWith<$Res> {
factory $ServersStateCopyWith(ServersState value, $Res Function(ServersState) _then) = _$ServersStateCopyWithImpl;
@useResult
$Res call({
Map<String, Spi> servers, List<String> serverOrder, Set<String> tags, Set<String> manualDisconnectedIds, Timer? autoRefreshTimer
});
}
/// @nodoc
class _$ServersStateCopyWithImpl<$Res>
implements $ServersStateCopyWith<$Res> {
_$ServersStateCopyWithImpl(this._self, this._then);
final ServersState _self;
final $Res Function(ServersState) _then;
/// Create a copy of ServersState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? servers = null,Object? serverOrder = null,Object? tags = null,Object? manualDisconnectedIds = null,Object? autoRefreshTimer = freezed,}) {
return _then(_self.copyWith(
servers: null == servers ? _self.servers : servers // ignore: cast_nullable_to_non_nullable
as Map<String, Spi>,serverOrder: null == serverOrder ? _self.serverOrder : serverOrder // ignore: cast_nullable_to_non_nullable
as List<String>,tags: null == tags ? _self.tags : tags // ignore: cast_nullable_to_non_nullable
as Set<String>,manualDisconnectedIds: null == manualDisconnectedIds ? _self.manualDisconnectedIds : manualDisconnectedIds // ignore: cast_nullable_to_non_nullable
as Set<String>,autoRefreshTimer: freezed == autoRefreshTimer ? _self.autoRefreshTimer : autoRefreshTimer // ignore: cast_nullable_to_non_nullable
as Timer?,
));
}
}
/// Adds pattern-matching-related methods to [ServersState].
extension ServersStatePatterns on ServersState {
/// 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( _ServersState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ServersState() 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( _ServersState value) $default,){
final _that = this;
switch (_that) {
case _ServersState():
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( _ServersState value)? $default,){
final _that = this;
switch (_that) {
case _ServersState() 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( Map<String, Spi> servers, List<String> serverOrder, Set<String> tags, Set<String> manualDisconnectedIds, Timer? autoRefreshTimer)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ServersState() when $default != null:
return $default(_that.servers,_that.serverOrder,_that.tags,_that.manualDisconnectedIds,_that.autoRefreshTimer);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( Map<String, Spi> servers, List<String> serverOrder, Set<String> tags, Set<String> manualDisconnectedIds, Timer? autoRefreshTimer) $default,) {final _that = this;
switch (_that) {
case _ServersState():
return $default(_that.servers,_that.serverOrder,_that.tags,_that.manualDisconnectedIds,_that.autoRefreshTimer);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( Map<String, Spi> servers, List<String> serverOrder, Set<String> tags, Set<String> manualDisconnectedIds, Timer? autoRefreshTimer)? $default,) {final _that = this;
switch (_that) {
case _ServersState() when $default != null:
return $default(_that.servers,_that.serverOrder,_that.tags,_that.manualDisconnectedIds,_that.autoRefreshTimer);case _:
return null;
}
}
}
/// @nodoc
class _ServersState implements ServersState {
const _ServersState({final Map<String, Spi> servers = const {}, final List<String> serverOrder = const [], final Set<String> tags = const <String>{}, final Set<String> manualDisconnectedIds = const <String>{}, this.autoRefreshTimer}): _servers = servers,_serverOrder = serverOrder,_tags = tags,_manualDisconnectedIds = manualDisconnectedIds;
final Map<String, Spi> _servers;
@override@JsonKey() Map<String, Spi> get servers {
if (_servers is EqualUnmodifiableMapView) return _servers;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_servers);
}
final List<String> _serverOrder;
@override@JsonKey() List<String> get serverOrder {
if (_serverOrder is EqualUnmodifiableListView) return _serverOrder;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_serverOrder);
}
final Set<String> _tags;
@override@JsonKey() Set<String> get tags {
if (_tags is EqualUnmodifiableSetView) return _tags;
// ignore: implicit_dynamic_type
return EqualUnmodifiableSetView(_tags);
}
final Set<String> _manualDisconnectedIds;
@override@JsonKey() Set<String> get manualDisconnectedIds {
if (_manualDisconnectedIds is EqualUnmodifiableSetView) return _manualDisconnectedIds;
// ignore: implicit_dynamic_type
return EqualUnmodifiableSetView(_manualDisconnectedIds);
}
@override final Timer? autoRefreshTimer;
/// Create a copy of ServersState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ServersStateCopyWith<_ServersState> get copyWith => __$ServersStateCopyWithImpl<_ServersState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ServersState&&const DeepCollectionEquality().equals(other._servers, _servers)&&const DeepCollectionEquality().equals(other._serverOrder, _serverOrder)&&const DeepCollectionEquality().equals(other._tags, _tags)&&const DeepCollectionEquality().equals(other._manualDisconnectedIds, _manualDisconnectedIds)&&(identical(other.autoRefreshTimer, autoRefreshTimer) || other.autoRefreshTimer == autoRefreshTimer));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_servers),const DeepCollectionEquality().hash(_serverOrder),const DeepCollectionEquality().hash(_tags),const DeepCollectionEquality().hash(_manualDisconnectedIds),autoRefreshTimer);
@override
String toString() {
return 'ServersState(servers: $servers, serverOrder: $serverOrder, tags: $tags, manualDisconnectedIds: $manualDisconnectedIds, autoRefreshTimer: $autoRefreshTimer)';
}
}
/// @nodoc
abstract mixin class _$ServersStateCopyWith<$Res> implements $ServersStateCopyWith<$Res> {
factory _$ServersStateCopyWith(_ServersState value, $Res Function(_ServersState) _then) = __$ServersStateCopyWithImpl;
@override @useResult
$Res call({
Map<String, Spi> servers, List<String> serverOrder, Set<String> tags, Set<String> manualDisconnectedIds, Timer? autoRefreshTimer
});
}
/// @nodoc
class __$ServersStateCopyWithImpl<$Res>
implements _$ServersStateCopyWith<$Res> {
__$ServersStateCopyWithImpl(this._self, this._then);
final _ServersState _self;
final $Res Function(_ServersState) _then;
/// Create a copy of ServersState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? servers = null,Object? serverOrder = null,Object? tags = null,Object? manualDisconnectedIds = null,Object? autoRefreshTimer = freezed,}) {
return _then(_ServersState(
servers: null == servers ? _self._servers : servers // ignore: cast_nullable_to_non_nullable
as Map<String, Spi>,serverOrder: null == serverOrder ? _self._serverOrder : serverOrder // ignore: cast_nullable_to_non_nullable
as List<String>,tags: null == tags ? _self._tags : tags // ignore: cast_nullable_to_non_nullable
as Set<String>,manualDisconnectedIds: null == manualDisconnectedIds ? _self._manualDisconnectedIds : manualDisconnectedIds // ignore: cast_nullable_to_non_nullable
as Set<String>,autoRefreshTimer: freezed == autoRefreshTimer ? _self.autoRefreshTimer : autoRefreshTimer // ignore: cast_nullable_to_non_nullable
as Timer?,
));
}
}
// dart format on

View File

@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'all.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ServersNotifier)
const serversProvider = ServersNotifierProvider._();
final class ServersNotifierProvider
extends $NotifierProvider<ServersNotifier, ServersState> {
const ServersNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'serversProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$serversNotifierHash();
@$internal
@override
ServersNotifier create() => ServersNotifier();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ServersState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ServersState>(value),
);
}
}
String _$serversNotifierHash() => r'277d1b219235f14bcc1b82a1e16260c2f28decdb';
abstract class _$ServersNotifier extends $Notifier<ServersState> {
ServersState build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<ServersState, ServersState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<ServersState, ServersState>,
ServersState,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -0,0 +1,385 @@
import 'dart:async';
import 'package:computer/computer.dart';
import 'package:dartssh2/dartssh2.dart';
import 'package:fl_lib/fl_lib.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';
import 'package:server_box/data/model/app/scripts/shell_func.dart';
import 'package:server_box/data/model/server/connection_stat.dart';
import 'package:server_box/data/model/server/server.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/server_status_update_req.dart';
import 'package:server_box/data/model/server/system.dart';
import 'package:server_box/data/model/server/try_limiter.dart';
import 'package:server_box/data/provider/server/all.dart';
import 'package:server_box/data/res/status.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/data/ssh/session_manager.dart';
part 'single.g.dart';
part 'single.freezed.dart';
// Individual server state, including connection and status information
@freezed
abstract class ServerState with _$ServerState {
const factory ServerState({
required Spi spi,
required ServerStatus status,
@Default(ServerConn.disconnected) ServerConn conn,
SSHClient? client,
}) = _ServerState;
}
// Individual server state management
@Riverpod(keepAlive: true)
class ServerNotifier extends _$ServerNotifier {
@override
ServerState build(String serverId) {
final serverNotifier = ref.read(serversProvider);
final spi = serverNotifier.servers[serverId];
if (spi == null) {
throw StateError('Server $serverId not found');
}
return ServerState(spi: spi, status: InitStatus.status);
}
// Update connection status
void updateConnection(ServerConn conn) {
state = state.copyWith(conn: conn);
}
// Update server status
void updateStatus(ServerStatus status) {
state = state.copyWith(status: status);
}
// Update SSH client
void updateClient(SSHClient? client) {
state = state.copyWith(client: client);
}
// Update SPI configuration
void updateSpi(Spi spi) {
state = state.copyWith(spi: spi);
}
// Close connection
void closeConnection() {
final client = state.client;
client?.close();
state = state.copyWith(client: null, conn: ServerConn.disconnected);
}
// Refresh server status
bool _isRefreshing = false;
Future<void> refresh() async {
if (_isRefreshing) return;
_isRefreshing = true;
try {
await _updateServer();
} finally {
_isRefreshing = false;
}
}
Future<void> _updateServer() async {
await _getData();
}
Future<void> _getData() async {
final spi = state.spi;
final sid = spi.id;
if (!TryLimiter.canTry(sid)) {
if (state.conn != ServerConn.failed) {
updateConnection(ServerConn.failed);
}
return;
}
final newStatus = state.status..err = null; // Clear previous error
updateStatus(newStatus);
if (state.conn < ServerConn.connecting || (state.client?.isClosed ?? true)) {
updateConnection(ServerConn.connecting);
// Wake on LAN
final wol = spi.wolCfg;
if (wol != null) {
try {
await wol.wake();
} catch (e) {
Loggers.app.warning('Wake on lan failed', e);
}
}
try {
final time1 = DateTime.now();
final client = await genClient(
spi,
timeout: Duration(seconds: Stores.setting.timeout.fetch()),
onKeyboardInteractive: (_) => KeybordInteractive.defaultHandle(spi),
);
updateClient(client);
final time2 = DateTime.now();
final spentTime = time2.difference(time1).inMilliseconds;
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.');
}
// Record successful connection
Stores.connectionStats.recordConnection(ConnectionStat(
serverId: spi.id,
serverName: spi.name,
timestamp: time1,
result: ConnectionResult.success,
durationMs: spentTime,
));
final sessionId = 'ssh_${spi.id}';
TermSessionManager.add(
id: sessionId,
spi: spi,
startTimeMs: time1.millisecondsSinceEpoch,
disconnect: () => ref.read(serversProvider.notifier).closeOneServer(spi.id),
status: TermSessionStatus.connecting,
);
TermSessionManager.setActive(sessionId, hasTerminal: false);
} catch (e) {
TryLimiter.inc(sid);
// Determine connection failure type
ConnectionResult failureResult;
if (e.toString().contains('timeout') || e.toString().contains('Timeout')) {
failureResult = ConnectionResult.timeout;
} else if (e.toString().contains('auth') || e.toString().contains('Authentication')) {
failureResult = ConnectionResult.authFailed;
} else if (e.toString().contains('network') || e.toString().contains('Network')) {
failureResult = ConnectionResult.networkError;
} else {
failureResult = ConnectionResult.unknownError;
}
// Record failed connection
Stores.connectionStats.recordConnection(ConnectionStat(
serverId: spi.id,
serverName: spi.name,
timestamp: DateTime.now(),
result: failureResult,
errorMessage: e.toString(),
durationMs: 0,
));
final newStatus = state.status..err = SSHErr(type: SSHErrType.connect, message: e.toString());
updateStatus(newStatus);
updateConnection(ServerConn.failed);
// Remove SSH session when connection fails
final sessionId = 'ssh_${spi.id}';
TermSessionManager.remove(sessionId);
Loggers.app.warning('Connect to ${spi.name} failed', e);
return;
}
updateConnection(ServerConn.connected);
// Update SSH session status to connected
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.connected);
try {
// Detect system type
final detectedSystemType = await SystemDetector.detect(state.client!, spi);
final newStatus = state.status..system = detectedSystemType;
updateStatus(newStatus);
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,
systemType: detectedSystemType,
disabledCmdTypes: spi.disabledCmdTypes,
).uint8List;
session.stdin.add(scriptRaw);
session.stdin.close();
},
entry: ShellFuncManager.getInstallShellCmd(
spi.id,
systemType: detectedSystemType,
customDir: spi.custom?.scriptDir,
),
systemType: detectedSystemType,
context: 'WriteScript<${spi.name}>',
);
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);
final err = SSHErr(type: SSHErrType.auth, message: e.toString());
final newStatus = state.status..err = err;
updateStatus(newStatus);
Loggers.app.warning(err);
updateConnection(ServerConn.failed);
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
return;
} on SSHAuthFailError catch (e) {
TryLimiter.inc(sid);
final err = SSHErr(type: SSHErrType.auth, message: e.toString());
final newStatus = state.status..err = err;
updateStatus(newStatus);
Loggers.app.warning(err);
updateConnection(ServerConn.failed);
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
return;
} catch (e) {
final err = SSHErr(type: SSHErrType.writeScript, message: e.toString());
final newStatus = state.status..err = err;
updateStatus(newStatus);
Loggers.app.warning(err);
updateConnection(ServerConn.failed);
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
}
}
if (state.conn == ServerConn.connecting) return;
// Keep finished status to prevent UI from refreshing to loading state
if (state.conn != ServerConn.finished) {
updateConnection(ServerConn.loading);
}
List<String>? segments;
String? raw;
try {
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) {
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 = '';
Loggers.app.warning('No status result from ${spi.name}');
}
if (raw.isEmpty) {
TryLimiter.inc(sid);
final newStatus = state.status
..err = SSHErr(type: SSHErrType.segements, message: 'Empty response from server');
updateStatus(newStatus);
updateConnection(ServerConn.failed);
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
return;
}
segments = raw.split(ScriptConstants.separator).map((e) => e.trim()).toList();
if (segments.isEmpty) {
if (Stores.setting.keepStatusWhenErr.fetch()) {
// Keep previous server status when error occurs
if (state.conn != ServerConn.failed && state.status.more.isNotEmpty) {
return;
}
}
TryLimiter.inc(sid);
final newStatus = state.status
..err = SSHErr(type: SSHErrType.segements, message: 'Separate segments failed, raw:\n$raw');
updateStatus(newStatus);
updateConnection(ServerConn.failed);
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
return;
}
} catch (e) {
TryLimiter.inc(sid);
final newStatus = state.status..err = SSHErr(type: SSHErrType.getStatus, message: e.toString());
updateStatus(newStatus);
updateConnection(ServerConn.failed);
Loggers.app.warning('Get status from ${spi.name} failed', e);
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
return;
}
try {
// Parse script output into command-specific mappings
final parsedOutput = ScriptConstants.parseScriptOutput(raw);
final req = ServerStatusUpdateReq(
ss: state.status,
parsedOutput: parsedOutput,
system: state.status.system,
customCmds: spi.custom?.cmds ?? {},
);
final newStatus = await Computer.shared.start(getStatus, req, taskName: 'StatusUpdateReq<${spi.id}>');
updateStatus(newStatus);
} catch (e, trace) {
TryLimiter.inc(sid);
final newStatus = state.status
..err = SSHErr(type: SSHErrType.getStatus, message: 'Parse failed: $e\n\n$raw');
updateStatus(newStatus);
updateConnection(ServerConn.failed);
Loggers.app.warning('Server status', e, trace);
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
return;
}
// Set Server.isBusy to false each time this method is called
updateConnection(ServerConn.finished);
// Reset retry count only after successful preparation
TryLimiter.reset(sid);
}
}
extension IndividualServerStateExtension on ServerState {
bool get needGenClient => conn < ServerConn.connecting;
bool get canViewDetails => conn == ServerConn.finished;
String get id => spi.id;
}

View File

@@ -0,0 +1,298 @@
// 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 'single.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ServerState {
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)
@pragma('vm:prefer-inline')
$ServerStateCopyWith<ServerState> get copyWith => _$ServerStateCopyWithImpl<ServerState>(this as ServerState, _$identity);
@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));
}
@override
int get hashCode => Object.hash(runtimeType,spi,status,conn,client);
@override
String toString() {
return 'ServerState(spi: $spi, status: $status, conn: $conn, client: $client)';
}
}
/// @nodoc
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
});
$SpiCopyWith<$Res> get spi;
}
/// @nodoc
class _$ServerStateCopyWithImpl<$Res>
implements $ServerStateCopyWith<$Res> {
_$ServerStateCopyWithImpl(this._self, this._then);
final ServerState _self;
final $Res Function(ServerState) _then;
/// 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,}) {
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?,
));
}
/// Create a copy of ServerState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SpiCopyWith<$Res> get spi {
return $SpiCopyWith<$Res>(_self.spi, (value) {
return _then(_self.copyWith(spi: value));
});
}
}
/// Adds pattern-matching-related methods to [ServerState].
extension ServerStatePatterns on ServerState {
/// 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( _ServerState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ServerState() 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( _ServerState value) $default,){
final _that = this;
switch (_that) {
case _ServerState():
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( _ServerState value)? $default,){
final _that = this;
switch (_that) {
case _ServerState() 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( 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);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( 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);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( 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);case _:
return null;
}
}
}
/// @nodoc
class _ServerState implements ServerState {
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;
/// Create a copy of ServerState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ServerStateCopyWith<_ServerState> get copyWith => __$ServerStateCopyWithImpl<_ServerState>(this, _$identity);
@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));
}
@override
int get hashCode => Object.hash(runtimeType,spi,status,conn,client);
@override
String toString() {
return 'ServerState(spi: $spi, status: $status, conn: $conn, client: $client)';
}
}
/// @nodoc
abstract mixin class _$ServerStateCopyWith<$Res> implements $ServerStateCopyWith<$Res> {
factory _$ServerStateCopyWith(_ServerState value, $Res Function(_ServerState) _then) = __$ServerStateCopyWithImpl;
@override @useResult
$Res call({
Spi spi, ServerStatus status, ServerConn conn, SSHClient? client
});
@override $SpiCopyWith<$Res> get spi;
}
/// @nodoc
class __$ServerStateCopyWithImpl<$Res>
implements _$ServerStateCopyWith<$Res> {
__$ServerStateCopyWithImpl(this._self, this._then);
final _ServerState _self;
final $Res Function(_ServerState) _then;
/// 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,}) {
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?,
));
}
/// Create a copy of ServerState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SpiCopyWith<$Res> get spi {
return $SpiCopyWith<$Res>(_self.spi, (value) {
return _then(_self.copyWith(spi: value));
});
}
}
// dart format on

View File

@@ -0,0 +1,108 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'single.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ServerNotifier)
const serverProvider = ServerNotifierFamily._();
final class ServerNotifierProvider
extends $NotifierProvider<ServerNotifier, ServerState> {
const ServerNotifierProvider._({
required ServerNotifierFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'serverProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$serverNotifierHash();
@override
String toString() {
return r'serverProvider'
''
'($argument)';
}
@$internal
@override
ServerNotifier create() => ServerNotifier();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ServerState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ServerState>(value),
);
}
@override
bool operator ==(Object other) {
return other is ServerNotifierProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$serverNotifierHash() => r'52e806bcc32a7818d1ec2b07a3c683b06885c9f8';
final class ServerNotifierFamily extends $Family
with
$ClassFamilyOverride<
ServerNotifier,
ServerState,
ServerState,
ServerState,
String
> {
const ServerNotifierFamily._()
: super(
retry: null,
name: r'serverProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: false,
);
ServerNotifierProvider call(String serverId) =>
ServerNotifierProvider._(argument: serverId, from: this);
@override
String toString() => r'serverProvider';
}
abstract class _$ServerNotifier extends $Notifier<ServerState> {
late final _$args = ref.$arg as String;
String get serverId => _$args;
ServerState build(String serverId);
@$mustCallSuper
@override
void runBuild() {
final created = build(_$args);
final ref = this.ref as $Ref<ServerState, ServerState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<ServerState, ServerState>,
ServerState,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -1,41 +1,69 @@
import 'dart:async'; import 'dart:async';
import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/fl_lib.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:server_box/data/model/sftp/worker.dart'; import 'package:server_box/data/model/sftp/worker.dart';
class SftpProvider extends Provider { part 'sftp.freezed.dart';
const SftpProvider._(); part 'sftp.g.dart';
static const instance = SftpProvider._();
static final status = <SftpReqStatus>[].vn; @freezed
abstract class SftpState with _$SftpState {
const factory SftpState({
@Default(<SftpReqStatus>[]) List<SftpReqStatus> requests,
}) = _SftpState;
}
static SftpReqStatus? get(int id) { @Riverpod(keepAlive: true)
return status.value.singleWhere((element) => element.id == id); class SftpNotifier extends _$SftpNotifier {
@override
SftpState build() {
return const SftpState();
} }
static int add(SftpReq req, {Completer? completer}) { SftpReqStatus? get(int id) {
final reqStat = SftpReqStatus(notifyListeners: status.notify, completer: completer, req: req); try {
status.value.add(reqStat); return state.requests.singleWhere((element) => element.id == id);
status.notify(); } catch (e) {
return null;
}
}
int add(SftpReq req, {Completer? completer}) {
final reqStat = SftpReqStatus(
notifyListeners: _notifyListeners,
completer: completer,
req: req,
);
state = state.copyWith(
requests: [...state.requests, reqStat],
);
return reqStat.id; return reqStat.id;
} }
static void dispose() { void dispose() {
for (final item in status.value) { for (final item in state.requests) {
item.dispose(); item.dispose();
} }
status.value.clear(); state = state.copyWith(requests: []);
status.notify();
} }
static void cancel(int id) { void cancel(int id) {
final idx = status.value.indexWhere((e) => e.id == id); final idx = state.requests.indexWhere((e) => e.id == id);
if (idx < 0 || idx >= status.value.length) { if (idx < 0 || idx >= state.requests.length) {
dprint('SftpProvider.cancel: id $id not found'); dprint('SftpProvider.cancel: id $id not found');
return; return;
} }
status.value[idx].dispose(); final item = state.requests[idx];
status.value.removeAt(idx); item.dispose();
status.notify(); final newRequests = List<SftpReqStatus>.from(state.requests)
..removeAt(idx);
state = state.copyWith(requests: newRequests);
}
void _notifyListeners() {
// Force state update to notify listeners
state = state.copyWith();
} }
} }

View File

@@ -0,0 +1,277 @@
// 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 'sftp.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$SftpState {
List<SftpReqStatus> get requests;
/// Create a copy of SftpState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SftpStateCopyWith<SftpState> get copyWith => _$SftpStateCopyWithImpl<SftpState>(this as SftpState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SftpState&&const DeepCollectionEquality().equals(other.requests, requests));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(requests));
@override
String toString() {
return 'SftpState(requests: $requests)';
}
}
/// @nodoc
abstract mixin class $SftpStateCopyWith<$Res> {
factory $SftpStateCopyWith(SftpState value, $Res Function(SftpState) _then) = _$SftpStateCopyWithImpl;
@useResult
$Res call({
List<SftpReqStatus> requests
});
}
/// @nodoc
class _$SftpStateCopyWithImpl<$Res>
implements $SftpStateCopyWith<$Res> {
_$SftpStateCopyWithImpl(this._self, this._then);
final SftpState _self;
final $Res Function(SftpState) _then;
/// Create a copy of SftpState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? requests = null,}) {
return _then(_self.copyWith(
requests: null == requests ? _self.requests : requests // ignore: cast_nullable_to_non_nullable
as List<SftpReqStatus>,
));
}
}
/// Adds pattern-matching-related methods to [SftpState].
extension SftpStatePatterns on SftpState {
/// 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( _SftpState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _SftpState() 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( _SftpState value) $default,){
final _that = this;
switch (_that) {
case _SftpState():
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( _SftpState value)? $default,){
final _that = this;
switch (_that) {
case _SftpState() 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( List<SftpReqStatus> requests)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SftpState() when $default != null:
return $default(_that.requests);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( List<SftpReqStatus> requests) $default,) {final _that = this;
switch (_that) {
case _SftpState():
return $default(_that.requests);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( List<SftpReqStatus> requests)? $default,) {final _that = this;
switch (_that) {
case _SftpState() when $default != null:
return $default(_that.requests);case _:
return null;
}
}
}
/// @nodoc
class _SftpState implements SftpState {
const _SftpState({final List<SftpReqStatus> requests = const <SftpReqStatus>[]}): _requests = requests;
final List<SftpReqStatus> _requests;
@override@JsonKey() List<SftpReqStatus> get requests {
if (_requests is EqualUnmodifiableListView) return _requests;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_requests);
}
/// Create a copy of SftpState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SftpStateCopyWith<_SftpState> get copyWith => __$SftpStateCopyWithImpl<_SftpState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SftpState&&const DeepCollectionEquality().equals(other._requests, _requests));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_requests));
@override
String toString() {
return 'SftpState(requests: $requests)';
}
}
/// @nodoc
abstract mixin class _$SftpStateCopyWith<$Res> implements $SftpStateCopyWith<$Res> {
factory _$SftpStateCopyWith(_SftpState value, $Res Function(_SftpState) _then) = __$SftpStateCopyWithImpl;
@override @useResult
$Res call({
List<SftpReqStatus> requests
});
}
/// @nodoc
class __$SftpStateCopyWithImpl<$Res>
implements _$SftpStateCopyWith<$Res> {
__$SftpStateCopyWithImpl(this._self, this._then);
final _SftpState _self;
final $Res Function(_SftpState) _then;
/// Create a copy of SftpState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? requests = null,}) {
return _then(_SftpState(
requests: null == requests ? _self._requests : requests // ignore: cast_nullable_to_non_nullable
as List<SftpReqStatus>,
));
}
}
// dart format on

View File

@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'sftp.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(SftpNotifier)
const sftpProvider = SftpNotifierProvider._();
final class SftpNotifierProvider
extends $NotifierProvider<SftpNotifier, SftpState> {
const SftpNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'sftpProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$sftpNotifierHash();
@$internal
@override
SftpNotifier create() => SftpNotifier();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(SftpState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<SftpState>(value),
);
}
}
String _$sftpNotifierHash() => r'f8412a4bd1f2bc5919ec31a3eba1c27e9a578f41';
abstract class _$SftpNotifier extends $Notifier<SftpState> {
SftpState build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<SftpState, SftpState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<SftpState, SftpState>,
SftpState,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -1,80 +1,105 @@
import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/fl_lib.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:server_box/core/sync.dart'; import 'package:server_box/core/sync.dart';
import 'package:server_box/data/model/server/snippet.dart'; import 'package:server_box/data/model/server/snippet.dart';
import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/res/store.dart';
class SnippetProvider extends Provider { part 'snippet.freezed.dart';
const SnippetProvider._(); part 'snippet.g.dart';
static const instance = SnippetProvider._();
static final snippets = <Snippet>[].vn; @freezed
static final tags = <String>{}.vn; abstract class SnippetState with _$SnippetState {
const factory SnippetState({
@Default(<Snippet>[]) List<Snippet> snippets,
@Default(<String>{}) Set<String> tags,
}) = _SnippetState;
}
@Riverpod(keepAlive: true)
class SnippetNotifier extends _$SnippetNotifier {
@override @override
void load() { SnippetState build() {
super.load(); return _load();
final snippets_ = Stores.snippet.fetch(); }
void reload() {
final newState = _load();
if (newState == state) return;
state = newState;
}
SnippetState _load() {
final snippets = Stores.snippet.fetch();
final order = Stores.setting.snippetOrder.fetch(); final order = Stores.setting.snippetOrder.fetch();
List<Snippet> orderedSnippets = snippets;
if (order.isNotEmpty) { if (order.isNotEmpty) {
final surplus = snippets_.reorder( final surplus = snippets.reorder(order: order, finder: (n, name) => n.name == name);
order: order,
finder: (n, name) => n.name == name,
);
order.removeWhere((e) => surplus.any((ele) => ele == e)); order.removeWhere((e) => surplus.any((ele) => ele == e));
if (order != Stores.setting.snippetOrder.fetch()) { if (order != Stores.setting.snippetOrder.fetch()) {
Stores.setting.snippetOrder.put(order); Stores.setting.snippetOrder.put(order);
} }
orderedSnippets = snippets;
} }
snippets.value = snippets_;
_updateTags(); final newTags = _computeTags(orderedSnippets);
return stateOrNull?.copyWith(snippets: orderedSnippets, tags: newTags) ??
SnippetState(snippets: orderedSnippets, tags: newTags);
} }
static void _updateTags() { Set<String> _computeTags(List<Snippet> snippets) {
final tags_ = <String>{}; final tags = <String>{};
for (final s in snippets.value) { for (final s in snippets) {
final t = s.tags; final t = s.tags;
if (t != null) { if (t != null) {
tags_.addAll(t); tags.addAll(t);
} }
} }
tags.value = tags_; return tags;
} }
static void add(Snippet snippet) { void add(Snippet snippet) {
snippets.value.add(snippet); final newSnippets = [...state.snippets, snippet];
snippets.notify(); final newTags = _computeTags(newSnippets);
state = state.copyWith(snippets: newSnippets, tags: newTags);
Stores.snippet.put(snippet); Stores.snippet.put(snippet);
_updateTags();
bakSync.sync(milliDelay: 1000); bakSync.sync(milliDelay: 1000);
} }
static void del(Snippet snippet) { void del(Snippet snippet) {
snippets.value.remove(snippet); final newSnippets = state.snippets.where((s) => s != snippet).toList();
snippets.notify(); final newTags = _computeTags(newSnippets);
state = state.copyWith(snippets: newSnippets, tags: newTags);
Stores.snippet.delete(snippet); Stores.snippet.delete(snippet);
_updateTags();
bakSync.sync(milliDelay: 1000); bakSync.sync(milliDelay: 1000);
} }
static void update(Snippet old, Snippet newOne) { void update(Snippet old, Snippet newOne) {
snippets.value.remove(old); final newSnippets = state.snippets.map((s) => s == old ? newOne : s).toList();
snippets.value.add(newOne); final newTags = _computeTags(newSnippets);
snippets.notify(); state = state.copyWith(snippets: newSnippets, tags: newTags);
Stores.snippet.delete(old); Stores.snippet.delete(old);
Stores.snippet.put(newOne); Stores.snippet.put(newOne);
_updateTags();
bakSync.sync(milliDelay: 1000); bakSync.sync(milliDelay: 1000);
} }
static void renameTag(String old, String newOne) { void renameTag(String old, String newOne) {
for (final s in snippets.value) { final updatedSnippets = <Snippet>[];
for (final s in state.snippets) {
if (s.tags?.contains(old) ?? false) { if (s.tags?.contains(old) ?? false) {
s.tags?.remove(old); final newTags = Set<String>.from(s.tags!);
s.tags?.add(newOne); newTags.remove(old);
Stores.snippet.put(s); newTags.add(newOne);
final updatedSnippet = s.copyWith(tags: newTags.toList());
updatedSnippets.add(updatedSnippet);
Stores.snippet.put(updatedSnippet);
} else {
updatedSnippets.add(s);
} }
} }
_updateTags(); final newTags = _computeTags(updatedSnippets);
state = state.copyWith(snippets: updatedSnippets, tags: newTags);
bakSync.sync(milliDelay: 1000); bakSync.sync(milliDelay: 1000);
} }
} }

View File

@@ -0,0 +1,286 @@
// 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 'snippet.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$SnippetState {
List<Snippet> get snippets; Set<String> get tags;
/// Create a copy of SnippetState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnippetStateCopyWith<SnippetState> get copyWith => _$SnippetStateCopyWithImpl<SnippetState>(this as SnippetState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnippetState&&const DeepCollectionEquality().equals(other.snippets, snippets)&&const DeepCollectionEquality().equals(other.tags, tags));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(snippets),const DeepCollectionEquality().hash(tags));
@override
String toString() {
return 'SnippetState(snippets: $snippets, tags: $tags)';
}
}
/// @nodoc
abstract mixin class $SnippetStateCopyWith<$Res> {
factory $SnippetStateCopyWith(SnippetState value, $Res Function(SnippetState) _then) = _$SnippetStateCopyWithImpl;
@useResult
$Res call({
List<Snippet> snippets, Set<String> tags
});
}
/// @nodoc
class _$SnippetStateCopyWithImpl<$Res>
implements $SnippetStateCopyWith<$Res> {
_$SnippetStateCopyWithImpl(this._self, this._then);
final SnippetState _self;
final $Res Function(SnippetState) _then;
/// Create a copy of SnippetState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? snippets = null,Object? tags = null,}) {
return _then(_self.copyWith(
snippets: null == snippets ? _self.snippets : snippets // ignore: cast_nullable_to_non_nullable
as List<Snippet>,tags: null == tags ? _self.tags : tags // ignore: cast_nullable_to_non_nullable
as Set<String>,
));
}
}
/// Adds pattern-matching-related methods to [SnippetState].
extension SnippetStatePatterns on SnippetState {
/// 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( _SnippetState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _SnippetState() 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( _SnippetState value) $default,){
final _that = this;
switch (_that) {
case _SnippetState():
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( _SnippetState value)? $default,){
final _that = this;
switch (_that) {
case _SnippetState() 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( List<Snippet> snippets, Set<String> tags)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SnippetState() when $default != null:
return $default(_that.snippets,_that.tags);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( List<Snippet> snippets, Set<String> tags) $default,) {final _that = this;
switch (_that) {
case _SnippetState():
return $default(_that.snippets,_that.tags);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( List<Snippet> snippets, Set<String> tags)? $default,) {final _that = this;
switch (_that) {
case _SnippetState() when $default != null:
return $default(_that.snippets,_that.tags);case _:
return null;
}
}
}
/// @nodoc
class _SnippetState implements SnippetState {
const _SnippetState({final List<Snippet> snippets = const <Snippet>[], final Set<String> tags = const <String>{}}): _snippets = snippets,_tags = tags;
final List<Snippet> _snippets;
@override@JsonKey() List<Snippet> get snippets {
if (_snippets is EqualUnmodifiableListView) return _snippets;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_snippets);
}
final Set<String> _tags;
@override@JsonKey() Set<String> get tags {
if (_tags is EqualUnmodifiableSetView) return _tags;
// ignore: implicit_dynamic_type
return EqualUnmodifiableSetView(_tags);
}
/// Create a copy of SnippetState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnippetStateCopyWith<_SnippetState> get copyWith => __$SnippetStateCopyWithImpl<_SnippetState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnippetState&&const DeepCollectionEquality().equals(other._snippets, _snippets)&&const DeepCollectionEquality().equals(other._tags, _tags));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_snippets),const DeepCollectionEquality().hash(_tags));
@override
String toString() {
return 'SnippetState(snippets: $snippets, tags: $tags)';
}
}
/// @nodoc
abstract mixin class _$SnippetStateCopyWith<$Res> implements $SnippetStateCopyWith<$Res> {
factory _$SnippetStateCopyWith(_SnippetState value, $Res Function(_SnippetState) _then) = __$SnippetStateCopyWithImpl;
@override @useResult
$Res call({
List<Snippet> snippets, Set<String> tags
});
}
/// @nodoc
class __$SnippetStateCopyWithImpl<$Res>
implements _$SnippetStateCopyWith<$Res> {
__$SnippetStateCopyWithImpl(this._self, this._then);
final _SnippetState _self;
final $Res Function(_SnippetState) _then;
/// Create a copy of SnippetState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? snippets = null,Object? tags = null,}) {
return _then(_SnippetState(
snippets: null == snippets ? _self._snippets : snippets // ignore: cast_nullable_to_non_nullable
as List<Snippet>,tags: null == tags ? _self._tags : tags // ignore: cast_nullable_to_non_nullable
as Set<String>,
));
}
}
// dart format on

View File

@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'snippet.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(SnippetNotifier)
const snippetProvider = SnippetNotifierProvider._();
final class SnippetNotifierProvider
extends $NotifierProvider<SnippetNotifier, SnippetState> {
const SnippetNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'snippetProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$snippetNotifierHash();
@$internal
@override
SnippetNotifier create() => SnippetNotifier();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(SnippetState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<SnippetState>(value),
);
}
}
String _$snippetNotifierHash() => r'8285c7edf905a4aaa41cd8b65b0a6755c8b97fc9';
abstract class _$SnippetNotifier extends $Notifier<SnippetState> {
SnippetState build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<SnippetState, SnippetState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<SnippetState, SnippetState>,
SnippetState,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -1,45 +1,57 @@
import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/fl_lib.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/extension/ssh_client.dart';
import 'package:server_box/data/model/app/scripts/script_consts.dart'; import 'package:server_box/data/model/app/scripts/script_consts.dart';
import 'package:server_box/data/model/server/server.dart';
import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/systemd.dart'; import 'package:server_box/data/model/server/systemd.dart';
import 'package:server_box/data/provider/server.dart'; import 'package:server_box/data/provider/server/single.dart';
final class SystemdProvider { part 'systemd.freezed.dart';
late final VNode<Server> _si; part 'systemd.g.dart';
SystemdProvider.init(Spi spi) { @freezed
_si = ServerProvider.pick(spi: spi)!; abstract class SystemdState with _$SystemdState {
getUnits(); const factory SystemdState({
} @Default(false) bool isBusy,
@Default(<SystemdUnit>[]) List<SystemdUnit> units,
@Default(SystemdScopeFilter.all) SystemdScopeFilter scopeFilter,
}) = _SystemdState;
}
final isBusy = false.vn; @riverpod
final units = <SystemdUnit>[].vn; class SystemdNotifier extends _$SystemdNotifier {
final scopeFilter = SystemdScopeFilter.all.vn; late final ServerState _si;
void dispose() { @override
isBusy.dispose(); SystemdState build(Spi spi) {
units.dispose(); final si = ref.read(serverProvider(spi.id));
scopeFilter.dispose(); _si = si;
// Async initialization
Future.microtask(() => getUnits());
return const SystemdState();
} }
List<SystemdUnit> get filteredUnits { List<SystemdUnit> get filteredUnits {
switch (scopeFilter.value) { switch (state.scopeFilter) {
case SystemdScopeFilter.all: case SystemdScopeFilter.all:
return units.value; return state.units;
case SystemdScopeFilter.system: case SystemdScopeFilter.system:
return units.value.where((unit) => unit.scope == SystemdUnitScope.system).toList(); return state.units.where((unit) => unit.scope == SystemdUnitScope.system).toList();
case SystemdScopeFilter.user: case SystemdScopeFilter.user:
return units.value.where((unit) => unit.scope == SystemdUnitScope.user).toList(); return state.units.where((unit) => unit.scope == SystemdUnitScope.user).toList();
} }
} }
void setScopeFilter(SystemdScopeFilter filter) {
state = state.copyWith(scopeFilter: filter);
}
Future<void> getUnits() async { Future<void> getUnits() async {
isBusy.value = true; state = state.copyWith(isBusy: true);
try { try {
final client = _si.value.client; final client = _si.client;
final result = await client!.execForOutput(_getUnitsCmd); final result = await client!.execForOutput(_getUnitsCmd);
final units = result.split('\n'); final units = result.split('\n');
@@ -57,12 +69,11 @@ final class SystemdProvider {
final parsedUserUnits = await _parseUnitObj(userUnits, SystemdUnitScope.user); final parsedUserUnits = await _parseUnitObj(userUnits, SystemdUnitScope.user);
final parsedSystemUnits = await _parseUnitObj(systemUnits, SystemdUnitScope.system); final parsedSystemUnits = await _parseUnitObj(systemUnits, SystemdUnitScope.system);
this.units.value = [...parsedUserUnits, ...parsedSystemUnits]; state = state.copyWith(units: [...parsedUserUnits, ...parsedSystemUnits], isBusy: false);
} catch (e, s) { } catch (e, s) {
dprint('Parse systemd', e, s); dprint('Parse systemd', e, s);
state = state.copyWith(isBusy: false);
} }
isBusy.value = false;
} }
Future<List<SystemdUnit>> _parseUnitObj(List<String> unitNames, SystemdUnitScope scope) async { Future<List<SystemdUnit>> _parseUnitObj(List<String> unitNames, SystemdUnitScope scope) async {
@@ -75,7 +86,7 @@ for unit in ${unitNames_.join(' ')}; do
echo -n "\n${ScriptConstants.separator}\n" echo -n "\n${ScriptConstants.separator}\n"
done done
'''; ''';
final client = _si.value.client!; final client = _si.client!;
final result = await client.execForOutput(script); final result = await client.execForOutput(script);
final units = result.split(ScriptConstants.separator); final units = result.split(ScriptConstants.separator);

View File

@@ -0,0 +1,283 @@
// 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 'systemd.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$SystemdState {
bool get isBusy; List<SystemdUnit> get units; SystemdScopeFilter get scopeFilter;
/// Create a copy of SystemdState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SystemdStateCopyWith<SystemdState> get copyWith => _$SystemdStateCopyWithImpl<SystemdState>(this as SystemdState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SystemdState&&(identical(other.isBusy, isBusy) || other.isBusy == isBusy)&&const DeepCollectionEquality().equals(other.units, units)&&(identical(other.scopeFilter, scopeFilter) || other.scopeFilter == scopeFilter));
}
@override
int get hashCode => Object.hash(runtimeType,isBusy,const DeepCollectionEquality().hash(units),scopeFilter);
@override
String toString() {
return 'SystemdState(isBusy: $isBusy, units: $units, scopeFilter: $scopeFilter)';
}
}
/// @nodoc
abstract mixin class $SystemdStateCopyWith<$Res> {
factory $SystemdStateCopyWith(SystemdState value, $Res Function(SystemdState) _then) = _$SystemdStateCopyWithImpl;
@useResult
$Res call({
bool isBusy, List<SystemdUnit> units, SystemdScopeFilter scopeFilter
});
}
/// @nodoc
class _$SystemdStateCopyWithImpl<$Res>
implements $SystemdStateCopyWith<$Res> {
_$SystemdStateCopyWithImpl(this._self, this._then);
final SystemdState _self;
final $Res Function(SystemdState) _then;
/// Create a copy of SystemdState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? isBusy = null,Object? units = null,Object? scopeFilter = null,}) {
return _then(_self.copyWith(
isBusy: null == isBusy ? _self.isBusy : isBusy // ignore: cast_nullable_to_non_nullable
as bool,units: null == units ? _self.units : units // ignore: cast_nullable_to_non_nullable
as List<SystemdUnit>,scopeFilter: null == scopeFilter ? _self.scopeFilter : scopeFilter // ignore: cast_nullable_to_non_nullable
as SystemdScopeFilter,
));
}
}
/// Adds pattern-matching-related methods to [SystemdState].
extension SystemdStatePatterns on SystemdState {
/// 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( _SystemdState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _SystemdState() 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( _SystemdState value) $default,){
final _that = this;
switch (_that) {
case _SystemdState():
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( _SystemdState value)? $default,){
final _that = this;
switch (_that) {
case _SystemdState() 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( bool isBusy, List<SystemdUnit> units, SystemdScopeFilter scopeFilter)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SystemdState() when $default != null:
return $default(_that.isBusy,_that.units,_that.scopeFilter);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( bool isBusy, List<SystemdUnit> units, SystemdScopeFilter scopeFilter) $default,) {final _that = this;
switch (_that) {
case _SystemdState():
return $default(_that.isBusy,_that.units,_that.scopeFilter);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( bool isBusy, List<SystemdUnit> units, SystemdScopeFilter scopeFilter)? $default,) {final _that = this;
switch (_that) {
case _SystemdState() when $default != null:
return $default(_that.isBusy,_that.units,_that.scopeFilter);case _:
return null;
}
}
}
/// @nodoc
class _SystemdState implements SystemdState {
const _SystemdState({this.isBusy = false, final List<SystemdUnit> units = const <SystemdUnit>[], this.scopeFilter = SystemdScopeFilter.all}): _units = units;
@override@JsonKey() final bool isBusy;
final List<SystemdUnit> _units;
@override@JsonKey() List<SystemdUnit> get units {
if (_units is EqualUnmodifiableListView) return _units;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_units);
}
@override@JsonKey() final SystemdScopeFilter scopeFilter;
/// Create a copy of SystemdState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SystemdStateCopyWith<_SystemdState> get copyWith => __$SystemdStateCopyWithImpl<_SystemdState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SystemdState&&(identical(other.isBusy, isBusy) || other.isBusy == isBusy)&&const DeepCollectionEquality().equals(other._units, _units)&&(identical(other.scopeFilter, scopeFilter) || other.scopeFilter == scopeFilter));
}
@override
int get hashCode => Object.hash(runtimeType,isBusy,const DeepCollectionEquality().hash(_units),scopeFilter);
@override
String toString() {
return 'SystemdState(isBusy: $isBusy, units: $units, scopeFilter: $scopeFilter)';
}
}
/// @nodoc
abstract mixin class _$SystemdStateCopyWith<$Res> implements $SystemdStateCopyWith<$Res> {
factory _$SystemdStateCopyWith(_SystemdState value, $Res Function(_SystemdState) _then) = __$SystemdStateCopyWithImpl;
@override @useResult
$Res call({
bool isBusy, List<SystemdUnit> units, SystemdScopeFilter scopeFilter
});
}
/// @nodoc
class __$SystemdStateCopyWithImpl<$Res>
implements _$SystemdStateCopyWith<$Res> {
__$SystemdStateCopyWithImpl(this._self, this._then);
final _SystemdState _self;
final $Res Function(_SystemdState) _then;
/// Create a copy of SystemdState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? isBusy = null,Object? units = null,Object? scopeFilter = null,}) {
return _then(_SystemdState(
isBusy: null == isBusy ? _self.isBusy : isBusy // ignore: cast_nullable_to_non_nullable
as bool,units: null == units ? _self._units : units // ignore: cast_nullable_to_non_nullable
as List<SystemdUnit>,scopeFilter: null == scopeFilter ? _self.scopeFilter : scopeFilter // ignore: cast_nullable_to_non_nullable
as SystemdScopeFilter,
));
}
}
// dart format on

View File

@@ -0,0 +1,108 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'systemd.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(SystemdNotifier)
const systemdProvider = SystemdNotifierFamily._();
final class SystemdNotifierProvider
extends $NotifierProvider<SystemdNotifier, SystemdState> {
const SystemdNotifierProvider._({
required SystemdNotifierFamily super.from,
required Spi super.argument,
}) : super(
retry: null,
name: r'systemdProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$systemdNotifierHash();
@override
String toString() {
return r'systemdProvider'
''
'($argument)';
}
@$internal
@override
SystemdNotifier create() => SystemdNotifier();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(SystemdState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<SystemdState>(value),
);
}
@override
bool operator ==(Object other) {
return other is SystemdNotifierProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$systemdNotifierHash() => r'030d556efc3d897419cd3462d37cb705813e24c7';
final class SystemdNotifierFamily extends $Family
with
$ClassFamilyOverride<
SystemdNotifier,
SystemdState,
SystemdState,
SystemdState,
Spi
> {
const SystemdNotifierFamily._()
: super(
retry: null,
name: r'systemdProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
SystemdNotifierProvider call(Spi spi) =>
SystemdNotifierProvider._(argument: spi, from: this);
@override
String toString() => r'systemdProvider';
}
abstract class _$SystemdNotifier extends $Notifier<SystemdState> {
late final _$args = ref.$arg as Spi;
Spi get spi => _$args;
SystemdState build(Spi spi);
@$mustCallSuper
@override
void runBuild() {
final created = build(_$args);
final ref = this.ref as $Ref<SystemdState, SystemdState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<SystemdState, SystemdState>,
SystemdState,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -1,56 +1,63 @@
import 'package:flutter/widgets.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/res/store.dart';
import 'package:xterm/core.dart'; import 'package:xterm/core.dart';
class VirtKeyProvider extends TerminalInputHandler with ChangeNotifier { part 'virtual_keyboard.g.dart';
VirtKeyProvider(); part 'virtual_keyboard.freezed.dart';
bool _ctrl = false; @freezed
bool get ctrl => _ctrl; abstract class VirtKeyState with _$VirtKeyState {
set ctrl(bool value) { const factory VirtKeyState({
if (value != _ctrl) { @Default(false) final bool ctrl,
_ctrl = value; @Default(false) final bool alt,
notifyListeners(); @Default(false) final bool shift,
}) = _VirtKeyState;
}
@riverpod
class VirtKeyboard extends _$VirtKeyboard implements TerminalInputHandler {
@override
VirtKeyState build() {
return const VirtKeyState();
}
bool get ctrl => state.ctrl;
bool get alt => state.alt;
bool get shift => state.shift;
void setCtrl(bool value) {
if (value != state.ctrl) {
state = state.copyWith(ctrl: value);
} }
} }
bool _alt = false; void setAlt(bool value) {
bool get alt => _alt; if (value != state.alt) {
set alt(bool value) { state = state.copyWith(alt: value);
if (value != _alt) {
_alt = value;
notifyListeners();
} }
} }
bool _shift = false; void setShift(bool value) {
bool get shift => _shift; if (value != state.shift) {
set shift(bool value) { state = state.copyWith(shift: value);
if (value != _shift) {
_shift = value;
notifyListeners();
} }
} }
void reset(TerminalKeyboardEvent e) { void reset(TerminalKeyboardEvent e) {
if (e.ctrl) { state = state.copyWith(
ctrl = false; ctrl: e.ctrl ? false : state.ctrl,
} alt: e.alt ? false : state.alt,
if (e.alt) { shift: e.shift ? false : state.shift,
alt = false; );
}
if (e.shift) {
shift = false;
}
notifyListeners();
} }
@override @override
String? call(TerminalKeyboardEvent event) { String? call(TerminalKeyboardEvent event) {
final e = event.copyWith( final e = event.copyWith(
ctrl: event.ctrl || ctrl, ctrl: event.ctrl || state.ctrl,
alt: event.alt || alt, alt: event.alt || state.alt,
shift: event.shift || shift, shift: event.shift || state.shift,
); );
if (Stores.setting.sshVirtualKeyAutoOff.fetch()) { if (Stores.setting.sshVirtualKeyAutoOff.fetch()) {
reset(e); reset(e);

View File

@@ -0,0 +1,277 @@
// 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 'virtual_keyboard.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$VirtKeyState {
bool get ctrl; bool get alt; bool get shift;
/// Create a copy of VirtKeyState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$VirtKeyStateCopyWith<VirtKeyState> get copyWith => _$VirtKeyStateCopyWithImpl<VirtKeyState>(this as VirtKeyState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is VirtKeyState&&(identical(other.ctrl, ctrl) || other.ctrl == ctrl)&&(identical(other.alt, alt) || other.alt == alt)&&(identical(other.shift, shift) || other.shift == shift));
}
@override
int get hashCode => Object.hash(runtimeType,ctrl,alt,shift);
@override
String toString() {
return 'VirtKeyState(ctrl: $ctrl, alt: $alt, shift: $shift)';
}
}
/// @nodoc
abstract mixin class $VirtKeyStateCopyWith<$Res> {
factory $VirtKeyStateCopyWith(VirtKeyState value, $Res Function(VirtKeyState) _then) = _$VirtKeyStateCopyWithImpl;
@useResult
$Res call({
bool ctrl, bool alt, bool shift
});
}
/// @nodoc
class _$VirtKeyStateCopyWithImpl<$Res>
implements $VirtKeyStateCopyWith<$Res> {
_$VirtKeyStateCopyWithImpl(this._self, this._then);
final VirtKeyState _self;
final $Res Function(VirtKeyState) _then;
/// Create a copy of VirtKeyState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? ctrl = null,Object? alt = null,Object? shift = null,}) {
return _then(_self.copyWith(
ctrl: null == ctrl ? _self.ctrl : ctrl // ignore: cast_nullable_to_non_nullable
as bool,alt: null == alt ? _self.alt : alt // ignore: cast_nullable_to_non_nullable
as bool,shift: null == shift ? _self.shift : shift // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// Adds pattern-matching-related methods to [VirtKeyState].
extension VirtKeyStatePatterns on VirtKeyState {
/// 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( _VirtKeyState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _VirtKeyState() 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( _VirtKeyState value) $default,){
final _that = this;
switch (_that) {
case _VirtKeyState():
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( _VirtKeyState value)? $default,){
final _that = this;
switch (_that) {
case _VirtKeyState() 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( bool ctrl, bool alt, bool shift)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _VirtKeyState() when $default != null:
return $default(_that.ctrl,_that.alt,_that.shift);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( bool ctrl, bool alt, bool shift) $default,) {final _that = this;
switch (_that) {
case _VirtKeyState():
return $default(_that.ctrl,_that.alt,_that.shift);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( bool ctrl, bool alt, bool shift)? $default,) {final _that = this;
switch (_that) {
case _VirtKeyState() when $default != null:
return $default(_that.ctrl,_that.alt,_that.shift);case _:
return null;
}
}
}
/// @nodoc
class _VirtKeyState implements VirtKeyState {
const _VirtKeyState({this.ctrl = false, this.alt = false, this.shift = false});
@override@JsonKey() final bool ctrl;
@override@JsonKey() final bool alt;
@override@JsonKey() final bool shift;
/// Create a copy of VirtKeyState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$VirtKeyStateCopyWith<_VirtKeyState> get copyWith => __$VirtKeyStateCopyWithImpl<_VirtKeyState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _VirtKeyState&&(identical(other.ctrl, ctrl) || other.ctrl == ctrl)&&(identical(other.alt, alt) || other.alt == alt)&&(identical(other.shift, shift) || other.shift == shift));
}
@override
int get hashCode => Object.hash(runtimeType,ctrl,alt,shift);
@override
String toString() {
return 'VirtKeyState(ctrl: $ctrl, alt: $alt, shift: $shift)';
}
}
/// @nodoc
abstract mixin class _$VirtKeyStateCopyWith<$Res> implements $VirtKeyStateCopyWith<$Res> {
factory _$VirtKeyStateCopyWith(_VirtKeyState value, $Res Function(_VirtKeyState) _then) = __$VirtKeyStateCopyWithImpl;
@override @useResult
$Res call({
bool ctrl, bool alt, bool shift
});
}
/// @nodoc
class __$VirtKeyStateCopyWithImpl<$Res>
implements _$VirtKeyStateCopyWith<$Res> {
__$VirtKeyStateCopyWithImpl(this._self, this._then);
final _VirtKeyState _self;
final $Res Function(_VirtKeyState) _then;
/// Create a copy of VirtKeyState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? ctrl = null,Object? alt = null,Object? shift = null,}) {
return _then(_VirtKeyState(
ctrl: null == ctrl ? _self.ctrl : ctrl // ignore: cast_nullable_to_non_nullable
as bool,alt: null == alt ? _self.alt : alt // ignore: cast_nullable_to_non_nullable
as bool,shift: null == shift ? _self.shift : shift // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
// dart format on

View File

@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'virtual_keyboard.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(VirtKeyboard)
const virtKeyboardProvider = VirtKeyboardProvider._();
final class VirtKeyboardProvider
extends $NotifierProvider<VirtKeyboard, VirtKeyState> {
const VirtKeyboardProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'virtKeyboardProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$virtKeyboardHash();
@$internal
@override
VirtKeyboard create() => VirtKeyboard();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(VirtKeyState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<VirtKeyState>(value),
);
}
}
String _$virtKeyboardHash() => r'1327d412bfb0dd261f3b555f353a8852b4f753e5';
abstract class _$VirtKeyboard extends $Notifier<VirtKeyState> {
VirtKeyState build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<VirtKeyState, VirtKeyState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<VirtKeyState, VirtKeyState>,
VirtKeyState,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

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

View File

@@ -22,6 +22,7 @@ abstract final class GithubIds {
'MasedMSD', 'MasedMSD',
'GitGitro', 'GitGitro',
'Shin-suechtig', 'Shin-suechtig',
'GT-610'
}; };
static const participants = <GhId>{ static const participants = <GhId>{
@@ -124,6 +125,12 @@ abstract final class GithubIds {
'CreeperKong', 'CreeperKong',
'zxf945', 'zxf945',
'cnen2018', 'cnen2018',
'xiaomeng9597',
'mingzhao2019',
'HHXXYY123',
'Lancerys',
'yaziku',
'yeluosln',
}; };
} }

View File

@@ -1,4 +1,6 @@
import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/fl_lib.dart';
import 'package:get_it/get_it.dart';
import 'package:server_box/data/store/connection_stats.dart';
import 'package:server_box/data/store/container.dart'; import 'package:server_box/data/store/container.dart';
import 'package:server_box/data/store/history.dart'; import 'package:server_box/data/store/history.dart';
import 'package:server_box/data/store/private_key.dart'; import 'package:server_box/data/store/private_key.dart';
@@ -6,25 +8,37 @@ import 'package:server_box/data/store/server.dart';
import 'package:server_box/data/store/setting.dart'; import 'package:server_box/data/store/setting.dart';
import 'package:server_box/data/store/snippet.dart'; import 'package:server_box/data/store/snippet.dart';
final GetIt getIt = GetIt.instance;
abstract final class Stores { abstract final class Stores {
static final setting = SettingStore.instance; static SettingStore get setting => getIt<SettingStore>();
static final server = ServerStore.instance; static ServerStore get server => getIt<ServerStore>();
static final container = ContainerStore.instance; static ContainerStore get container => getIt<ContainerStore>();
static final key = PrivateKeyStore.instance; static PrivateKeyStore get key => getIt<PrivateKeyStore>();
static final snippet = SnippetStore.instance; static SnippetStore get snippet => getIt<SnippetStore>();
static final history = HistoryStore.instance; static HistoryStore get history => getIt<HistoryStore>();
static ConnectionStatsStore get connectionStats => getIt<ConnectionStatsStore>();
/// All stores that need backup /// All stores that need backup
static final List<HiveStore> _allBackup = [ static List<HiveStore> get _allBackup => [
SettingStore.instance, setting,
ServerStore.instance, server,
ContainerStore.instance, container,
PrivateKeyStore.instance, key,
SnippetStore.instance, snippet,
HistoryStore.instance, history,
]; connectionStats,
];
static Future<void> init() async { static Future<void> init() async {
getIt.registerLazySingleton<SettingStore>(() => SettingStore.instance);
getIt.registerLazySingleton<ServerStore>(() => ServerStore.instance);
getIt.registerLazySingleton<ContainerStore>(() => ContainerStore.instance);
getIt.registerLazySingleton<PrivateKeyStore>(() => PrivateKeyStore.instance);
getIt.registerLazySingleton<SnippetStore>(() => SnippetStore.instance);
getIt.registerLazySingleton<HistoryStore>(() => HistoryStore.instance);
getIt.registerLazySingleton<ConnectionStatsStore>(() => ConnectionStatsStore.instance);
await Future.wait(_allBackup.map((store) => store.init())); await Future.wait(_allBackup.map((store) => store.init()));
} }

View File

@@ -51,12 +51,31 @@ abstract final class TermSessionManager {
static void init() { static void init() {
if (isAndroid) { if (isAndroid) {
MethodChans.registerHandler((id) async { MethodChans.registerHandler(
_entries[id]?.disconnect?.call(); (id) async {
}); _entries[id]?.disconnect?.call();
},
() {
// Stop all connections when notification "Stop All" is pressed
stopAllConnections();
},
);
} }
} }
/// Called when Android notification "Stop All" button is pressed
static void stopAllConnections() {
// Disconnect all sessions
final disconnectCallbacks = _entries.values.map((e) => e.disconnect).where((cb) => cb != null).toList();
for (final disconnect in disconnectCallbacks) {
disconnect!();
}
// Clear all entries
_entries.clear();
_activeId = null;
_sync();
}
/// Add a session record and push update to Android. /// Add a session record and push update to Android.
static void add({ static void add({
required String id, required String id,

View File

@@ -0,0 +1,190 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/data/model/server/connection_stat.dart';
class ConnectionStatsStore extends HiveStore {
ConnectionStatsStore._() : super('connection_stats');
static final instance = ConnectionStatsStore._();
// Record a connection attempt
void recordConnection(ConnectionStat stat) {
final key = '${stat.serverId}_${ShortId.generate()}';
set(key, stat);
_cleanOldRecords(stat.serverId);
}
// Clean records older than 30 days for a specific server
void _cleanOldRecords(String serverId) {
final cutoffTime = DateTime.now().subtract(const Duration(days: 30));
final allKeys = keys().toList();
final keysToDelete = <String>[];
for (final key in allKeys) {
if (key.startsWith(serverId)) {
final parts = key.split('_');
if (parts.length >= 2) {
final timestamp = int.tryParse(parts.last);
if (timestamp != null) {
final recordTime = DateTime.fromMillisecondsSinceEpoch(timestamp);
if (recordTime.isBefore(cutoffTime)) {
keysToDelete.add(key);
}
}
}
}
}
for (final key in keysToDelete) {
remove(key);
}
}
// Get connection stats for a specific server
ServerConnectionStats getServerStats(String serverId, String serverName) {
final allStats = getConnectionHistory(serverId);
if (allStats.isEmpty) {
return ServerConnectionStats(
serverId: serverId,
serverName: serverName,
totalAttempts: 0,
successCount: 0,
failureCount: 0,
recentConnections: [],
successRate: 0.0,
);
}
final totalAttempts = allStats.length;
final successCount = allStats.where((s) => s.result.isSuccess).length;
final failureCount = totalAttempts - successCount;
final successRate = totalAttempts > 0 ? (successCount / totalAttempts) : 0.0;
final successTimes = allStats
.where((s) => s.result.isSuccess)
.map((s) => s.timestamp)
.toList();
final failureTimes = allStats
.where((s) => !s.result.isSuccess)
.map((s) => s.timestamp)
.toList();
DateTime? lastSuccessTime;
DateTime? lastFailureTime;
if (successTimes.isNotEmpty) {
successTimes.sort((a, b) => b.compareTo(a));
lastSuccessTime = successTimes.first;
}
if (failureTimes.isNotEmpty) {
failureTimes.sort((a, b) => b.compareTo(a));
lastFailureTime = failureTimes.first;
}
// Get recent connections (last 20)
final recentConnections = allStats.take(20).toList();
return ServerConnectionStats(
serverId: serverId,
serverName: serverName,
totalAttempts: totalAttempts,
successCount: successCount,
failureCount: failureCount,
lastSuccessTime: lastSuccessTime,
lastFailureTime: lastFailureTime,
recentConnections: recentConnections,
successRate: successRate,
);
}
// Get connection history for a specific server
List<ConnectionStat> getConnectionHistory(String serverId) {
final allKeys = keys().where((key) => key.startsWith(serverId)).toList();
final stats = <ConnectionStat>[];
for (final key in allKeys) {
final stat = get<ConnectionStat>(
key,
fromObj: (val) {
if (val is ConnectionStat) return val;
if (val is Map<dynamic, dynamic>) {
final map = val.toStrDynMap;
if (map == null) return null;
try {
return ConnectionStat.fromJson(map as Map<String, dynamic>);
} catch (e) {
dprint('Parsing ConnectionStat from JSON', e);
}
}
return null;
},
);
if (stat != null) {
stats.add(stat);
}
}
// Sort by timestamp, newest first
stats.sort((a, b) => b.timestamp.compareTo(a.timestamp));
return stats;
}
// Get all servers' stats
List<ServerConnectionStats> getAllServerStats() {
final serverIds = <String>{};
final serverNames = <String, String>{};
// Get all unique server IDs
for (final key in keys()) {
final parts = key.split('_');
if (parts.length >= 2) {
final serverId = parts[0];
serverIds.add(serverId);
// Try to get server name from the stored stat
final stat = get<ConnectionStat>(
key,
fromObj: (val) {
if (val is ConnectionStat) return val;
if (val is Map<dynamic, dynamic>) {
final map = val.toStrDynMap;
if (map == null) return null;
try {
return ConnectionStat.fromJson(map as Map<String, dynamic>);
} catch (e) {
dprint('Parsing ConnectionStat from JSON', e);
}
}
return null;
},
);
if (stat != null) {
serverNames[serverId] = stat.serverName;
}
}
}
final allStats = <ServerConnectionStats>[];
for (final serverId in serverIds) {
final serverName = serverNames[serverId] ?? serverId;
final stats = getServerStats(serverId, serverName);
allStats.add(stats);
}
return allStats;
}
// Clear all connection stats
void clearAll() {
box.clear();
}
// Clear stats for a specific server
void clearServerStats(String serverId) {
final keysToDelete = keys().where((key) => key.startsWith(serverId)).toList();
for (final key in keysToDelete) {
remove(key);
}
}
}

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