mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2026-02-15 20:55:25 +01:00
Compare commits
99 Commits
v1.0.1206
...
lollipopki
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a0928e2f6 | ||
|
|
61f161d8a6 | ||
|
|
52c80795f4 | ||
|
|
09f1ab2cf2 | ||
|
|
2eeb55c1d8 | ||
|
|
6738ac94f8 | ||
|
|
827d40b8b5 | ||
|
|
928f2becf1 | ||
|
|
7d30af44d6 | ||
|
|
35349a90eb | ||
|
|
8be9b9b10b | ||
|
|
c51cf62015 | ||
|
|
8589b3b4d7 | ||
|
|
7693e30cbf | ||
|
|
874d28be12 | ||
|
|
06070c29b9 | ||
|
|
bb0ada12e6 | ||
|
|
9ceeaf7cc4 | ||
|
|
29a57ad742 | ||
|
|
2c495a44c3 | ||
|
|
cc300c141a | ||
|
|
26efb8e185 | ||
|
|
06ed38ff45 | ||
|
|
7c35abe30e | ||
|
|
78ef181d4a | ||
|
|
3f15caeaf2 | ||
|
|
6458e736fa | ||
|
|
99fda8b747 | ||
|
|
c5cbb12ac3 | ||
|
|
038f0d4d77 | ||
|
|
141519d952 | ||
|
|
75d1a59e77 | ||
|
|
ca4e65d7a5 | ||
|
|
ffda27d057 | ||
|
|
c548b4ef48 | ||
|
|
70040c5840 | ||
|
|
5272324be6 | ||
|
|
8cbb48ed67 | ||
|
|
03720fa322 | ||
|
|
0b51719070 | ||
|
|
a84231393d | ||
|
|
d6c2cafce7 | ||
|
|
729b76177e | ||
|
|
860c11d4a8 | ||
|
|
bd949288ed | ||
|
|
bb3e3b4848 | ||
|
|
3307fca620 | ||
|
|
da8517bcf7 | ||
|
|
f68c4a851b | ||
|
|
17db393c12 | ||
|
|
275581cfa3 | ||
|
|
d7168ea1ff | ||
|
|
fd2bf08f78 | ||
|
|
98e13c39cf | ||
|
|
e70abeef04 | ||
|
|
194774d6fb | ||
|
|
640d61bab9 | ||
|
|
7f4cf22cc9 | ||
|
|
05a927753f | ||
|
|
0c7b72fb2c | ||
|
|
a869b97502 | ||
|
|
eadd343205 | ||
|
|
1bac986fe0 | ||
|
|
a94be6c2c3 | ||
|
|
fc8e9b4bb1 | ||
|
|
ec4b633889 | ||
|
|
e51804fa70 | ||
|
|
2466341999 | ||
|
|
929061213f | ||
|
|
6b52679942 | ||
|
|
efc0315c93 | ||
|
|
8e4c2a7cde | ||
|
|
4ec7f5895e | ||
|
|
ee22cdb55f | ||
|
|
b1b0d9a18f | ||
|
|
56e67f4725 | ||
|
|
3b7fdf36fb | ||
|
|
5291d316a2 | ||
|
|
4c369546da | ||
|
|
12a243d139 | ||
|
|
a97b3cf43e | ||
|
|
53a7c0d8ff | ||
|
|
9cb705f8dd | ||
|
|
8270674b7d | ||
|
|
24fd4b782d | ||
|
|
fcb3d7e2b3 | ||
|
|
f5634d6e88 | ||
|
|
5497ad83e0 | ||
|
|
4a7827f41a | ||
|
|
60671fe461 | ||
|
|
bc1b6e5a4a | ||
|
|
1d553eccd5 | ||
|
|
68734a9e52 | ||
|
|
ed8a1d18b9 | ||
|
|
e4a9875620 | ||
|
|
6f9aa2ece9 | ||
|
|
13e28675af | ||
|
|
8c0e0f89d5 | ||
|
|
9b01da5a23 |
11
.github/workflows/analysis.yml
vendored
11
.github/workflows/analysis.yml
vendored
@@ -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
|
||||||
|
|||||||
66
.github/workflows/claude.yml
vendored
Normal file
66
.github/workflows/claude.yml
vendored
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
name: Claude Code
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
pull_request_review_comment:
|
||||||
|
types: [created]
|
||||||
|
issues:
|
||||||
|
types: [opened, assigned]
|
||||||
|
pull_request_review:
|
||||||
|
types: [submitted]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
claude:
|
||||||
|
if: |
|
||||||
|
github.actor == 'lollipopkit' && (
|
||||||
|
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||||
|
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||||
|
)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
issues: read
|
||||||
|
id-token: write
|
||||||
|
actions: read # Required for Claude to read CI results on PRs
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Run Claude Code
|
||||||
|
id: claude
|
||||||
|
uses: anthropics/claude-code-action@beta
|
||||||
|
with:
|
||||||
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
|
||||||
|
# This is an optional setting that allows Claude to read CI results on PRs
|
||||||
|
additional_permissions: |
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
|
||||||
|
# model: "claude-opus-4-1-20250805"
|
||||||
|
|
||||||
|
# Optional: Customize the trigger phrase (default: @claude)
|
||||||
|
# trigger_phrase: "/claude"
|
||||||
|
|
||||||
|
# Optional: Trigger when specific user is assigned to an issue
|
||||||
|
# assignee_trigger: "claude-bot"
|
||||||
|
|
||||||
|
# Optional: Allow Claude to run specific commands
|
||||||
|
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
|
||||||
|
|
||||||
|
# Optional: Add custom instructions for Claude to customize its behavior for your project
|
||||||
|
# custom_instructions: |
|
||||||
|
# Follow our coding standards
|
||||||
|
# Ensure all new code has tests
|
||||||
|
# Use TypeScript for new files
|
||||||
|
|
||||||
|
# Optional: Custom environment variables for Claude
|
||||||
|
# claude_env: |
|
||||||
|
# NODE_ENV: test
|
||||||
|
|
||||||
38
.github/workflows/release.yml
vendored
38
.github/workflows/release.yml
vendored
@@ -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.32.2"
|
flutter-version: "3.38.0"
|
||||||
- uses: actions/setup-java@v4
|
- uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: "zulu"
|
distribution: "zulu"
|
||||||
@@ -48,34 +53,27 @@ 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
|
||||||
run: |
|
run: |
|
||||||
sudo apt update
|
sudo apt update
|
||||||
# Basic
|
# Basic
|
||||||
sudo apt install -y clang cmake ninja-build pkg-config libgtk-3-dev libvulkan-dev desktop-file-utils wget
|
sudo apt install -y clang cmake ninja-build pkg-config libgtk-3-dev mesa-utils libvulkan-dev desktop-file-utils wget
|
||||||
# App Specific
|
# App Specific
|
||||||
sudo apt install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libunwind-dev
|
sudo apt install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libunwind-dev libsecret-1-dev
|
||||||
# Packaging
|
|
||||||
sudo wget https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage -O /bin/appimagetool
|
|
||||||
sudo chmod +x /bin/appimagetool
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
dart run fl_build -p linux
|
dart run fl_build -p linux
|
||||||
- name: Rename artifacts
|
|
||||||
run: |
|
|
||||||
appimage_name=$(ls dist/*/*.AppImage)
|
|
||||||
mv $appimage_name ${{ env.APP_NAME }}_${{ env.BUILD_NUMBER }}_amd64.appimage
|
|
||||||
- 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 }}_${{ env.BUILD_NUMBER }}_amd64.appimage
|
${{ env.APP_NAME }}_${{ env.BUILD_NUMBER }}_amd64.AppImage
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
@@ -84,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
|
||||||
@@ -102,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
95
CLAUDE.md
Normal 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
143
LICENSE
@@ -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>.
|
|
||||||
@@ -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`
|
||||||
|
|||||||
@@ -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 & 所有贡献者`
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,15 @@
|
|||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".widget.WidgetConfigureActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@android:style/Theme.Material.Light.Dialog">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widget.HomeWidget"
|
android:name=".widget.HomeWidget"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
|||||||
@@ -2,14 +2,32 @@ 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
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
class ForegroundService : Service() {
|
class ForegroundService : Service() {
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
var isRunning: Boolean = false
|
||||||
|
}
|
||||||
private val chanId = "ForegroundServiceChannel"
|
private val chanId = "ForegroundServiceChannel"
|
||||||
|
private val NOTIFICATION_ID = 1000
|
||||||
|
private val ACTION_STOP_FOREGROUND = "ACTION_STOP_FOREGROUND"
|
||||||
|
private val ACTION_UPDATE_SESSIONS = "tech.lolli.toolbox.ACTION_UPDATE_SESSIONS"
|
||||||
|
private val ACTION_DISCONNECT_SESSION = "tech.lolli.toolbox.ACTION_DISCONNECT_SESSION"
|
||||||
|
|
||||||
|
private var isFgStarted = false
|
||||||
|
private val postedIds = mutableSetOf<Int>()
|
||||||
|
// Stable mapping from session-id -> notification-id to avoid hash collisions
|
||||||
|
private val notificationIdMap = mutableMapOf<String, Int>()
|
||||||
|
private val nextNotificationId = java.util.concurrent.atomic.AtomicInteger(2001)
|
||||||
|
|
||||||
private fun logError(message: String, error: Throwable? = null) {
|
private fun logError(message: String, error: Throwable? = null) {
|
||||||
Log.e("ForegroundService", message, error)
|
Log.e("ForegroundService", message, error)
|
||||||
@@ -26,48 +44,51 @@ class ForegroundService : Service() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
Log.d("ForegroundService", "Service onCreate")
|
Log.d("ForegroundService", "Service onCreate")
|
||||||
|
isRunning = true
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
val action = intent.action
|
val action = intent.action
|
||||||
Log.d("ForegroundService", "onStartCommand action=$action")
|
Log.d("ForegroundService", "onStartCommand action=$action")
|
||||||
|
|
||||||
// Create notification before starting foreground
|
|
||||||
val notification = createNotification()
|
|
||||||
|
|
||||||
// Use try-catch for startForeground
|
|
||||||
try {
|
|
||||||
startForeground(1, notification)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logError("Failed to start foreground", e)
|
|
||||||
stopSelf()
|
|
||||||
return START_NOT_STICKY
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
||||||
stopForegroundService()
|
stopForegroundService()
|
||||||
START_NOT_STICKY
|
START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
ACTION_UPDATE_SESSIONS -> {
|
||||||
|
val payload = intent.getStringExtra("payload") ?: "{}"
|
||||||
|
handleUpdateSessions(payload)
|
||||||
|
START_STICKY
|
||||||
|
}
|
||||||
else -> {
|
else -> {
|
||||||
|
// Default bring up foreground with placeholder
|
||||||
|
ensureForeground(createMergedNotification(0, emptyList(), emptyList()))
|
||||||
START_STICKY
|
START_STICKY
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,68 +106,205 @@ 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) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureForeground(notification: Notification) {
|
||||||
|
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
|
return
|
||||||
}
|
}
|
||||||
val serviceChannel = NotificationChannel(
|
|
||||||
chanId,
|
|
||||||
"ForegroundServiceChannel",
|
|
||||||
NotificationManager.IMPORTANCE_DEFAULT
|
|
||||||
).apply {
|
|
||||||
description = "For foreground service"
|
|
||||||
}
|
|
||||||
manager.createNotificationChannel(serviceChannel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createNotification(): Notification {
|
if (!isFgStarted) {
|
||||||
try {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
val notificationIntent = Intent(this, MainActivity::class.java)
|
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
} else {
|
||||||
this,
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
0,
|
}
|
||||||
notificationIntent,
|
isFgStarted = true
|
||||||
PendingIntent.FLAG_IMMUTABLE
|
Log.d("ForegroundService", "Foreground service started successfully")
|
||||||
)
|
|
||||||
|
|
||||||
val deleteIntent = Intent(this, ForegroundService::class.java).apply {
|
|
||||||
action = "ACTION_STOP_FOREGROUND"
|
|
||||||
}
|
|
||||||
val deletePendingIntent = PendingIntent.getService(
|
|
||||||
this,
|
|
||||||
0,
|
|
||||||
deleteIntent,
|
|
||||||
PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
|
|
||||||
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
Notification.Builder(this, chanId)
|
|
||||||
} else {
|
} else {
|
||||||
Notification.Builder(this)
|
val nm = getSystemService(NotificationManager::class.java)
|
||||||
|
if (nm != null) {
|
||||||
|
nm.notify(NOTIFICATION_ID, notification)
|
||||||
|
} else {
|
||||||
|
Log.w("ForegroundService", "NotificationManager is null, cannot update notification")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: SecurityException) {
|
||||||
return builder
|
logError("Security exception when starting foreground service (likely missing permission)", e)
|
||||||
.setContentTitle("Server Box")
|
stopSelf()
|
||||||
.setContentText("Running in background")
|
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
|
||||||
.setContentIntent(pendingIntent)
|
|
||||||
.addAction(android.R.drawable.ic_delete, "Stop", deletePendingIntent)
|
|
||||||
.build()
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logError("Error creating notification", e)
|
logError("Failed to start/update foreground", e)
|
||||||
// Return a basic notification as fallback
|
// Don't stop the service for other exceptions, just log them
|
||||||
return Notification.Builder(this)
|
|
||||||
.setContentTitle("Server Box")
|
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
|
||||||
.build()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun createMergedNotification(count: Int, lines: List<String>, sessions: List<SessionItem>): Notification {
|
||||||
|
val notificationIntent = Intent(this, MainActivity::class.java)
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val stopIntent = Intent(this, ForegroundService::class.java).apply { action = ACTION_STOP_FOREGROUND }
|
||||||
|
val stopPending = PendingIntent.getService(this, 0, stopIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
|
||||||
|
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
Notification.Builder(this, chanId)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
Notification.Builder(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the earliest session's start time for chronometer
|
||||||
|
val earliestStartTime = sessions.minOfOrNull { it.startWhen } ?: System.currentTimeMillis()
|
||||||
|
|
||||||
|
val title = when (count) {
|
||||||
|
0 -> "Server Box"
|
||||||
|
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)
|
||||||
|
.setWhen(earliestStartTime)
|
||||||
|
.setUsesChronometer(true)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.addAction(
|
||||||
|
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) {
|
||||||
|
val nm = getSystemService(NotificationManager::class.java)
|
||||||
|
if (nm == null) {
|
||||||
|
logError("NotificationManager null")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val sessions = mutableListOf<SessionItem>()
|
||||||
|
try {
|
||||||
|
val obj = JSONObject(payload)
|
||||||
|
val arr: JSONArray = obj.optJSONArray("sessions") ?: JSONArray()
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val s = arr.optJSONObject(i) ?: continue
|
||||||
|
val id = s.optString("id")
|
||||||
|
val title = s.optString("title")
|
||||||
|
val sub = s.optString("subtitle")
|
||||||
|
val whenMs = s.optLong("startTimeMs", System.currentTimeMillis())
|
||||||
|
val status = s.optString("status", "connected")
|
||||||
|
if (id.isNotEmpty()) {
|
||||||
|
sessions.add(SessionItem(id, title, sub, whenMs, status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logError("Failed to parse payload", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear if empty
|
||||||
|
if (sessions.isEmpty()) {
|
||||||
|
clearAll()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel any existing individual notifications (we only show merged notification now)
|
||||||
|
val toCancel = postedIds.toSet()
|
||||||
|
toCancel.forEach { nm.cancel(it) }
|
||||||
|
postedIds.clear()
|
||||||
|
notificationIdMap.clear()
|
||||||
|
|
||||||
|
// Create merged notification content
|
||||||
|
val summaryLines = sessions.map { "${it.title}: ${it.status}" }
|
||||||
|
val mergedNotification = createMergedNotification(sessions.size, summaryLines, sessions)
|
||||||
|
ensureForeground(mergedNotification)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearAll() {
|
||||||
|
val nm = getSystemService(NotificationManager::class.java)
|
||||||
|
nm?.cancel(NOTIFICATION_ID)
|
||||||
|
postedIds.forEach { id -> nm?.cancel(id) }
|
||||||
|
postedIds.clear()
|
||||||
|
isFgStarted = false
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SessionItem(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val subtitle: String,
|
||||||
|
val startWhen: Long,
|
||||||
|
val status: String,
|
||||||
|
)
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -157,5 +315,6 @@ class ForegroundService : Service() {
|
|||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
Log.d("ForegroundService", "Service onDestroy")
|
Log.d("ForegroundService", "Service onDestroy")
|
||||||
|
isRunning = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -13,20 +16,34 @@ import android.appwidget.AppWidgetManager
|
|||||||
import tech.lolli.toolbox.widget.HomeWidget
|
import tech.lolli.toolbox.widget.HomeWidget
|
||||||
|
|
||||||
class MainActivity: FlutterFragmentActivity() {
|
class MainActivity: FlutterFragmentActivity() {
|
||||||
|
private lateinit var channel: MethodChannel
|
||||||
|
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_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)
|
||||||
val binaryMessenger = flutterEngine.dartExecutor.binaryMessenger
|
val binaryMessenger = flutterEngine.dartExecutor.binaryMessenger
|
||||||
|
|
||||||
MethodChannel(binaryMessenger, "tech.lolli.toolbox/main_chan").apply {
|
channel = MethodChannel(binaryMessenger, "tech.lolli.toolbox/main_chan")
|
||||||
setMethodCallHandler { method, result ->
|
channel.setMethodCallHandler { method, result ->
|
||||||
when (method.method) {
|
when (method.method) {
|
||||||
"sendToBackground" -> {
|
"sendToBackground" -> {
|
||||||
moveTaskToBack(true)
|
moveTaskToBack(true)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"isServiceRunning" -> {
|
||||||
|
result.success(ForegroundService.isRunning)
|
||||||
|
}
|
||||||
"startService" -> {
|
"startService" -> {
|
||||||
try {
|
try {
|
||||||
reqPerm()
|
reqPerm()
|
||||||
|
if (!notificationsAllowed()) {
|
||||||
|
// Don't start foreground service without notification permission on API 33+
|
||||||
|
result.error("NOTIFICATION_PERMISSION_DENIED", "Notification permission not granted", null)
|
||||||
|
return@setMethodCallHandler
|
||||||
|
}
|
||||||
val serviceIntent = Intent(this@MainActivity, ForegroundService::class.java)
|
val serviceIntent = Intent(this@MainActivity, ForegroundService::class.java)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
startForegroundService(serviceIntent)
|
startForegroundService(serviceIntent)
|
||||||
@@ -51,31 +68,138 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
sendBroadcast(intent)
|
sendBroadcast(intent)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"updateSessions" -> {
|
||||||
|
try {
|
||||||
|
if (!notificationsAllowed()) {
|
||||||
|
// Avoid starting/continuing service updates when notifications are blocked
|
||||||
|
result.error("NOTIFICATION_PERMISSION_DENIED", "Notification permission not granted", null)
|
||||||
|
return@setMethodCallHandler
|
||||||
|
}
|
||||||
|
val serviceIntent = Intent(this@MainActivity, ForegroundService::class.java)
|
||||||
|
serviceIntent.action = ACTION_UPDATE_SESSIONS
|
||||||
|
serviceIntent.putExtra("payload", method.arguments as String)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
startForegroundService(serviceIntent)
|
||||||
|
} else {
|
||||||
|
startService(serviceIntent)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("MainActivity", "Failed to update sessions: ${e.message}")
|
||||||
|
result.error("SERVICE_ERROR", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
else -> {
|
else -> {
|
||||||
result.notImplemented()
|
result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle intent if launched via notification action
|
||||||
|
handleActionIntent(intent)
|
||||||
|
|
||||||
|
// Register broadcast receiver for stop all connections
|
||||||
|
setupStopAllReceiver()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reqPerm() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if we already have the permission to avoid unnecessary prompts
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
!= 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(
|
||||||
|
this,
|
||||||
|
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||||
|
123,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Log error but don't crash
|
||||||
|
android.util.Log.e("MainActivity", "Failed to request permissions: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notificationsAllowed(): Boolean {
|
||||||
|
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
handleActionIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleActionIntent(intent: Intent?) {
|
||||||
|
if (intent == null) return
|
||||||
|
when (intent.action) {
|
||||||
|
ACTION_DISCONNECT_SESSION -> {
|
||||||
|
val sessionId = intent.getStringExtra("session_id")
|
||||||
|
if (sessionId != null && ::channel.isInitialized) {
|
||||||
|
try {
|
||||||
|
channel.invokeMethod("disconnectSession", mapOf("id" to sessionId))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("MainActivity", "Failed to invoke disconnect: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun reqPerm() {
|
private fun setupStopAllReceiver() {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
stopAllReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
// Check if we already have the permission to avoid unnecessary prompts
|
if (intent?.action == ACTION_STOP_ALL_CONNECTIONS && ::channel.isInitialized) {
|
||||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
try {
|
||||||
!= PackageManager.PERMISSION_GRANTED) {
|
channel.invokeMethod("stopAllConnections", null)
|
||||||
try {
|
} catch (e: Exception) {
|
||||||
ActivityCompat.requestPermissions(
|
android.util.Log.e("MainActivity", "Failed to invoke stopAllConnections: ${e.message}")
|
||||||
this,
|
}
|
||||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
}
|
||||||
123,
|
}
|
||||||
)
|
}
|
||||||
} catch (e: Exception) {
|
val filter = IntentFilter(ACTION_STOP_ALL_CONNECTIONS)
|
||||||
// Log error but don't crash
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
android.util.Log.e("MainActivity", "Failed to request permissions: ${e.message}")
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,13 +13,24 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import org.json.JSONException
|
||||||
import tech.lolli.toolbox.R
|
import tech.lolli.toolbox.R
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
class HomeWidget : AppWidgetProvider() {
|
class HomeWidget : AppWidgetProvider() {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "HomeWidget"
|
||||||
|
private const val NETWORK_TIMEOUT = 10_000L // 10 seconds
|
||||||
|
private const val COROUTINE_TIMEOUT = 15_000L // 15 seconds
|
||||||
|
private val activeUpdates = ConcurrentHashMap<Int, Boolean>()
|
||||||
|
}
|
||||||
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||||
for (appWidgetId in appWidgetIds) {
|
for (appWidgetId in appWidgetIds) {
|
||||||
updateAppWidget(context, appWidgetManager, appWidgetId)
|
updateAppWidget(context, appWidgetManager, appWidgetId)
|
||||||
@@ -27,105 +38,184 @@ class HomeWidget : AppWidgetProvider() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) {
|
private fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) {
|
||||||
val views = RemoteViews(context.packageName, R.layout.home_widget)
|
// Prevent concurrent updates for the same widget
|
||||||
val sp = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)
|
if (activeUpdates.putIfAbsent(appWidgetId, true) == true) {
|
||||||
var url = sp.getString("widget_$appWidgetId", null)
|
Log.d(TAG, "Widget $appWidgetId is already updating, skipping")
|
||||||
if (url.isNullOrEmpty()) {
|
|
||||||
url = sp.getString("$appWidgetId", null)
|
|
||||||
}
|
|
||||||
if (url.isNullOrEmpty()) {
|
|
||||||
val gUrl = sp.getString("widget_*", null)
|
|
||||||
url = gUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.isNullOrEmpty()) {
|
|
||||||
Log.e("HomeWidget", "URL not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
val intentUpdate = Intent(context, HomeWidget::class.java)
|
|
||||||
intentUpdate.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
|
|
||||||
val ids = intArrayOf(appWidgetId)
|
|
||||||
intentUpdate.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
|
|
||||||
|
|
||||||
var flag = PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
if (Build.VERSION_CODES.O <= Build.VERSION.SDK_INT) {
|
|
||||||
flag = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
}
|
|
||||||
|
|
||||||
val pendingUpdate: PendingIntent = PendingIntent.getBroadcast(
|
|
||||||
context,
|
|
||||||
appWidgetId,
|
|
||||||
intentUpdate,
|
|
||||||
flag)
|
|
||||||
views.setOnClickPendingIntent(R.id.widget_container, pendingUpdate)
|
|
||||||
|
|
||||||
if (url.isNullOrEmpty()) {
|
|
||||||
views.setTextViewText(R.id.widget_name, "No URL")
|
|
||||||
// Update the widget to display a message for missing URL
|
|
||||||
views.setViewVisibility(R.id.error_message, View.VISIBLE)
|
|
||||||
views.setTextViewText(R.id.error_message, "Please configure the widget URL.")
|
|
||||||
views.setViewVisibility(R.id.widget_content, View.GONE)
|
|
||||||
views.setFloat(R.id.widget_name, "setAlpha", 1f)
|
|
||||||
views.setFloat(R.id.error_message, "setAlpha", 1f)
|
|
||||||
appWidgetManager.updateAppWidget(appWidgetId, views)
|
|
||||||
return
|
return
|
||||||
} else {
|
|
||||||
views.setViewVisibility(R.id.widget_cpu_label, View.VISIBLE)
|
|
||||||
views.setViewVisibility(R.id.widget_mem_label, View.VISIBLE)
|
|
||||||
views.setViewVisibility(R.id.widget_disk_label, View.VISIBLE)
|
|
||||||
views.setViewVisibility(R.id.widget_net_label, View.VISIBLE)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val views = RemoteViews(context.packageName, R.layout.home_widget)
|
||||||
|
val url = getWidgetUrl(context, appWidgetId)
|
||||||
|
|
||||||
|
if (url.isNullOrEmpty()) {
|
||||||
|
Log.w(TAG, "URL not found for widget $appWidgetId")
|
||||||
|
showErrorState(views, appWidgetManager, appWidgetId, "Please configure the widget URL.")
|
||||||
|
activeUpdates.remove(appWidgetId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setupClickIntent(context, views, appWidgetId)
|
||||||
|
|
||||||
|
showLoadingState(views, appWidgetManager, appWidgetId)
|
||||||
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
try {
|
withTimeoutOrNull(COROUTINE_TIMEOUT) {
|
||||||
val connection = URL(url).openConnection() as HttpURLConnection
|
try {
|
||||||
connection.requestMethod = "GET"
|
val serverData = fetchServerData(url)
|
||||||
val responseCode = connection.responseCode
|
if (serverData != null) {
|
||||||
if (responseCode == HttpURLConnection.HTTP_OK) {
|
withContext(Dispatchers.Main) {
|
||||||
val jsonStr = connection.inputStream.bufferedReader().use { it.readText() }
|
showSuccessState(views, appWidgetManager, appWidgetId, serverData)
|
||||||
val jsonObject = JSONObject(jsonStr)
|
}
|
||||||
val data = jsonObject.getJSONObject("data")
|
} else {
|
||||||
val server = data.getString("name")
|
withContext(Dispatchers.Main) {
|
||||||
val cpu = data.getString("cpu")
|
showErrorState(views, appWidgetManager, appWidgetId, "Invalid server data received.")
|
||||||
val mem = data.getString("mem")
|
|
||||||
val disk = data.getString("disk")
|
|
||||||
val net = data.getString("net")
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
if (mem.isEmpty() || disk.isEmpty()) {
|
|
||||||
Log.e("HomeWidget", "Failed to retrieve status: Memory or disk information is empty")
|
|
||||||
return@withContext
|
|
||||||
}
|
}
|
||||||
views.setTextViewText(R.id.widget_name, server)
|
|
||||||
views.setTextViewText(R.id.widget_cpu, cpu)
|
|
||||||
views.setTextViewText(R.id.widget_mem, mem)
|
|
||||||
views.setTextViewText(R.id.widget_disk, disk)
|
|
||||||
views.setTextViewText(R.id.widget_net, net)
|
|
||||||
val timeStr = android.text.format.DateFormat.format("HH:mm", java.util.Date()).toString()
|
|
||||||
views.setTextViewText(R.id.widget_time, timeStr)
|
|
||||||
views.setFloat(R.id.widget_name, "setAlpha", 1f)
|
|
||||||
views.setFloat(R.id.widget_cpu_label, "setAlpha", 1f)
|
|
||||||
views.setFloat(R.id.widget_mem_label, "setAlpha", 1f)
|
|
||||||
views.setFloat(R.id.widget_disk_label, "setAlpha", 1f)
|
|
||||||
views.setFloat(R.id.widget_net_label, "setAlpha", 1f)
|
|
||||||
views.setFloat(R.id.widget_time, "setAlpha", 1f)
|
|
||||||
appWidgetManager.updateAppWidget(appWidgetId, views)
|
|
||||||
}
|
}
|
||||||
} else {
|
} catch (e: Exception) {
|
||||||
throw FileNotFoundException("HTTP response code: $responseCode")
|
Log.e(TAG, "Error updating widget $appWidgetId: ${e.message}", e)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
val errorMessage = when (e) {
|
||||||
|
is SocketTimeoutException -> "Connection timeout. Please check your network."
|
||||||
|
is IOException -> "Network error. Please check your connection."
|
||||||
|
is JSONException -> "Invalid data format received from server."
|
||||||
|
else -> "Failed to retrieve data: ${e.message}"
|
||||||
|
}
|
||||||
|
showErrorState(views, appWidgetManager, appWidgetId, errorMessage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} ?: run {
|
||||||
Log.e("HomeWidget", "Error updating widget: ${e.localizedMessage}", e)
|
Log.w(TAG, "Widget update timed out for widget $appWidgetId")
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
views.setTextViewText(R.id.widget_name, "Error")
|
showErrorState(views, appWidgetManager, appWidgetId, "Update timed out. Please try again.")
|
||||||
// Update the widget to display a message for data retrieval failure
|
|
||||||
views.setViewVisibility(R.id.error_message, View.VISIBLE)
|
|
||||||
views.setTextViewText(R.id.error_message, "Failed to retrieve data.")
|
|
||||||
views.setViewVisibility(R.id.widget_content, View.GONE)
|
|
||||||
views.setFloat(R.id.widget_name, "setAlpha", 1f)
|
|
||||||
views.setFloat(R.id.error_message, "setAlpha", 1f)
|
|
||||||
appWidgetManager.updateAppWidget(appWidgetId, views)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
activeUpdates.remove(appWidgetId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getWidgetUrl(context: Context, appWidgetId: Int): String? {
|
||||||
|
val sp = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)
|
||||||
|
return sp.getString("widget_$appWidgetId", null)
|
||||||
|
?: sp.getString("$appWidgetId", null)
|
||||||
|
?: sp.getString("widget_*", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupClickIntent(context: Context, views: RemoteViews, appWidgetId: Int) {
|
||||||
|
val intentConfigure = Intent(context, WidgetConfigureActivity::class.java).apply {
|
||||||
|
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
||||||
|
}
|
||||||
|
|
||||||
|
val flag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
} else {
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
}
|
||||||
|
|
||||||
|
val pendingConfigure = PendingIntent.getActivity(context, appWidgetId, intentConfigure, flag)
|
||||||
|
views.setOnClickPendingIntent(R.id.widget_container, pendingConfigure)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchServerData(url: String): ServerData? = withContext(Dispatchers.IO) {
|
||||||
|
var connection: HttpURLConnection? = null
|
||||||
|
try {
|
||||||
|
connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
|
requestMethod = "GET"
|
||||||
|
connectTimeout = NETWORK_TIMEOUT.toInt()
|
||||||
|
readTimeout = NETWORK_TIMEOUT.toInt()
|
||||||
|
setRequestProperty("User-Agent", "ServerBox-Widget/1.0")
|
||||||
|
setRequestProperty("Accept", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
|
||||||
|
throw IOException("HTTP ${connection.responseCode}: ${connection.responseMessage}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val jsonStr = connection.inputStream.bufferedReader().use { it.readText() }
|
||||||
|
parseServerData(jsonStr)
|
||||||
|
} finally {
|
||||||
|
connection?.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseServerData(jsonStr: String): ServerData? {
|
||||||
|
return try {
|
||||||
|
val jsonObject = JSONObject(jsonStr)
|
||||||
|
val data = jsonObject.getJSONObject("data")
|
||||||
|
|
||||||
|
val server = data.optString("name", "Unknown Server")
|
||||||
|
val cpu = data.optString("cpu", "").takeIf { it.isNotBlank() } ?: "N/A"
|
||||||
|
val mem = data.optString("mem", "").takeIf { it.isNotBlank() } ?: "N/A"
|
||||||
|
val disk = data.optString("disk", "").takeIf { it.isNotBlank() } ?: "N/A"
|
||||||
|
val net = data.optString("net", "").takeIf { it.isNotBlank() } ?: "N/A"
|
||||||
|
|
||||||
|
// Return data even if some fields are missing, providing defaults
|
||||||
|
// Only reject if we can't parse the JSON structure properly
|
||||||
|
ServerData(server, cpu, mem, disk, net)
|
||||||
|
} catch (e: JSONException) {
|
||||||
|
Log.e(TAG, "JSON parsing error: ${e.message}", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showLoadingState(views: RemoteViews, appWidgetManager: AppWidgetManager, appWidgetId: Int) {
|
||||||
|
views.apply {
|
||||||
|
setTextViewText(R.id.widget_name, "Loading...")
|
||||||
|
setViewVisibility(R.id.error_message, View.GONE)
|
||||||
|
setViewVisibility(R.id.widget_content, View.VISIBLE)
|
||||||
|
setViewVisibility(R.id.widget_cpu_label, View.VISIBLE)
|
||||||
|
setViewVisibility(R.id.widget_mem_label, View.VISIBLE)
|
||||||
|
setViewVisibility(R.id.widget_disk_label, View.VISIBLE)
|
||||||
|
setViewVisibility(R.id.widget_net_label, View.VISIBLE)
|
||||||
|
setViewVisibility(R.id.widget_progress, View.VISIBLE)
|
||||||
|
setFloat(R.id.widget_name, "setAlpha", 0.7f)
|
||||||
|
}
|
||||||
|
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showSuccessState(views: RemoteViews, appWidgetManager: AppWidgetManager, appWidgetId: Int, data: ServerData) {
|
||||||
|
views.apply {
|
||||||
|
setTextViewText(R.id.widget_name, data.name)
|
||||||
|
setTextViewText(R.id.widget_cpu, data.cpu)
|
||||||
|
setTextViewText(R.id.widget_mem, data.mem)
|
||||||
|
setTextViewText(R.id.widget_disk, data.disk)
|
||||||
|
setTextViewText(R.id.widget_net, data.net)
|
||||||
|
|
||||||
|
val timeStr = android.text.format.DateFormat.format("HH:mm", java.util.Date()).toString()
|
||||||
|
setTextViewText(R.id.widget_time, timeStr)
|
||||||
|
|
||||||
|
setViewVisibility(R.id.error_message, View.GONE)
|
||||||
|
setViewVisibility(R.id.widget_content, View.VISIBLE)
|
||||||
|
setViewVisibility(R.id.widget_progress, View.GONE)
|
||||||
|
|
||||||
|
// Smooth fade-in animation
|
||||||
|
setFloat(R.id.widget_name, "setAlpha", 1f)
|
||||||
|
setFloat(R.id.widget_cpu_label, "setAlpha", 1f)
|
||||||
|
setFloat(R.id.widget_mem_label, "setAlpha", 1f)
|
||||||
|
setFloat(R.id.widget_disk_label, "setAlpha", 1f)
|
||||||
|
setFloat(R.id.widget_net_label, "setAlpha", 1f)
|
||||||
|
setFloat(R.id.widget_time, "setAlpha", 1f)
|
||||||
|
}
|
||||||
|
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showErrorState(views: RemoteViews, appWidgetManager: AppWidgetManager, appWidgetId: Int, errorMessage: String) {
|
||||||
|
views.apply {
|
||||||
|
setTextViewText(R.id.widget_name, "Error")
|
||||||
|
setViewVisibility(R.id.error_message, View.VISIBLE)
|
||||||
|
setTextViewText(R.id.error_message, errorMessage)
|
||||||
|
setViewVisibility(R.id.widget_content, View.GONE)
|
||||||
|
setViewVisibility(R.id.widget_progress, View.GONE)
|
||||||
|
setFloat(R.id.widget_name, "setAlpha", 1f)
|
||||||
|
setFloat(R.id.error_message, "setAlpha", 1f)
|
||||||
|
}
|
||||||
|
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ServerData(
|
||||||
|
val name: String,
|
||||||
|
val cpu: String,
|
||||||
|
val mem: String,
|
||||||
|
val disk: String,
|
||||||
|
val net: String
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package tech.lolli.toolbox.widget
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Patterns
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.EditText
|
||||||
|
import tech.lolli.toolbox.R
|
||||||
|
|
||||||
|
class WidgetConfigureActivity : Activity() {
|
||||||
|
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
|
||||||
|
private lateinit var urlEditText: EditText
|
||||||
|
private lateinit var saveButton: Button
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.widget_configure)
|
||||||
|
|
||||||
|
// 设置结果为取消,以防用户在完成配置前退出
|
||||||
|
setResult(RESULT_CANCELED)
|
||||||
|
|
||||||
|
// 获取 widget ID
|
||||||
|
val extras = intent.extras
|
||||||
|
if (extras != null) {
|
||||||
|
appWidgetId = extras.getInt(
|
||||||
|
AppWidgetManager.EXTRA_APPWIDGET_ID,
|
||||||
|
AppWidgetManager.INVALID_APPWIDGET_ID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有有效的 widget ID,完成 activity
|
||||||
|
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化 UI 元素
|
||||||
|
urlEditText = findViewById(R.id.url_edit_text)
|
||||||
|
saveButton = findViewById(R.id.save_button)
|
||||||
|
|
||||||
|
// 从 SharedPreferences 加载现有配置
|
||||||
|
val sp = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)
|
||||||
|
val existingUrl = sp.getString("widget_$appWidgetId", "")
|
||||||
|
urlEditText.setText(existingUrl)
|
||||||
|
|
||||||
|
// 设置保存按钮点击事件
|
||||||
|
saveButton.setOnClickListener {
|
||||||
|
val url = urlEditText.text.toString().trim()
|
||||||
|
if (url.isEmpty()) {
|
||||||
|
urlEditText.error = "Please enter a URL"
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 URL 格式
|
||||||
|
if (!Patterns.WEB_URL.matcher(url).matches()) {
|
||||||
|
urlEditText.error = "Please enter a valid URL"
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存 URL 到 SharedPreferences
|
||||||
|
val editor = sp.edit()
|
||||||
|
editor.putString("widget_$appWidgetId", url)
|
||||||
|
editor.apply()
|
||||||
|
|
||||||
|
// 更新 widget 使用 AppWidgetManager
|
||||||
|
val appWidgetManager = AppWidgetManager.getInstance(this)
|
||||||
|
val updateIntent = Intent(this, HomeWidget::class.java).apply {
|
||||||
|
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
|
||||||
|
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(appWidgetId))
|
||||||
|
}
|
||||||
|
sendBroadcast(updateIntent)
|
||||||
|
|
||||||
|
// 设置结果并结束 activity
|
||||||
|
val resultValue = Intent()
|
||||||
|
resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
||||||
|
setResult(RESULT_OK, resultValue)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,14 +10,17 @@
|
|||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_name"
|
android:id="@+id/widget_name"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textColor="@color/widgetText"
|
android:textColor="@color/widgetText"
|
||||||
android:textSize="23sp"
|
android:textSize="20sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end"
|
||||||
android:alpha="0"
|
android:alpha="0"
|
||||||
android:animateLayoutChanges="true"
|
android:animateLayoutChanges="true"
|
||||||
|
android:fadingEdge="horizontal"
|
||||||
|
android:singleLine="true"
|
||||||
tools:text="Server Name" />
|
tools:text="Server Name" />
|
||||||
|
|
||||||
<!-- Wrap the content in a LinearLayout for easy visibility management -->
|
<!-- Wrap the content in a LinearLayout for easy visibility management -->
|
||||||
@@ -27,121 +30,138 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:layout_below="@id/widget_name"
|
android:layout_below="@id/widget_name"
|
||||||
android:paddingTop="13dp">
|
android:layout_marginTop="8dp">
|
||||||
|
|
||||||
<RelativeLayout
|
<RelativeLayout
|
||||||
android:id="@+id/widget_container_inner"
|
android:id="@+id/widget_container_inner"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="wrap_content"
|
||||||
android:gravity="center_vertical"
|
|
||||||
android:paddingTop="13dp"
|
|
||||||
android:animateLayoutChanges="true">
|
android:animateLayoutChanges="true">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/widget_cpu_label"
|
android:id="@+id/widget_cpu_label"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:paddingBottom="2.7dp"
|
android:layout_marginBottom="4dp"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:orientation="horizontal">
|
android:orientation="horizontal"
|
||||||
|
android:alpha="0"
|
||||||
|
android:animateLayoutChanges="true">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="17dp"
|
android:layout_width="16dp"
|
||||||
android:layout_height="17dp"
|
android:layout_height="16dp"
|
||||||
android:src="@drawable/speed_24">
|
android:src="@drawable/speed_24"
|
||||||
</ImageView>
|
android:layout_gravity="center_vertical"
|
||||||
|
android:contentDescription="CPU usage" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_cpu"
|
android:id="@+id/widget_cpu"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="11dp"
|
android:layout_weight="1"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
android:singleLine="true"
|
android:singleLine="true"
|
||||||
android:ellipsize = "marquee"
|
android:ellipsize="end"
|
||||||
android:textColor="@color/widgetSummaryText"
|
android:textColor="@color/widgetSummaryText"
|
||||||
android:textSize="12.7sp"
|
android:textSize="12sp"
|
||||||
tools:text="CPU" />
|
tools:text="CPU: 25.6%" />
|
||||||
|
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/widget_mem_label"
|
android:id="@+id/widget_mem_label"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:paddingBottom="2.7dp"
|
android:layout_marginBottom="4dp"
|
||||||
android:layout_below="@id/widget_cpu_label"
|
android:layout_below="@id/widget_cpu_label"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:orientation="horizontal">
|
android:orientation="horizontal"
|
||||||
|
android:alpha="0"
|
||||||
|
android:animateLayoutChanges="true">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="17dp"
|
android:layout_width="16dp"
|
||||||
android:layout_height="17dp"
|
android:layout_height="16dp"
|
||||||
android:src="@drawable/memory_24">
|
android:src="@drawable/memory_24"
|
||||||
</ImageView>
|
android:layout_gravity="center_vertical"
|
||||||
|
android:contentDescription="Memory usage" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_mem"
|
android:id="@+id/widget_mem"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="11dp"
|
android:layout_weight="1"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end"
|
||||||
android:textColor="@color/widgetSummaryText"
|
android:textColor="@color/widgetSummaryText"
|
||||||
android:textSize="12.7sp"
|
android:textSize="12sp"
|
||||||
tools:text="Mem" />
|
tools:text="Memory: 4.2GB / 8GB" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/widget_disk_label"
|
android:id="@+id/widget_disk_label"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:paddingBottom="2.7dp"
|
android:layout_marginBottom="4dp"
|
||||||
android:layout_below="@id/widget_mem_label"
|
android:layout_below="@id/widget_mem_label"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:orientation="horizontal">
|
android:orientation="horizontal"
|
||||||
|
android:alpha="0"
|
||||||
|
android:animateLayoutChanges="true">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="17dp"
|
android:layout_width="16dp"
|
||||||
android:layout_height="17dp"
|
android:layout_height="16dp"
|
||||||
android:src="@drawable/storage_24">
|
android:src="@drawable/storage_24"
|
||||||
</ImageView>
|
android:layout_gravity="center_vertical"
|
||||||
|
android:contentDescription="Disk usage" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_disk"
|
android:id="@+id/widget_disk"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="11dp"
|
android:layout_weight="1"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end"
|
||||||
android:textColor="@color/widgetSummaryText"
|
android:textColor="@color/widgetSummaryText"
|
||||||
android:textSize="12.7sp"
|
android:textSize="12sp"
|
||||||
tools:text="Disk" />
|
tools:text="Disk: 125GB / 250GB" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/widget_net_label"
|
android:id="@+id/widget_net_label"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_below="@id/widget_disk_label"
|
android:layout_below="@id/widget_disk_label"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:orientation="horizontal">
|
android:orientation="horizontal"
|
||||||
|
android:alpha="0"
|
||||||
|
android:animateLayoutChanges="true">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="17dp"
|
android:layout_width="16dp"
|
||||||
android:layout_height="17dp"
|
android:layout_height="16dp"
|
||||||
android:src="@drawable/net_24">
|
android:src="@drawable/net_24"
|
||||||
</ImageView>
|
android:layout_gravity="center_vertical"
|
||||||
|
android:contentDescription="Network usage" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_net"
|
android:id="@+id/widget_net"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="11dp"
|
android:layout_weight="1"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end"
|
||||||
android:textColor="@color/widgetSummaryText"
|
android:textColor="@color/widgetSummaryText"
|
||||||
android:textSize="12.7sp"
|
android:textSize="12sp"
|
||||||
tools:text="Net" />
|
tools:text="Network: 15MB/s ↓ 8MB/s ↑" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
@@ -149,29 +169,45 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Add a TextView for error messages -->
|
<!-- Error message display -->
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/error_message"
|
android:id="@+id/error_message"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_below="@id/widget_name"
|
android:layout_below="@id/widget_name"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
android:textColor="@color/widgetSummaryText"
|
android:textColor="@color/widgetSummaryText"
|
||||||
android:textSize="12sp"
|
android:textSize="11sp"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
android:alpha="0"
|
android:alpha="0"
|
||||||
android:animateLayoutChanges="true"
|
android:animateLayoutChanges="true"
|
||||||
tools:text="Error message" />
|
android:lineSpacingMultiplier="1.2"
|
||||||
|
android:maxLines="3"
|
||||||
|
android:ellipsize="end"
|
||||||
|
tools:text="Error message text that might be longer than usual" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_time"
|
android:id="@+id/widget_time"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_alignParentBottom="true"
|
android:layout_alignParentBottom="true"
|
||||||
android:maxLines="2"
|
android:layout_alignParentEnd="true"
|
||||||
|
android:maxLines="1"
|
||||||
android:textColor="@color/widgetSummaryText"
|
android:textColor="@color/widgetSummaryText"
|
||||||
android:textSize="11sp"
|
android:textSize="10sp"
|
||||||
android:alpha="0"
|
android:alpha="0"
|
||||||
android:animateLayoutChanges="true"
|
android:animateLayoutChanges="true"
|
||||||
tools:text="UpdateTime" />
|
android:fontFamily="monospace"
|
||||||
|
tools:text="12:34" />
|
||||||
|
|
||||||
|
<!-- Progress indicator for loading state -->
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/widget_progress"
|
||||||
|
style="?android:attr/progressBarStyleLarge"
|
||||||
|
android:layout_width="32dp"
|
||||||
|
android:layout_height="32dp"
|
||||||
|
android:layout_centerInParent="true"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:indeterminate="true" />
|
||||||
|
|
||||||
</RelativeLayout>
|
</RelativeLayout>
|
||||||
38
android/app/src/main/res/layout/widget_configure.xml
Normal file
38
android/app/src/main/res/layout/widget_configure.xml
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Widget URL"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
android:textColor="@android:color/black" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/url_edit_text"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="https://server/status"
|
||||||
|
android:inputType="textUri"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
android:background="@android:drawable/edit_text"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:textColor="@android:color/black"
|
||||||
|
android:textColorHint="@android:color/darker_gray" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/save_button"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Save"
|
||||||
|
android:background="#8b2252"
|
||||||
|
android:textColor="@android:color/white"
|
||||||
|
android:padding="12dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
android:minHeight="110dp"
|
android:minHeight="110dp"
|
||||||
android:updatePeriodMillis="1800001"
|
android:updatePeriodMillis="1800001"
|
||||||
android:initialLayout="@layout/home_widget"
|
android:initialLayout="@layout/home_widget"
|
||||||
|
android:configure="tech.lolli.toolbox.widget.WidgetConfigureActivity"
|
||||||
android:resizeMode="none"
|
android:resizeMode="none"
|
||||||
android:widgetCategory="home_screen">
|
android:widgetCategory="home_screen">
|
||||||
</appwidget-provider>
|
</appwidget-provider>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
|
description: This file stores settings for Dart & Flutter DevTools.
|
||||||
|
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||||
extensions:
|
extensions:
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
variables:
|
|
||||||
output: dist/
|
|
||||||
releases:
|
|
||||||
- name: linux
|
|
||||||
jobs:
|
|
||||||
- name: release-linux-deb
|
|
||||||
package:
|
|
||||||
platform: linux
|
|
||||||
target: deb
|
|
||||||
- name: release-linux-rpm
|
|
||||||
package:
|
|
||||||
platform: linux
|
|
||||||
target: rpm
|
|
||||||
7
fastlane/metadata/android/ru/full_description.txt
Normal file
7
fastlane/metadata/android/ru/full_description.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Проект на базе Flutter, предоставляющий диаграммы состояний серверов под Linux, Unix и Windows и инструменты для управления ими.
|
||||||
|
|
||||||
|
Особая благодарность dartssh2 и xterm.dart.
|
||||||
|
|
||||||
|
* Диаграмма состояния (ЦП, датчики, видеокарта…), SSH Term, SFTP, Docker, пакеты, процессы…
|
||||||
|
* Платформозависимые: биометрическая аутентификация, push-уведомления, виджет, приложение для watchOS…
|
||||||
|
* Многоязычная поддержка: English, 简体中文; Deutsch, 繁體中文, Indonesian, Français, Dutch; Español, Русский язык, Português, 日本語
|
||||||
1
fastlane/metadata/android/ru/short_description.txt
Normal file
1
fastlane/metadata/android/ru/short_description.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Приложение для мониторинга серверов и набор инструментов управления ими
|
||||||
@@ -21,6 +21,6 @@
|
|||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<key>MinimumOSVersion</key>
|
<key>MinimumOSVersion</key>
|
||||||
<string>12.0</string>
|
<string>13.0</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Uncomment this line to define a global platform for your project
|
# Uncomment this line to define a global platform for your project
|
||||||
# platform :ios, '12.0'
|
# platform :ios, '13.0'
|
||||||
|
|
||||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
PODS:
|
PODS:
|
||||||
- app_links (0.0.2):
|
- app_links (6.4.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- camera_avfoundation (0.0.1):
|
- camera_avfoundation (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
@@ -87,23 +87,23 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/watch_connectivity/ios"
|
:path: ".symlinks/plugins/watch_connectivity/ios"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7
|
app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a
|
||||||
camera_avfoundation: be3be85408cd4126f250386828e9b1dfa40ab436
|
camera_avfoundation: 5675ca25298b6f81fa0a325188e7df62cc217741
|
||||||
file_picker: fb04e739ae6239a76ce1f571863a196a922c87d4
|
file_picker: fb04e739ae6239a76ce1f571863a196a922c87d4
|
||||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
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: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391
|
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
|
||||||
|
|
||||||
PODFILE CHECKSUM: ec6ef69056f066e8b21a3391082f23b5ad2d37f8
|
PODFILE CHECKSUM: 5a0fb6438066e44ab2c77bd223668d351b8d8461
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
|
4A2DCD6B2E4B127100CF68B7 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */; };
|
||||||
|
4A2DCD6C2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */; };
|
||||||
|
4A2DCD6F2E4B128100CF68B7 /* TerminalLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD6D2E4B128100CF68B7 /* TerminalLiveActivity.swift */; };
|
||||||
|
4A2DCD702E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD6E2E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift */; };
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
7538AEC32BB83FAB002AB82A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7538AEC22BB83FAB002AB82A /* PrivacyInfo.xcprivacy */; };
|
7538AEC32BB83FAB002AB82A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7538AEC22BB83FAB002AB82A /* PrivacyInfo.xcprivacy */; };
|
||||||
7538AEC52BB83FC8002AB82A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7538AEC42BB83FC8002AB82A /* PrivacyInfo.xcprivacy */; };
|
7538AEC52BB83FC8002AB82A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7538AEC42BB83FC8002AB82A /* PrivacyInfo.xcprivacy */; };
|
||||||
@@ -36,6 +40,8 @@
|
|||||||
E3AE8AEB2AB601DB000A6459 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AE8AE92AB601DB000A6459 /* Utils.swift */; };
|
E3AE8AEB2AB601DB000A6459 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AE8AE92AB601DB000A6459 /* Utils.swift */; };
|
||||||
E3AE8AEC2AB601DB000A6459 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AE8AE92AB601DB000A6459 /* Utils.swift */; };
|
E3AE8AEC2AB601DB000A6459 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AE8AE92AB601DB000A6459 /* Utils.swift */; };
|
||||||
E3DB67ED2A31FE200027B8CB /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E3DB67EB2A31FE200027B8CB /* LaunchScreen.storyboard */; };
|
E3DB67ED2A31FE200027B8CB /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E3DB67EB2A31FE200027B8CB /* LaunchScreen.storyboard */; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F0005 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = F0A1B2C31A2B3C4D5E6F0001 /* Localizable.strings */; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F1005 /* Localizable.strings (StatusWidget) in Resources */ = {isa = PBXBuildFile; fileRef = F0A1B2C31A2B3C4D5E6F1001 /* Localizable.strings */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -95,6 +101,10 @@
|
|||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||||
278C1EB3935F9285537B0516 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
278C1EB3935F9285537B0516 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
|
4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; };
|
||||||
|
4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLiveActivityAttributes.swift; sourceTree = "<group>"; };
|
||||||
|
4A2DCD6D2E4B128100CF68B7 /* TerminalLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLiveActivity.swift; sourceTree = "<group>"; };
|
||||||
|
4A2DCD6E2E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLiveActivityAttributes.swift; sourceTree = "<group>"; };
|
||||||
5A4B3EB10512B2EB8E10213B /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
5A4B3EB10512B2EB8E10213B /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
@@ -156,6 +166,26 @@
|
|||||||
E3D26BD22B9966EC00D83425 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Main.strings; sourceTree = "<group>"; };
|
E3D26BD22B9966EC00D83425 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Main.strings; sourceTree = "<group>"; };
|
||||||
E3D26BD32B9966EC00D83425 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/LaunchScreen.strings; sourceTree = "<group>"; };
|
E3D26BD32B9966EC00D83425 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/LaunchScreen.strings; sourceTree = "<group>"; };
|
||||||
E3DB67EC2A31FE200027B8CB /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
E3DB67EC2A31FE200027B8CB /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F0002 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F0003 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F0004 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F0006 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F0007 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F0008 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F0009 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F000A /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F000B /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F000C /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F1002 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F1003 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F1004 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F1006 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F1007 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F1008 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F1009 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F100A /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F100B /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F100C /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -233,6 +263,7 @@
|
|||||||
97C146F01CF9000F007C117D /* Runner */ = {
|
97C146F01CF9000F007C117D /* Runner */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
F0A1B2C31A2B3C4D5E6F0001 /* Localizable.strings */,
|
||||||
7538AEC22BB83FAB002AB82A /* PrivacyInfo.xcprivacy */,
|
7538AEC22BB83FAB002AB82A /* PrivacyInfo.xcprivacy */,
|
||||||
E398BF6A29BDB34500FE4FD5 /* Runner.entitlements */,
|
E398BF6A29BDB34500FE4FD5 /* Runner.entitlements */,
|
||||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||||
@@ -242,6 +273,8 @@
|
|||||||
E39A76AD2AB9A2F70067C641 /* Info-Profile.plist */,
|
E39A76AD2AB9A2F70067C641 /* Info-Profile.plist */,
|
||||||
E39A76AC2AB9A2F70067C641 /* Info-Release.plist */,
|
E39A76AC2AB9A2F70067C641 /* Info-Release.plist */,
|
||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||||
|
4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */,
|
||||||
|
4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */,
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||||
E3AE8AE92AB601DB000A6459 /* Utils.swift */,
|
E3AE8AE92AB601DB000A6459 /* Utils.swift */,
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||||
@@ -263,8 +296,11 @@
|
|||||||
E33A3E3A2A626DCE009744AB /* StatusWidget */ = {
|
E33A3E3A2A626DCE009744AB /* StatusWidget */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
F0A1B2C31A2B3C4D5E6F1001 /* Localizable.strings */,
|
||||||
7538AEC42BB83FC8002AB82A /* PrivacyInfo.xcprivacy */,
|
7538AEC42BB83FC8002AB82A /* PrivacyInfo.xcprivacy */,
|
||||||
E33A3E3B2A626DCE009744AB /* StatusWidgetBundle.swift */,
|
E33A3E3B2A626DCE009744AB /* StatusWidgetBundle.swift */,
|
||||||
|
4A2DCD6D2E4B128100CF68B7 /* TerminalLiveActivity.swift */,
|
||||||
|
4A2DCD6E2E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift */,
|
||||||
E33A3E3F2A626DCE009744AB /* StatusWidget.swift */,
|
E33A3E3F2A626DCE009744AB /* StatusWidget.swift */,
|
||||||
E37C48ED2B9C30EE00E542D2 /* StatusWidget.intentdefinition */,
|
E37C48ED2B9C30EE00E542D2 /* StatusWidget.intentdefinition */,
|
||||||
E33A3E442A626DD0009744AB /* Info.plist */,
|
E33A3E442A626DD0009744AB /* Info.plist */,
|
||||||
@@ -412,6 +448,7 @@
|
|||||||
E39A76B02AB9A2F70067C641 /* Info-Profile.plist in Resources */,
|
E39A76B02AB9A2F70067C641 /* Info-Profile.plist in Resources */,
|
||||||
7538AEC32BB83FAB002AB82A /* PrivacyInfo.xcprivacy in Resources */,
|
7538AEC32BB83FAB002AB82A /* PrivacyInfo.xcprivacy in Resources */,
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F0005 /* Localizable.strings in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -420,6 +457,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
7538AEC52BB83FC8002AB82A /* PrivacyInfo.xcprivacy in Resources */,
|
7538AEC52BB83FC8002AB82A /* PrivacyInfo.xcprivacy in Resources */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F1005 /* Localizable.strings (StatusWidget) in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -516,6 +554,8 @@
|
|||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
E37C48EA2B9C30EE00E542D2 /* StatusWidget.intentdefinition in Sources */,
|
E37C48EA2B9C30EE00E542D2 /* StatusWidget.intentdefinition in Sources */,
|
||||||
E3AE8AEA2AB601DB000A6459 /* Utils.swift in Sources */,
|
E3AE8AEA2AB601DB000A6459 /* Utils.swift in Sources */,
|
||||||
|
4A2DCD6B2E4B127100CF68B7 /* LiveActivityManager.swift in Sources */,
|
||||||
|
4A2DCD6C2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -525,6 +565,8 @@
|
|||||||
files = (
|
files = (
|
||||||
E33A3E402A626DCE009744AB /* StatusWidget.swift in Sources */,
|
E33A3E402A626DCE009744AB /* StatusWidget.swift in Sources */,
|
||||||
E37C48EB2B9C30EE00E542D2 /* StatusWidget.intentdefinition in Sources */,
|
E37C48EB2B9C30EE00E542D2 /* StatusWidget.intentdefinition in Sources */,
|
||||||
|
4A2DCD6F2E4B128100CF68B7 /* TerminalLiveActivity.swift in Sources */,
|
||||||
|
4A2DCD702E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */,
|
||||||
E33A3E3C2A626DCE009744AB /* StatusWidgetBundle.swift in Sources */,
|
E33A3E3C2A626DCE009744AB /* StatusWidgetBundle.swift in Sources */,
|
||||||
E3AE8AEB2AB601DB000A6459 /* Utils.swift in Sources */,
|
E3AE8AEB2AB601DB000A6459 /* Utils.swift in Sources */,
|
||||||
);
|
);
|
||||||
@@ -610,6 +652,40 @@
|
|||||||
name = LaunchScreen.storyboard;
|
name = LaunchScreen.storyboard;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
F0A1B2C31A2B3C4D5E6F0001 /* Localizable.strings */ = {
|
||||||
|
isa = PBXVariantGroup;
|
||||||
|
children = (
|
||||||
|
F0A1B2C31A2B3C4D5E6F0002 /* en */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F0003 /* zh-Hans */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F0004 /* zh-Hant */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F0006 /* fr */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F0007 /* ru */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F0008 /* es */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F0009 /* de */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F000A /* pt-BR */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F000B /* id */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F000C /* ja */,
|
||||||
|
);
|
||||||
|
name = Localizable.strings;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
F0A1B2C31A2B3C4D5E6F1001 /* Localizable.strings */ = {
|
||||||
|
isa = PBXVariantGroup;
|
||||||
|
children = (
|
||||||
|
F0A1B2C31A2B3C4D5E6F1002 /* en */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F1003 /* zh-Hans */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F1004 /* zh-Hant */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F1006 /* fr */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F1007 /* ru */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F1008 /* es */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F1009 /* de */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F100A /* pt-BR */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F100B /* id */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F100C /* ja */,
|
||||||
|
);
|
||||||
|
name = Localizable.strings;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXVariantGroup section */
|
/* End PBXVariantGroup section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
@@ -655,7 +731,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
@@ -672,17 +748,17 @@
|
|||||||
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 = 1206;
|
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";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1206;
|
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";
|
||||||
@@ -739,7 +815,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -789,7 +865,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
@@ -808,17 +884,17 @@
|
|||||||
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 = 1206;
|
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";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1206;
|
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";
|
||||||
@@ -836,17 +912,17 @@
|
|||||||
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 = 1206;
|
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";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1206;
|
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";
|
||||||
@@ -867,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 = 1206;
|
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;
|
||||||
@@ -880,7 +956,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1206;
|
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;
|
||||||
@@ -906,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 = 1206;
|
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;
|
||||||
@@ -919,7 +995,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1206;
|
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)";
|
||||||
@@ -942,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 = 1206;
|
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;
|
||||||
@@ -955,7 +1031,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1206;
|
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)";
|
||||||
@@ -978,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 = 1206;
|
CURRENT_PROJECT_VERSION = 1291;
|
||||||
DEVELOPMENT_ASSET_PATHS = "";
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
DEVELOPMENT_TEAM = BA88US33G6;
|
DEVELOPMENT_TEAM = BA88US33G6;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -990,7 +1066,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1206;
|
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;
|
||||||
@@ -1019,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 = 1206;
|
CURRENT_PROJECT_VERSION = 1291;
|
||||||
DEVELOPMENT_ASSET_PATHS = "";
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
DEVELOPMENT_TEAM = BA88US33G6;
|
DEVELOPMENT_TEAM = BA88US33G6;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -1031,7 +1107,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1206;
|
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;
|
||||||
@@ -1057,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 = 1206;
|
CURRENT_PROJECT_VERSION = 1291;
|
||||||
DEVELOPMENT_ASSET_PATHS = "";
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
DEVELOPMENT_TEAM = BA88US33G6;
|
DEVELOPMENT_TEAM = BA88US33G6;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -1069,7 +1145,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1206;
|
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;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
import Flutter
|
import Flutter
|
||||||
|
import ActivityKit
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
@@ -11,14 +12,48 @@ import Flutter
|
|||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
|
|
||||||
let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
|
let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
|
||||||
let methodChannel = FlutterMethodChannel(name: "tech.lolli.toolbox/home_widget", binaryMessenger: controller.binaryMessenger)
|
// Home widget channel (legacy)
|
||||||
methodChannel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
|
let homeWidgetChannel = FlutterMethodChannel(name: "tech.lolli.toolbox/home_widget", binaryMessenger: controller.binaryMessenger)
|
||||||
|
homeWidgetChannel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
|
||||||
if call.method == "update" {
|
if call.method == "update" {
|
||||||
if #available(iOS 14.0, *) {
|
if #available(iOS 14.0, *) {
|
||||||
WidgetCenter.shared.reloadTimelines(ofKind: "StatusWidget")
|
WidgetCenter.shared.reloadTimelines(ofKind: "StatusWidget")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Main channel for cross-platform calls (incl. Live Activities)
|
||||||
|
let mainChannel = FlutterMethodChannel(name: "tech.lolli.toolbox/main_chan", binaryMessenger: controller.binaryMessenger)
|
||||||
|
mainChannel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
|
||||||
|
switch call.method {
|
||||||
|
case "updateHomeWidget":
|
||||||
|
if #available(iOS 14.0, *) {
|
||||||
|
WidgetCenter.shared.reloadTimelines(ofKind: "StatusWidget")
|
||||||
|
}
|
||||||
|
result(nil)
|
||||||
|
case "startLiveActivity":
|
||||||
|
if #available(iOS 16.2, *) {
|
||||||
|
if let payload = call.arguments as? String {
|
||||||
|
LiveActivityManager.start(json: payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result(nil)
|
||||||
|
case "updateLiveActivity":
|
||||||
|
if #available(iOS 16.2, *) {
|
||||||
|
if let payload = call.arguments as? String {
|
||||||
|
LiveActivityManager.update(json: payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result(nil)
|
||||||
|
case "stopLiveActivity":
|
||||||
|
if #available(iOS 16.2, *) {
|
||||||
|
LiveActivityManager.stop()
|
||||||
|
}
|
||||||
|
result(nil)
|
||||||
|
default:
|
||||||
|
result(FlutterMethodNotImplemented)
|
||||||
|
}
|
||||||
|
})
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,4 +65,11 @@ import Flutter
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func applicationWillTerminate(_ application: UIApplication) {
|
||||||
|
// Stop Live Activity when app is about to terminate
|
||||||
|
if #available(iOS 16.2, *) {
|
||||||
|
LiveActivityManager.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,8 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>ConfigurationIntent</string>
|
<string>ConfigurationIntent</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>NSSupportsLiveActivities</key>
|
||||||
|
<true/>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true />
|
<true />
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
@@ -78,4 +80,4 @@
|
|||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>Get QR code and etc.</string>
|
<string>Get QR code and etc.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
<string>en</string>
|
<string>en</string>
|
||||||
<string>zh</string>
|
<string>zh</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>NSSupportsLiveActivities</key>
|
||||||
|
<true/>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>ServerBox</string>
|
<string>ServerBox</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
<string>en</string>
|
<string>en</string>
|
||||||
<string>zh</string>
|
<string>zh</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>NSSupportsLiveActivities</key>
|
||||||
|
<true/>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>ServerBox</string>
|
<string>ServerBox</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
@@ -68,4 +70,4 @@
|
|||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>Get QR code and etc.</string>
|
<string>Get QR code and etc.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
95
ios/Runner/LiveActivityManager.swift
Normal file
95
ios/Runner/LiveActivityManager.swift
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
//
|
||||||
|
// LiveActivityManager.swift
|
||||||
|
// Runner
|
||||||
|
//
|
||||||
|
// Handles starting/updating/stopping Terminal Live Activities from Flutter via MethodChannel.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import ActivityKit
|
||||||
|
|
||||||
|
@available(iOS 16.2, *)
|
||||||
|
class LiveActivityManager {
|
||||||
|
static var current: Activity<TerminalAttributes>?
|
||||||
|
|
||||||
|
struct Payload: Decodable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
let startTimeMs: Int
|
||||||
|
let status: String
|
||||||
|
let hasTerminal: Bool?
|
||||||
|
let connectionCount: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parse(_ json: String) -> Payload? {
|
||||||
|
guard let data = json.data(using: .utf8) else { return nil }
|
||||||
|
return try? JSONDecoder().decode(Payload.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func start(json: String) {
|
||||||
|
guard #available(iOS 16.2, *) else { return }
|
||||||
|
guard let p = parse(json) else { return }
|
||||||
|
let attributes = TerminalAttributes(id: p.id)
|
||||||
|
let date = Date(timeIntervalSince1970: TimeInterval(p.startTimeMs) / 1000.0)
|
||||||
|
// Localize multi-connection title/subtitle on iOS side
|
||||||
|
let isMulti = (p.id == "multi_connections")
|
||||||
|
let title = isMulti
|
||||||
|
? String(format: NSLocalizedString("%d connections", comment: "Title for multiple connections"), p.connectionCount ?? 1)
|
||||||
|
: p.title
|
||||||
|
let subtitle = isMulti
|
||||||
|
? NSLocalizedString("Multiple SSH sessions active", comment: "Subtitle for multiple connections")
|
||||||
|
: p.subtitle
|
||||||
|
let state = TerminalAttributes.ContentState(
|
||||||
|
id: p.id,
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitle,
|
||||||
|
status: p.status,
|
||||||
|
startTime: date,
|
||||||
|
hasTerminal: p.hasTerminal ?? true,
|
||||||
|
connectionCount: p.connectionCount ?? 1
|
||||||
|
)
|
||||||
|
let content = ActivityContent(state: state, staleDate: nil)
|
||||||
|
do {
|
||||||
|
current = try Activity<TerminalAttributes>.request(attributes: attributes, content: content, pushType: nil)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func update(json: String) {
|
||||||
|
guard #available(iOS 16.2, *) else { return }
|
||||||
|
guard let p = parse(json) else { return }
|
||||||
|
let date = Date(timeIntervalSince1970: TimeInterval(p.startTimeMs) / 1000.0)
|
||||||
|
// Localize multi-connection title/subtitle on iOS side
|
||||||
|
let isMulti = (p.id == "multi_connections")
|
||||||
|
let title = isMulti
|
||||||
|
? String(format: NSLocalizedString("%d connections", comment: "Title for multiple connections"), p.connectionCount ?? 1)
|
||||||
|
: p.title
|
||||||
|
let subtitle = isMulti
|
||||||
|
? NSLocalizedString("Multiple SSH sessions active", comment: "Subtitle for multiple connections")
|
||||||
|
: p.subtitle
|
||||||
|
let state = TerminalAttributes.ContentState(
|
||||||
|
id: p.id,
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitle,
|
||||||
|
status: p.status,
|
||||||
|
startTime: date,
|
||||||
|
hasTerminal: p.hasTerminal ?? true,
|
||||||
|
connectionCount: p.connectionCount ?? 1
|
||||||
|
)
|
||||||
|
if let activity = current {
|
||||||
|
Task { await activity.update(ActivityContent(state: state, staleDate: nil)) }
|
||||||
|
} else {
|
||||||
|
start(json: json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func stop() {
|
||||||
|
guard #available(iOS 16.2, *) else { return }
|
||||||
|
if let activity = current {
|
||||||
|
Task { await activity.end(dismissalPolicy: .immediate) }
|
||||||
|
current = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
ios/Runner/TerminalLiveActivityAttributes.swift
Normal file
39
ios/Runner/TerminalLiveActivityAttributes.swift
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
//
|
||||||
|
// TerminalLiveActivityAttributes.swift
|
||||||
|
// Runner
|
||||||
|
//
|
||||||
|
// Mirror of the ActivityKit attributes used in the extension.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import ActivityKit
|
||||||
|
|
||||||
|
@available(iOS 16.1, *)
|
||||||
|
public struct TerminalAttributes: ActivityAttributes {
|
||||||
|
public struct ContentState: Codable, Hashable {
|
||||||
|
public var id: String
|
||||||
|
public var title: String
|
||||||
|
public var subtitle: String
|
||||||
|
public var status: String
|
||||||
|
public var startTime: Date
|
||||||
|
public var hasTerminal: Bool
|
||||||
|
public var connectionCount: Int
|
||||||
|
|
||||||
|
public init(id: String, title: String, subtitle: String, status: String, startTime: Date, hasTerminal: Bool, connectionCount: Int = 1) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.status = status
|
||||||
|
self.startTime = startTime
|
||||||
|
self.hasTerminal = hasTerminal
|
||||||
|
self.connectionCount = connectionCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var id: String
|
||||||
|
|
||||||
|
public init(id: String) {
|
||||||
|
self.id = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
8
ios/Runner/de.lproj/Localizable.strings
Normal file
8
ios/Runner/de.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Terminal";
|
||||||
|
"Connected" = "Verbunden";
|
||||||
|
"Connecting" = "Verbindung wird hergestellt";
|
||||||
|
"Disconnected" = "Getrennt";
|
||||||
|
"Multiple SSH sessions active" = "Mehrere aktive SSH-Sitzungen";
|
||||||
|
"1 connection" = "1 Verbindung";
|
||||||
|
"%d connections" = "%d Verbindungen";
|
||||||
|
|
||||||
8
ios/Runner/en.lproj/Localizable.strings
Normal file
8
ios/Runner/en.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Terminal";
|
||||||
|
"Connected" = "Connected";
|
||||||
|
"Connecting" = "Connecting";
|
||||||
|
"Disconnected" = "Disconnected";
|
||||||
|
"Multiple SSH sessions active" = "Multiple SSH sessions active";
|
||||||
|
"1 connection" = "1 connection";
|
||||||
|
"%d connections" = "%d connections";
|
||||||
|
|
||||||
8
ios/Runner/es.lproj/Localizable.strings
Normal file
8
ios/Runner/es.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Terminal";
|
||||||
|
"Connected" = "Conectado";
|
||||||
|
"Connecting" = "Conectando";
|
||||||
|
"Disconnected" = "Desconectado";
|
||||||
|
"Multiple SSH sessions active" = "Varias sesiones SSH activas";
|
||||||
|
"1 connection" = "1 conexión";
|
||||||
|
"%d connections" = "%d conexiones";
|
||||||
|
|
||||||
8
ios/Runner/fr.lproj/Localizable.strings
Normal file
8
ios/Runner/fr.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Terminal";
|
||||||
|
"Connected" = "Connecté";
|
||||||
|
"Connecting" = "Connexion en cours";
|
||||||
|
"Disconnected" = "Déconnecté";
|
||||||
|
"Multiple SSH sessions active" = "Plusieurs sessions SSH actives";
|
||||||
|
"1 connection" = "1 connexion";
|
||||||
|
"%d connections" = "%d connexions";
|
||||||
|
|
||||||
8
ios/Runner/id.lproj/Localizable.strings
Normal file
8
ios/Runner/id.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Terminal";
|
||||||
|
"Connected" = "Terhubung";
|
||||||
|
"Connecting" = "Menghubungkan";
|
||||||
|
"Disconnected" = "Terputus";
|
||||||
|
"Multiple SSH sessions active" = "Beberapa sesi SSH aktif";
|
||||||
|
"1 connection" = "1 koneksi";
|
||||||
|
"%d connections" = "%d koneksi";
|
||||||
|
|
||||||
8
ios/Runner/ja.lproj/Localizable.strings
Normal file
8
ios/Runner/ja.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "ターミナル";
|
||||||
|
"Connected" = "接続済み";
|
||||||
|
"Connecting" = "接続中";
|
||||||
|
"Disconnected" = "切断";
|
||||||
|
"Multiple SSH sessions active" = "複数の SSH セッションがアクティブ";
|
||||||
|
"1 connection" = "1 件の接続";
|
||||||
|
"%d connections" = "%d 件の接続";
|
||||||
|
|
||||||
8
ios/Runner/pt-BR.lproj/Localizable.strings
Normal file
8
ios/Runner/pt-BR.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Terminal";
|
||||||
|
"Connected" = "Conectado";
|
||||||
|
"Connecting" = "Conectando";
|
||||||
|
"Disconnected" = "Desconectado";
|
||||||
|
"Multiple SSH sessions active" = "Várias sessões SSH ativas";
|
||||||
|
"1 connection" = "1 conexão";
|
||||||
|
"%d connections" = "%d conexões";
|
||||||
|
|
||||||
8
ios/Runner/ru.lproj/Localizable.strings
Normal file
8
ios/Runner/ru.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Терминал";
|
||||||
|
"Connected" = "Подключено";
|
||||||
|
"Connecting" = "Подключение";
|
||||||
|
"Disconnected" = "Отключено";
|
||||||
|
"Multiple SSH sessions active" = "Несколько активных сеансов SSH";
|
||||||
|
"1 connection" = "1 подключение";
|
||||||
|
"%d connections" = "%d подключений";
|
||||||
|
|
||||||
8
ios/Runner/zh-Hans.lproj/Localizable.strings
Normal file
8
ios/Runner/zh-Hans.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "终端";
|
||||||
|
"Connected" = "已连接";
|
||||||
|
"Connecting" = "连接中";
|
||||||
|
"Disconnected" = "已断开连接";
|
||||||
|
"Multiple SSH sessions active" = "多个 SSH 会话正在活动";
|
||||||
|
"1 connection" = "1 个连接";
|
||||||
|
"%d connections" = "%d 个连接";
|
||||||
|
|
||||||
8
ios/Runner/zh-Hant.lproj/Localizable.strings
Normal file
8
ios/Runner/zh-Hant.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "終端機";
|
||||||
|
"Connected" = "已連線";
|
||||||
|
"Connecting" = "連線中";
|
||||||
|
"Disconnected" = "已中斷連線";
|
||||||
|
"Multiple SSH sessions active" = "多個 SSH 連線運行中";
|
||||||
|
"1 connection" = "1 個連線";
|
||||||
|
"%d connections" = "%d 個連線";
|
||||||
|
|
||||||
@@ -4,6 +4,15 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>NSExtension</key>
|
<key>NSExtension</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>NSExtensionAttributes</key>
|
||||||
|
<dict>
|
||||||
|
<key>IntentsSupportedIntents</key>
|
||||||
|
<array>
|
||||||
|
<string>ConfigurationIntent</string>
|
||||||
|
</array>
|
||||||
|
<key>NSSupportsLiveActivities</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
<key>NSExtensionPointIdentifier</key>
|
<key>NSExtensionPointIdentifier</key>
|
||||||
<string>com.apple.widgetkit-extension</string>
|
<string>com.apple.widgetkit-extension</string>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -15,6 +15,142 @@ let demoStatus = Status(name: "Server", cpu: "31.7%", mem: "1.3g / 1.9g", disk:
|
|||||||
let domain = "com.lollipopkit.toolbox"
|
let domain = "com.lollipopkit.toolbox"
|
||||||
let bgColor = DynamicColor(dark: UIColor.black, light: UIColor.white)
|
let bgColor = DynamicColor(dark: UIColor.black, light: UIColor.white)
|
||||||
|
|
||||||
|
// Widget-specific constants
|
||||||
|
enum WidgetConstants {
|
||||||
|
enum Dimensions {
|
||||||
|
static let smallGauge: CGFloat = 56
|
||||||
|
static let mediumGauge: CGFloat = 64
|
||||||
|
static let largeGauge: CGFloat = 76
|
||||||
|
static let refreshIconSmall: CGFloat = 12
|
||||||
|
static let refreshIconLarge: CGFloat = 14
|
||||||
|
static let cornerRadius: CGFloat = 12
|
||||||
|
static let shadowRadius: CGFloat = 2
|
||||||
|
}
|
||||||
|
enum Thresholds {
|
||||||
|
static let warningThreshold: Double = 0.6
|
||||||
|
static let criticalThreshold: Double = 0.85
|
||||||
|
}
|
||||||
|
enum Spacing {
|
||||||
|
static let tight: CGFloat = 4
|
||||||
|
static let normal: CGFloat = 8
|
||||||
|
static let loose: CGFloat = 12
|
||||||
|
static let extraLoose: CGFloat = 16
|
||||||
|
}
|
||||||
|
enum Colors {
|
||||||
|
static let cardBackground = Color(.systemBackground)
|
||||||
|
static let secondaryText = Color(.secondaryLabel)
|
||||||
|
static let success = Color(.systemGreen)
|
||||||
|
static let warning = Color(.systemOrange)
|
||||||
|
static let critical = Color(.systemRed)
|
||||||
|
static let accent = Color(.systemBlue)
|
||||||
|
}
|
||||||
|
static let appGroupId = "group.com.lollipopkit.toolbox"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Performance optimization: cache parsed values
|
||||||
|
struct ParseCache {
|
||||||
|
private static var percentCache: [String: Double] = [:]
|
||||||
|
private static var usagePercentCache: [String: Double] = [:]
|
||||||
|
|
||||||
|
static func parsePercent(_ text: String) -> Double {
|
||||||
|
if let cached = percentCache[text] { return cached }
|
||||||
|
let trimmed = text.trimmingCharacters(in: CharacterSet(charactersIn: "% "))
|
||||||
|
let result = Double(trimmed).map { max(0, min(1, $0 / 100.0)) } ?? 0
|
||||||
|
percentCache[text] = result
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parseUsagePercent(_ text: String) -> Double {
|
||||||
|
if let cached = usagePercentCache[text] { return cached }
|
||||||
|
let parts = text.split(separator: "/").map { String($0).trimmingCharacters(in: .whitespaces) }
|
||||||
|
guard parts.count == 2 else { return 0 }
|
||||||
|
let used = PerformanceUtils.parseSizeToBytes(parts[0])
|
||||||
|
let total = PerformanceUtils.parseSizeToBytes(parts[1])
|
||||||
|
let result = total <= 0 ? 0 : max(0, min(1, used / total))
|
||||||
|
usagePercentCache[text] = result
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parseNetworkTotal(_ text: String) -> (totalBytes: Double, displayText: String) {
|
||||||
|
let parts = text.split(separator: "/").map { String($0).trimmingCharacters(in: .whitespaces) }
|
||||||
|
guard parts.count == 2 else { return (0, "0 B") }
|
||||||
|
let upload = PerformanceUtils.parseSizeToBytes(parts[0])
|
||||||
|
let download = PerformanceUtils.parseSizeToBytes(parts[1])
|
||||||
|
let total = upload + download
|
||||||
|
let displayText = PerformanceUtils.formatSize(total)
|
||||||
|
return (total, displayText)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parseNetworkPercent(_ text: String) -> Double {
|
||||||
|
let parts = text.split(separator: "/").map { String($0).trimmingCharacters(in: .whitespaces) }
|
||||||
|
guard parts.count == 2 else { return 0 }
|
||||||
|
let upload = PerformanceUtils.parseSizeToBytes(parts[0])
|
||||||
|
let download = PerformanceUtils.parseSizeToBytes(parts[1])
|
||||||
|
let total = upload + download
|
||||||
|
// Return upload percentage of total traffic
|
||||||
|
return total <= 0 ? 0 : max(0, min(1, upload / total))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PerformanceUtils {
|
||||||
|
// Precomputed multipliers for performance
|
||||||
|
private static let sizeMultipliers: [Character: Double] = [
|
||||||
|
"k": 1024,
|
||||||
|
"m": pow(1024, 2),
|
||||||
|
"g": pow(1024, 3),
|
||||||
|
"t": pow(1024, 4),
|
||||||
|
"p": pow(1024, 5)
|
||||||
|
]
|
||||||
|
|
||||||
|
static func parseSizeToBytes(_ text: String) -> Double {
|
||||||
|
let lower = text.lowercased().replacingOccurrences(of: "b", with: "")
|
||||||
|
let unitChar = lower.trimmingCharacters(in: .whitespaces).last
|
||||||
|
let numberPart: String
|
||||||
|
let multiplier: Double
|
||||||
|
|
||||||
|
if let u = unitChar, let mult = sizeMultipliers[u] {
|
||||||
|
multiplier = mult
|
||||||
|
numberPart = String(lower.dropLast())
|
||||||
|
} else {
|
||||||
|
multiplier = 1.0
|
||||||
|
numberPart = lower
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = Double(numberPart.trimmingCharacters(in: .whitespaces)) ?? 0
|
||||||
|
return value * multiplier
|
||||||
|
}
|
||||||
|
|
||||||
|
static func percentStr(_ value: Double) -> String {
|
||||||
|
let pct = max(0, min(1, value)) * 100
|
||||||
|
let rounded = (pct * 10).rounded() / 10
|
||||||
|
return rounded.truncatingRemainder(dividingBy: 1) == 0
|
||||||
|
? String(format: "%.0f%%", rounded)
|
||||||
|
: String(format: "%.1f%%", rounded)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func thresholdColor(_ value: Double) -> Color {
|
||||||
|
let v = max(0, min(1, value))
|
||||||
|
switch v {
|
||||||
|
case ..<WidgetConstants.Thresholds.warningThreshold: return WidgetConstants.Colors.success
|
||||||
|
case ..<WidgetConstants.Thresholds.criticalThreshold: return WidgetConstants.Colors.warning
|
||||||
|
default: return WidgetConstants.Colors.critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func formatSize(_ bytes: Double) -> String {
|
||||||
|
let units = ["B", "KB", "MB", "GB", "TB"]
|
||||||
|
var size = bytes
|
||||||
|
var unitIndex = 0
|
||||||
|
|
||||||
|
while size >= 1024 && unitIndex < units.count - 1 {
|
||||||
|
size /= 1024
|
||||||
|
unitIndex += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(format: "%.1f %@", size, units[unitIndex])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct Provider: IntentTimelineProvider {
|
struct Provider: IntentTimelineProvider {
|
||||||
func placeholder(in context: Context) -> SimpleEntry {
|
func placeholder(in context: Context) -> SimpleEntry {
|
||||||
SimpleEntry(date: Date(), configuration: ConfigurationIntent(), state: .normal(demoStatus))
|
SimpleEntry(date: Date(), configuration: ConfigurationIntent(), state: .normal(demoStatus))
|
||||||
@@ -29,11 +165,13 @@ struct Provider: IntentTimelineProvider {
|
|||||||
var url = configuration.url
|
var url = configuration.url
|
||||||
|
|
||||||
let family = context.family
|
let family = context.family
|
||||||
|
#if os(iOS)
|
||||||
if #available(iOSApplicationExtension 16.0, *) {
|
if #available(iOSApplicationExtension 16.0, *) {
|
||||||
if family == .accessoryInline || family == .accessoryRectangular {
|
if family == .accessoryInline || family == .accessoryRectangular {
|
||||||
url = UserDefaults.standard.string(forKey: accessoryKey)
|
url = UserDefaults(suiteName: WidgetConstants.appGroupId)?.string(forKey: "accessory_widget_url")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
let currentDate = Date()
|
let currentDate = Date()
|
||||||
let refreshDate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)!
|
let refreshDate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)!
|
||||||
@@ -111,7 +249,7 @@ struct StatusWidgetEntryView : View {
|
|||||||
Button(intent: RefreshIntent()) {
|
Button(intent: RefreshIntent()) {
|
||||||
Image(systemName: "arrow.clockwise")
|
Image(systemName: "arrow.clockwise")
|
||||||
.resizable()
|
.resizable()
|
||||||
.frame(width: 10, height: 12.7)
|
.frame(width: WidgetConstants.Dimensions.refreshIconSmall, height: WidgetConstants.Dimensions.refreshIconSmall * 1.27)
|
||||||
}.tint(.gray)
|
}.tint(.gray)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,6 +261,37 @@ struct StatusWidgetEntryView : View {
|
|||||||
case .normal(let data):
|
case .normal(let data):
|
||||||
let sumColor: Color = .primary.opacity(0.7)
|
let sumColor: Color = .primary.opacity(0.7)
|
||||||
switch family {
|
switch family {
|
||||||
|
case .systemMedium:
|
||||||
|
VStack(alignment: .leading, spacing: WidgetConstants.Spacing.normal) {
|
||||||
|
// Title + refresh
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
|
HStack {
|
||||||
|
Text(data.name).font(.system(.title3, design: .monospaced))
|
||||||
|
Spacer()
|
||||||
|
Button(intent: RefreshIntent()) {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: WidgetConstants.Dimensions.refreshIconSmall, height: WidgetConstants.Dimensions.refreshIconSmall * 1.27)
|
||||||
|
}.tint(.gray)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(data.name).font(.system(.title3, design: .monospaced))
|
||||||
|
}
|
||||||
|
Spacer(minLength: WidgetConstants.Spacing.normal)
|
||||||
|
// Gauges row
|
||||||
|
HStack(spacing: WidgetConstants.Spacing.tight) {
|
||||||
|
GaugeTile(label: "CPU", value: ParseCache.parsePercent(data.cpu), display: data.cpu, diameter: WidgetConstants.Dimensions.smallGauge)
|
||||||
|
GaugeTile(label: "MEM", value: ParseCache.parseUsagePercent(data.mem), display: PerformanceUtils.percentStr(ParseCache.parseUsagePercent(data.mem)), diameter: WidgetConstants.Dimensions.smallGauge)
|
||||||
|
GaugeTile(label: "DISK", value: ParseCache.parseUsagePercent(data.disk), display: PerformanceUtils.percentStr(ParseCache.parseUsagePercent(data.disk)), diameter: WidgetConstants.Dimensions.smallGauge)
|
||||||
|
GaugeTile(label: "NET", value: ParseCache.parseNetworkPercent(data.net), display: ParseCache.parseNetworkTotal(data.net).displayText, diameter: WidgetConstants.Dimensions.smallGauge)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.bottom, 3)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.autoPadding()
|
||||||
|
.widgetBackground()
|
||||||
|
#if os(iOS)
|
||||||
case .accessoryRectangular:
|
case .accessoryRectangular:
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -142,6 +311,7 @@ struct StatusWidgetEntryView : View {
|
|||||||
.widgetBackground()
|
.widgetBackground()
|
||||||
case .accessoryInline:
|
case .accessoryInline:
|
||||||
Text("\(data.name) \(data.cpu)").widgetBackground()
|
Text("\(data.name) \(data.cpu)").widgetBackground()
|
||||||
|
#endif
|
||||||
default:
|
default:
|
||||||
VStack(alignment: .leading, spacing: 3.7) {
|
VStack(alignment: .leading, spacing: 3.7) {
|
||||||
if #available(iOS 17.0, *) {
|
if #available(iOS 17.0, *) {
|
||||||
@@ -151,7 +321,7 @@ struct StatusWidgetEntryView : View {
|
|||||||
Button(intent: RefreshIntent()) {
|
Button(intent: RefreshIntent()) {
|
||||||
Image(systemName: "arrow.clockwise")
|
Image(systemName: "arrow.clockwise")
|
||||||
.resizable()
|
.resizable()
|
||||||
.frame(width: 10, height: 12.7)
|
.frame(width: WidgetConstants.Dimensions.refreshIconSmall, height: WidgetConstants.Dimensions.refreshIconSmall * 1.27)
|
||||||
}.tint(.gray)
|
}.tint(.gray)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -162,9 +332,6 @@ struct StatusWidgetEntryView : View {
|
|||||||
DetailItem(icon: "memorychip", text: data.mem, color: sumColor)
|
DetailItem(icon: "memorychip", text: data.mem, color: sumColor)
|
||||||
DetailItem(icon: "externaldrive", text: data.disk, color: sumColor)
|
DetailItem(icon: "externaldrive", text: data.disk, color: sumColor)
|
||||||
DetailItem(icon: "network", text: data.net, color: sumColor)
|
DetailItem(icon: "network", text: data.net, color: sumColor)
|
||||||
Spacer()
|
|
||||||
DetailItem(icon: "clock", text: entry.date.toStr(), color: sumColor)
|
|
||||||
.padding(.bottom, 3)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.autoPadding()
|
.autoPadding()
|
||||||
@@ -177,8 +344,16 @@ struct StatusWidgetEntryView : View {
|
|||||||
extension View {
|
extension View {
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func widgetBackground() -> some View {
|
func widgetBackground() -> some View {
|
||||||
// Set bg to black in Night, white in Day
|
// Modern card-style background with subtle effects
|
||||||
let backgroundView = Color(bgColor.resolve())
|
let backgroundView = LinearGradient(
|
||||||
|
gradient: Gradient(colors: [
|
||||||
|
Color(bgColor.resolve()),
|
||||||
|
Color(bgColor.resolve()).opacity(0.95)
|
||||||
|
]),
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
|
||||||
if #available(iOS 17.0, *) {
|
if #available(iOS 17.0, *) {
|
||||||
containerBackground(for: .widget) {
|
containerBackground(for: .widget) {
|
||||||
backgroundView
|
backgroundView
|
||||||
@@ -188,14 +363,29 @@ extension View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// iOS 17 will auto add a SafeArea, so when iOS < 17, add .padding(.all, 17)
|
// Enhanced padding with improved spacing
|
||||||
func autoPadding() -> some View {
|
func autoPadding() -> some View {
|
||||||
if #available(iOS 17.0, *) {
|
if #available(iOS 17.0, *) {
|
||||||
return self
|
return self.padding(.all, WidgetConstants.Spacing.tight)
|
||||||
} else {
|
} else {
|
||||||
return self.padding(.all, 17)
|
return self.padding(.all, WidgetConstants.Spacing.extraLoose + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Modern card container with shadow and rounded corners
|
||||||
|
func modernCard(cornerRadius: CGFloat = WidgetConstants.Dimensions.cornerRadius) -> some View {
|
||||||
|
self
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: cornerRadius)
|
||||||
|
.fill(WidgetConstants.Colors.cardBackground)
|
||||||
|
.shadow(
|
||||||
|
color: .black.opacity(0.08),
|
||||||
|
radius: WidgetConstants.Dimensions.shadowRadius,
|
||||||
|
x: 0,
|
||||||
|
y: 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StatusWidget: Widget {
|
struct StatusWidget: Widget {
|
||||||
@@ -207,11 +397,15 @@ struct StatusWidget: Widget {
|
|||||||
}
|
}
|
||||||
.configurationDisplayName("Status")
|
.configurationDisplayName("Status")
|
||||||
.description("Status of your servers.")
|
.description("Status of your servers.")
|
||||||
if #available(iOSApplicationExtension 16.0, *) {
|
#if os(iOS)
|
||||||
return cfg.supportedFamilies([.systemSmall, .accessoryRectangular, .accessoryInline])
|
if #available(iOSApplicationExtension 16.0, *) {
|
||||||
|
return cfg.supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular, .accessoryInline])
|
||||||
} else {
|
} else {
|
||||||
return cfg.supportedFamilies([.systemSmall])
|
return cfg.supportedFamilies([.systemSmall, .systemMedium])
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
return cfg.supportedFamilies([.systemSmall, .systemMedium])
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,31 +422,176 @@ struct DetailItem: View {
|
|||||||
let color: Color
|
let color: Color
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 6.7) {
|
HStack(spacing: WidgetConstants.Spacing.normal) {
|
||||||
Image(systemName: icon).resizable().foregroundColor(color).frame(width: 11, height: 11, alignment: .center)
|
Image(systemName: icon)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.foregroundColor(color.opacity(0.8))
|
||||||
|
.frame(width: 12, height: 12)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(color.opacity(0.1))
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
)
|
||||||
|
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.system(size: 12, weight: .medium, design: .rounded))
|
||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, WidgetConstants.Spacing.tight)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced circular progress indicator
|
||||||
|
struct CirclePercent: View {
|
||||||
|
// eg: 31.7%
|
||||||
|
let percent: String
|
||||||
|
@State private var animatedProgress: Double = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let percentD = Double(percent.trimmingCharacters(in: .init(charactersIn: "%")))
|
||||||
|
let progress = (percentD ?? 0) / 100
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
// Background circle
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.primary.opacity(0.15), lineWidth: 2.5)
|
||||||
|
|
||||||
|
// Progress circle with gradient
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: CGFloat(max(0, min(1, animatedProgress))))
|
||||||
|
.stroke(
|
||||||
|
AngularGradient(
|
||||||
|
gradient: Gradient(colors: [
|
||||||
|
PerformanceUtils.thresholdColor(progress).opacity(0.7),
|
||||||
|
PerformanceUtils.thresholdColor(progress)
|
||||||
|
]),
|
||||||
|
center: .center
|
||||||
|
),
|
||||||
|
style: StrokeStyle(lineWidth: 3, lineCap: .round)
|
||||||
|
)
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
|
||||||
|
// Percentage text
|
||||||
|
Text(percent)
|
||||||
|
.font(.system(size: 8, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(.primary.opacity(0.8))
|
||||||
|
}
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.easeOut(duration: 0.8).delay(0.2)) {
|
||||||
|
animatedProgress = progress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: progress) { newProgress in
|
||||||
|
withAnimation(.easeInOut(duration: 0.5)) {
|
||||||
|
animatedProgress = newProgress
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 空心圆,显示百分比
|
// Modern gauge tile with enhanced visual design
|
||||||
struct CirclePercent: View {
|
struct GaugeTile: View {
|
||||||
// eg: 31.7%
|
let label: String
|
||||||
let percent: String
|
// 0..1
|
||||||
|
let value: Double
|
||||||
|
// eg: "31.7%"
|
||||||
|
let display: String
|
||||||
|
let diameter: CGFloat
|
||||||
|
|
||||||
|
@State private var animatedValue: Double = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
// 31.7% -> 0.317
|
VStack(spacing: WidgetConstants.Spacing.normal) {
|
||||||
let percentD = Double(percent.trimmingCharacters(in: .init(charactersIn: "%")))
|
ZStack {
|
||||||
let double = (percentD ?? 0) / 100
|
// Background circle with subtle shadow effect
|
||||||
Circle()
|
Circle()
|
||||||
.trim(from: 0, to: CGFloat(double))
|
.stroke(Color.primary.opacity(0.1), lineWidth: 4)
|
||||||
.stroke(Color.primary, lineWidth: 3)
|
.background(
|
||||||
.animation(.easeInOut(duration: 0.5))
|
Circle()
|
||||||
|
.fill(WidgetConstants.Colors.cardBackground)
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: WidgetConstants.Dimensions.shadowRadius, x: 0, y: 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Progress arc with gradient effect
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: CGFloat(max(0, min(1, animatedValue))))
|
||||||
|
.stroke(
|
||||||
|
AngularGradient(
|
||||||
|
gradient: Gradient(colors: [
|
||||||
|
PerformanceUtils.thresholdColor(value).opacity(0.8),
|
||||||
|
PerformanceUtils.thresholdColor(value)
|
||||||
|
]),
|
||||||
|
center: .center,
|
||||||
|
startAngle: .degrees(-90),
|
||||||
|
endAngle: .degrees(270)
|
||||||
|
),
|
||||||
|
style: StrokeStyle(lineWidth: 5, lineCap: .round)
|
||||||
|
)
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
|
||||||
|
// Center value text with improved typography
|
||||||
|
Text(display)
|
||||||
|
.font(.system(size: diameter < 60 ? 11 : 13, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.frame(width: diameter, height: diameter)
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.easeOut(duration: 0.8).delay(0.1)) {
|
||||||
|
animatedValue = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: value) { newValue in
|
||||||
|
withAnimation(.easeInOut(duration: 0.6)) {
|
||||||
|
animatedValue = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label with enhanced styling
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 11, weight: .medium, design: .rounded))
|
||||||
|
.foregroundColor(WidgetConstants.Colors.secondaryText)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
.tracking(0.5)
|
||||||
|
} else {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 11, weight: .medium, design: .rounded))
|
||||||
|
.foregroundColor(WidgetConstants.Colors.secondaryText)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legacy functions maintained for compatibility - now delegate to optimized versions
|
||||||
|
func parsePercent(_ text: String) -> Double {
|
||||||
|
return ParseCache.parsePercent(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUsagePercent(_ text: String) -> Double {
|
||||||
|
return ParseCache.parseUsagePercent(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSizeToBytes(_ text: String) -> Double {
|
||||||
|
return PerformanceUtils.parseSizeToBytes(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func percentStr(_ value: Double) -> String {
|
||||||
|
return PerformanceUtils.percentStr(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func thresholdColor(_ value: Double) -> Color {
|
||||||
|
return PerformanceUtils.thresholdColor(value)
|
||||||
|
}
|
||||||
|
|
||||||
struct DynamicColor {
|
struct DynamicColor {
|
||||||
let dark: UIColor
|
let dark: UIColor
|
||||||
let light: UIColor
|
let light: UIColor
|
||||||
|
|||||||
@@ -12,5 +12,8 @@ import SwiftUI
|
|||||||
struct StatusWidgetBundle: WidgetBundle {
|
struct StatusWidgetBundle: WidgetBundle {
|
||||||
var body: some Widget {
|
var body: some Widget {
|
||||||
StatusWidget()
|
StatusWidget()
|
||||||
|
if #available(iOSApplicationExtension 16.1, *) {
|
||||||
|
TerminalLiveActivity()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
185
ios/StatusWidget/TerminalLiveActivity.swift
Normal file
185
ios/StatusWidget/TerminalLiveActivity.swift
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
//
|
||||||
|
// TerminalLiveActivity.swift
|
||||||
|
// StatusWidget
|
||||||
|
//
|
||||||
|
// Renders the Live Activity UI for SSH/Terminal sessions.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
import ActivityKit
|
||||||
|
|
||||||
|
// Helper to map status strings to a color dot (case-insensitive).
|
||||||
|
@inline(__always)
|
||||||
|
private func getStatusDotColor(_ status: String) -> Color {
|
||||||
|
switch status.lowercased() {
|
||||||
|
case "connected":
|
||||||
|
return .green
|
||||||
|
case "connecting":
|
||||||
|
return .yellow
|
||||||
|
case "disconnected":
|
||||||
|
return .red
|
||||||
|
default:
|
||||||
|
return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize status for display: capitalize first letter only.
|
||||||
|
@inline(__always)
|
||||||
|
private func formatStatus(_ status: String) -> String {
|
||||||
|
let trimmed = status.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard let first = trimmed.first else { return status }
|
||||||
|
let head = String(first).uppercased()
|
||||||
|
let tail = String(trimmed.dropFirst()).lowercased()
|
||||||
|
return head + tail
|
||||||
|
}
|
||||||
|
|
||||||
|
// Localize known statuses; fall back to formatted original.
|
||||||
|
@inline(__always)
|
||||||
|
private func localizedStatus(_ status: String) -> String {
|
||||||
|
switch status.lowercased() {
|
||||||
|
case "connected":
|
||||||
|
return NSLocalizedString("Connected", comment: "Session connected status")
|
||||||
|
case "connecting":
|
||||||
|
return NSLocalizedString("Connecting", comment: "Session connecting status")
|
||||||
|
case "disconnected":
|
||||||
|
return NSLocalizedString("Disconnected", comment: "Session disconnected status")
|
||||||
|
default:
|
||||||
|
return formatStatus(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.1, *)
|
||||||
|
struct TerminalLiveActivity: Widget {
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
ActivityConfiguration(for: TerminalAttributes.self) { context in
|
||||||
|
let state = context.state
|
||||||
|
|
||||||
|
HStack(alignment: .center, spacing: 12) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(state.hasTerminal ? NSLocalizedString("Terminal", comment: "Terminal label") : "SSH")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if state.connectionCount > 1 {
|
||||||
|
Text("(\(state.connectionCount))")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(state.title)
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
Text(state.subtitle)
|
||||||
|
.font(.subheadline)
|
||||||
|
.lineLimit(1)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.fill(getStatusDotColor(state.status))
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
Text(localizedStatus(state.status))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Image(systemName: state.hasTerminal ? "terminal" : "bolt.horizontal.circle")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
} dynamicIsland: { context in
|
||||||
|
DynamicIsland {
|
||||||
|
DynamicIslandExpandedRegion(.leading) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(context.state.hasTerminal ? NSLocalizedString("Terminal", comment: "Terminal label") : "SSH")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if context.state.connectionCount > 1 {
|
||||||
|
Text("(\(context.state.connectionCount))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(context.state.title)
|
||||||
|
.font(.subheadline)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
}
|
||||||
|
DynamicIslandExpandedRegion(.trailing) {
|
||||||
|
VStack(alignment: .trailing, spacing: 6) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Circle()
|
||||||
|
.fill(getStatusDotColor(context.state.status))
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
Text(localizedStatus(context.state.status))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
}
|
||||||
|
DynamicIslandExpandedRegion(.bottom) {
|
||||||
|
Text(context.state.subtitle)
|
||||||
|
.font(.caption)
|
||||||
|
.lineLimit(1)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} compactLeading: {
|
||||||
|
Image(systemName: context.state.hasTerminal ? "terminal" : "bolt.horizontal.circle")
|
||||||
|
} compactTrailing: {
|
||||||
|
EmptyView()
|
||||||
|
} minimal: {
|
||||||
|
Image(systemName: context.state.hasTerminal ? "terminal" : "bolt.horizontal.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
@available(iOS 16.2, *)
|
||||||
|
struct TerminalLiveActivity_Previews: PreviewProvider {
|
||||||
|
static let attributes = TerminalAttributes(id: "preview")
|
||||||
|
static let contentState = TerminalAttributes.ContentState(
|
||||||
|
id: "preview",
|
||||||
|
title: "root@server-01",
|
||||||
|
subtitle: "CPU 37% • Mem 1.3G/2.0G",
|
||||||
|
status: "Connected",
|
||||||
|
startTime: Date().addingTimeInterval(-1234),
|
||||||
|
hasTerminal: true,
|
||||||
|
connectionCount: 2
|
||||||
|
)
|
||||||
|
|
||||||
|
static var previews: some View {
|
||||||
|
Group {
|
||||||
|
// 锁屏 / 通知样式预览
|
||||||
|
attributes
|
||||||
|
.previewContext(contentState, viewKind: .content)
|
||||||
|
.previewDisplayName("Lock Screen")
|
||||||
|
|
||||||
|
// 岛屿展开态预览
|
||||||
|
attributes
|
||||||
|
.previewContext(contentState, viewKind: .dynamicIsland(.expanded))
|
||||||
|
.previewDisplayName("Dynamic Island • Expanded")
|
||||||
|
|
||||||
|
// 岛屿紧凑态预览
|
||||||
|
attributes
|
||||||
|
.previewContext(contentState, viewKind: .dynamicIsland(.compact))
|
||||||
|
.previewDisplayName("Dynamic Island • Compact")
|
||||||
|
|
||||||
|
// 岛屿最小态预览
|
||||||
|
attributes
|
||||||
|
.previewContext(contentState, viewKind: .dynamicIsland(.minimal))
|
||||||
|
.previewDisplayName("Dynamic Island • Minimal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
39
ios/StatusWidget/TerminalLiveActivityAttributes.swift
Normal file
39
ios/StatusWidget/TerminalLiveActivityAttributes.swift
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
//
|
||||||
|
// TerminalLiveActivityAttributes.swift
|
||||||
|
// StatusWidget
|
||||||
|
//
|
||||||
|
// Defines ActivityKit attributes and content state for SSH/Terminal Live Activities.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import ActivityKit
|
||||||
|
|
||||||
|
@available(iOS 16.1, *)
|
||||||
|
public struct TerminalAttributes: ActivityAttributes {
|
||||||
|
public struct ContentState: Codable, Hashable {
|
||||||
|
public var id: String
|
||||||
|
public var title: String
|
||||||
|
public var subtitle: String
|
||||||
|
public var status: String
|
||||||
|
public var startTime: Date
|
||||||
|
public var hasTerminal: Bool
|
||||||
|
public var connectionCount: Int
|
||||||
|
|
||||||
|
public init(id: String, title: String, subtitle: String, status: String, startTime: Date, hasTerminal: Bool, connectionCount: Int = 1) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.status = status
|
||||||
|
self.startTime = startTime
|
||||||
|
self.hasTerminal = hasTerminal
|
||||||
|
self.connectionCount = connectionCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var id: String
|
||||||
|
|
||||||
|
public init(id: String) {
|
||||||
|
self.id = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
8
ios/StatusWidget/de.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/de.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Terminal";
|
||||||
|
"Connected" = "Verbunden";
|
||||||
|
"Connecting" = "Verbindung wird hergestellt";
|
||||||
|
"Disconnected" = "Getrennt";
|
||||||
|
"Multiple SSH sessions active" = "Mehrere aktive SSH-Sitzungen";
|
||||||
|
"1 connection" = "1 Verbindung";
|
||||||
|
"%d connections" = "%d Verbindungen";
|
||||||
|
|
||||||
8
ios/StatusWidget/en.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/en.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Terminal";
|
||||||
|
"Connected" = "Connected";
|
||||||
|
"Connecting" = "Connecting";
|
||||||
|
"Disconnected" = "Disconnected";
|
||||||
|
"Multiple SSH sessions active" = "Multiple SSH sessions active";
|
||||||
|
"1 connection" = "1 connection";
|
||||||
|
"%d connections" = "%d connections";
|
||||||
|
|
||||||
8
ios/StatusWidget/es.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/es.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Terminal";
|
||||||
|
"Connected" = "Conectado";
|
||||||
|
"Connecting" = "Conectando";
|
||||||
|
"Disconnected" = "Desconectado";
|
||||||
|
"Multiple SSH sessions active" = "Varias sesiones SSH activas";
|
||||||
|
"1 connection" = "1 conexión";
|
||||||
|
"%d connections" = "%d conexiones";
|
||||||
|
|
||||||
8
ios/StatusWidget/fr.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/fr.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Terminal";
|
||||||
|
"Connected" = "Connecté";
|
||||||
|
"Connecting" = "Connexion en cours";
|
||||||
|
"Disconnected" = "Déconnecté";
|
||||||
|
"Multiple SSH sessions active" = "Plusieurs sessions SSH actives";
|
||||||
|
"1 connection" = "1 connexion";
|
||||||
|
"%d connections" = "%d connexions";
|
||||||
|
|
||||||
8
ios/StatusWidget/id.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/id.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Terminal";
|
||||||
|
"Connected" = "Terhubung";
|
||||||
|
"Connecting" = "Menghubungkan";
|
||||||
|
"Disconnected" = "Terputus";
|
||||||
|
"Multiple SSH sessions active" = "Beberapa sesi SSH aktif";
|
||||||
|
"1 connection" = "1 koneksi";
|
||||||
|
"%d connections" = "%d koneksi";
|
||||||
|
|
||||||
8
ios/StatusWidget/ja.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/ja.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "ターミナル";
|
||||||
|
"Connected" = "接続済み";
|
||||||
|
"Connecting" = "接続中";
|
||||||
|
"Disconnected" = "切断";
|
||||||
|
"Multiple SSH sessions active" = "複数の SSH セッションがアクティブ";
|
||||||
|
"1 connection" = "1 件の接続";
|
||||||
|
"%d connections" = "%d 件の接続";
|
||||||
|
|
||||||
8
ios/StatusWidget/pt-BR.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/pt-BR.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Terminal";
|
||||||
|
"Connected" = "Conectado";
|
||||||
|
"Connecting" = "Conectando";
|
||||||
|
"Disconnected" = "Desconectado";
|
||||||
|
"Multiple SSH sessions active" = "Várias sessões SSH ativas";
|
||||||
|
"1 connection" = "1 conexão";
|
||||||
|
"%d connections" = "%d conexões";
|
||||||
|
|
||||||
8
ios/StatusWidget/ru.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/ru.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Терминал";
|
||||||
|
"Connected" = "Подключено";
|
||||||
|
"Connecting" = "Подключение";
|
||||||
|
"Disconnected" = "Отключено";
|
||||||
|
"Multiple SSH sessions active" = "Несколько активных сеансов SSH";
|
||||||
|
"1 connection" = "1 подключение";
|
||||||
|
"%d connections" = "%d подключений";
|
||||||
|
|
||||||
8
ios/StatusWidget/zh-Hans.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/zh-Hans.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "终端";
|
||||||
|
"Connected" = "已连接";
|
||||||
|
"Connecting" = "连接中";
|
||||||
|
"Disconnected" = "已断开连接";
|
||||||
|
"Multiple SSH sessions active" = "多个 SSH 会话正在活动";
|
||||||
|
"1 connection" = "1 个连接";
|
||||||
|
"%d connections" = "%d 个连接";
|
||||||
|
|
||||||
8
ios/StatusWidget/zh-Hant.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/zh-Hant.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "終端機";
|
||||||
|
"Connected" = "已連線";
|
||||||
|
"Connecting" = "連線中";
|
||||||
|
"Disconnected" = "已中斷連線";
|
||||||
|
"Multiple SSH sessions active" = "多個 SSH 連線運行中";
|
||||||
|
"1 connection" = "1 個連線";
|
||||||
|
"%d connections" = "%d 個連線";
|
||||||
|
|
||||||
@@ -9,22 +9,62 @@ import SwiftUI
|
|||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@ObservedObject var _mgr = PhoneConnMgr()
|
@ObservedObject var _mgr = PhoneConnMgr()
|
||||||
|
@State private var selection: Int = 0
|
||||||
|
@State private var refreshAllCounter: Int = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let _count = _mgr.urls.count == 0 ? 1 : _mgr.urls.count
|
let hasServers = !_mgr.urls.isEmpty
|
||||||
TabView {
|
let pagesCount = hasServers ? _mgr.urls.count : 1
|
||||||
ForEach(0 ..< _count, id:\.self) { index in
|
TabView(selection: $selection) {
|
||||||
let url = _count == 1 && _mgr.urls.count == 0 ? nil : _mgr.urls[index]
|
ForEach(0 ..< pagesCount, id:\.self) { index in
|
||||||
PageView(url: url, state: .loading)
|
let url = hasServers ? _mgr.urls[index] : nil
|
||||||
|
PageView(
|
||||||
|
url: url,
|
||||||
|
state: .loading,
|
||||||
|
refreshAllCounter: refreshAllCounter,
|
||||||
|
onRefreshAll: { refreshAllCounter += 1 }
|
||||||
|
)
|
||||||
|
.tag(index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tabViewStyle(PageTabViewStyle())
|
.tabViewStyle(PageTabViewStyle())
|
||||||
|
// 当 URL 列表变化时,尽量保持当前选中的页面不变
|
||||||
|
.onChange(of: _mgr.urls) { newValue in
|
||||||
|
let newCount = newValue.count
|
||||||
|
// 当没有服务器时,只有占位页
|
||||||
|
if newCount == 0 {
|
||||||
|
selection = 0
|
||||||
|
} else if selection >= newCount {
|
||||||
|
// 如果当前选择超出范围,则跳到最后一个有效页
|
||||||
|
selection = max(0, newCount - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 持久化当前选择,供 Widget 使用
|
||||||
|
.onChange(of: selection) { newIndex in
|
||||||
|
let appGroupId = "group.com.lollipopkit.toolbox"
|
||||||
|
if let defaults = UserDefaults(suiteName: appGroupId) {
|
||||||
|
defaults.set(newIndex, forKey: "watch_shared_selected_index")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// 尽量恢复上一次的选择
|
||||||
|
let appGroupId = "group.com.lollipopkit.toolbox"
|
||||||
|
let saved = UserDefaults(suiteName: appGroupId)?.integer(forKey: "watch_shared_selected_index") ?? 0
|
||||||
|
if !_mgr.urls.isEmpty {
|
||||||
|
selection = min(max(0, saved), _mgr.urls.count - 1)
|
||||||
|
} else {
|
||||||
|
selection = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PageView: View {
|
struct PageView: View {
|
||||||
let url: String?
|
let url: String?
|
||||||
@State var state: ContentState
|
@State var state: ContentState
|
||||||
|
// 触发所有页面刷新的计数器
|
||||||
|
let refreshAllCounter: Int
|
||||||
|
let onRefreshAll: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if url == nil {
|
if url == nil {
|
||||||
@@ -36,35 +76,50 @@ struct PageView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
switch state {
|
Group {
|
||||||
case .loading:
|
switch state {
|
||||||
ProgressView().padding().onAppear {
|
case .loading:
|
||||||
getStatus(url: url!)
|
ProgressView().padding().onAppear {
|
||||||
}
|
getStatus(url: url!)
|
||||||
case .error(let err):
|
}
|
||||||
|
case .error(let err):
|
||||||
switch err {
|
switch err {
|
||||||
case .http(let description):
|
case .http(let description):
|
||||||
VStack(alignment: .center) {
|
VStack(alignment: .center) {
|
||||||
Text(description)
|
Text(description)
|
||||||
Button(action: {
|
HStack(spacing: 10) {
|
||||||
state = .loading
|
Button(action: {
|
||||||
}){
|
state = .loading
|
||||||
Image(systemName: "arrow.clockwise")
|
}){
|
||||||
}.buttonStyle(.plain)
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}.buttonStyle(.plain)
|
||||||
|
Button(action: {
|
||||||
|
onRefreshAll()
|
||||||
|
}){
|
||||||
|
Image(systemName: "arrow.triangle.2.circlepath")
|
||||||
|
}.buttonStyle(.plain)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case .url(_):
|
case .url(_):
|
||||||
Link("View help", destination: helpUrl)
|
Link("View help", destination: helpUrl)
|
||||||
}
|
}
|
||||||
case .normal(let status):
|
case .normal(let status):
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(status.name).font(.system(.title, design: .monospaced))
|
Text(status.name).font(.system(.title, design: .monospaced))
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: {
|
HStack(spacing: 10) {
|
||||||
state = .loading
|
Button(action: {
|
||||||
}){
|
state = .loading
|
||||||
Image(systemName: "arrow.clockwise")
|
}){
|
||||||
}.buttonStyle(.plain)
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}.buttonStyle(.plain)
|
||||||
|
Button(action: {
|
||||||
|
onRefreshAll()
|
||||||
|
}){
|
||||||
|
Image(systemName: "arrow.triangle.2.circlepath")
|
||||||
|
}.buttonStyle(.plain)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
DetailItem(icon: "cpu", text: status.cpu)
|
DetailItem(icon: "cpu", text: status.cpu)
|
||||||
@@ -72,6 +127,12 @@ struct PageView: View {
|
|||||||
DetailItem(icon: "externaldrive", text: status.disk)
|
DetailItem(icon: "externaldrive", text: status.disk)
|
||||||
DetailItem(icon: "network", text: status.net)
|
DetailItem(icon: "network", text: status.net)
|
||||||
}.frame(maxWidth: .infinity, maxHeight: .infinity).padding([.horizontal], 11)
|
}.frame(maxWidth: .infinity, maxHeight: .infinity).padding([.horizontal], 11)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: refreshAllCounter) { _ in
|
||||||
|
if let url = url {
|
||||||
|
getStatus(url: url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,25 +148,32 @@ struct PageView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
|
let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
|
||||||
guard error == nil else {
|
// 所有 UI 状态更新必须在主线程执行,否则可能导致 TabView 跳回第一页等问题
|
||||||
state = .error(.http(error!.localizedDescription))
|
func setStateOnMain(_ newState: ContentState) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.state = newState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = error {
|
||||||
|
setStateOnMain(.error(.http(error.localizedDescription)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let data = data else {
|
guard let data = data else {
|
||||||
state = .error(.http("empty data"))
|
setStateOnMain(.error(.http("empty data")))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let jsonAll = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
|
guard let jsonAll = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
|
||||||
state = .error(.http("json parse fail"))
|
setStateOnMain(.error(.http("json parse fail")))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let code = jsonAll["code"] as? Int else {
|
guard let code = jsonAll["code"] as? Int else {
|
||||||
state = .error(.http("code is nil"))
|
setStateOnMain(.error(.http("code is nil")))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (code != 0) {
|
if (code != 0) {
|
||||||
let msg = jsonAll["msg"] as? String ?? ""
|
let msg = jsonAll["msg"] as? String ?? ""
|
||||||
state = .error(.http(msg))
|
setStateOnMain(.error(.http(msg)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,10 +183,35 @@ struct PageView: View {
|
|||||||
let cpu = json["cpu"] as? String ?? ""
|
let cpu = json["cpu"] as? String ?? ""
|
||||||
let mem = json["mem"] as? String ?? ""
|
let mem = json["mem"] as? String ?? ""
|
||||||
let net = json["net"] as? String ?? ""
|
let net = json["net"] as? String ?? ""
|
||||||
state = .normal(Status(name: name, cpu: cpu, mem: mem, disk: disk, net: net))
|
let status = Status(name: name, cpu: cpu, mem: mem, disk: disk, net: net)
|
||||||
|
setStateOnMain(.normal(status))
|
||||||
|
// 将最新数据写入 App Group,供表盘/叠放的 Widget 使用
|
||||||
|
let appGroupId = "group.com.lollipopkit.toolbox"
|
||||||
|
if let defaults = UserDefaults(suiteName: appGroupId) {
|
||||||
|
var statusMap = (defaults.dictionary(forKey: "watch_shared_status_by_url") as? [String: [String: String]]) ?? [:]
|
||||||
|
statusMap[url.absoluteString] = [
|
||||||
|
"name": status.name,
|
||||||
|
"cpu": status.cpu,
|
||||||
|
"mem": status.mem,
|
||||||
|
"disk": status.disk,
|
||||||
|
"net": status.net
|
||||||
|
]
|
||||||
|
defaults.set(statusMap, forKey: "watch_shared_status_by_url")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
task.resume()
|
task.resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 监听“刷新全部”触发器变化,主动刷新当前页
|
||||||
|
@ViewBuilder
|
||||||
|
var _onRefreshAllHook: some View {
|
||||||
|
EmptyView()
|
||||||
|
.onChange(of: refreshAllCounter) { _ in
|
||||||
|
if let url = url {
|
||||||
|
getStatus(url: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ContentView_Previews: PreviewProvider {
|
struct ContentView_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -44,7 +44,13 @@ class PhoneConnMgr: NSObject, WCSessionDelegate, ObservableObject {
|
|||||||
func updateUrls(_ val: [String: Any]) {
|
func updateUrls(_ val: [String: Any]) {
|
||||||
if let urls = val["urls"] as? [String] {
|
if let urls = val["urls"] as? [String] {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.urls = urls.filter { !$0.isEmpty }
|
let list = urls.filter { !$0.isEmpty }
|
||||||
|
self.urls = list
|
||||||
|
// Save URLs to App Group for widget access
|
||||||
|
let appGroupId = "group.com.lollipopkit.toolbox"
|
||||||
|
if let defaults = UserDefaults(suiteName: appGroupId) {
|
||||||
|
defaults.set(list, forKey: "watch_shared_urls")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
141
ios/WatchWidget/WatchStatusWidget.swift
Normal file
141
ios/WatchWidget/WatchStatusWidget.swift
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
//
|
||||||
|
// WatchStatusWidget.swift
|
||||||
|
// WatchStatusWidget Extension
|
||||||
|
//
|
||||||
|
// Created by AI Assistant
|
||||||
|
//
|
||||||
|
|
||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// Simple model, independent from Runner target
|
||||||
|
struct Status: Hashable {
|
||||||
|
let name: String
|
||||||
|
let cpu: String
|
||||||
|
let mem: String
|
||||||
|
let disk: String
|
||||||
|
let net: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WatchProvider: TimelineProvider {
|
||||||
|
func placeholder(in context: Context) -> WatchEntry {
|
||||||
|
WatchEntry(date: Date(), status: Status(name: "Server", cpu: "32%", mem: "1.3g/1.9g", disk: "7.1g/30g", net: "712k/1.2m"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSnapshot(in context: Context, completion: @escaping (WatchEntry) -> Void) {
|
||||||
|
completion(loadEntry())
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTimeline(in context: Context, completion: @escaping (Timeline<WatchEntry>) -> Void) {
|
||||||
|
let entry = loadEntry()
|
||||||
|
let next = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) ?? Date().addingTimeInterval(900)
|
||||||
|
completion(Timeline(entries: [entry], policy: .after(next)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadEntry() -> WatchEntry {
|
||||||
|
let appGroupId = "group.com.lollipopkit.toolbox"
|
||||||
|
guard let defaults = UserDefaults(suiteName: appGroupId) else {
|
||||||
|
return WatchEntry(date: Date(), status: Status(name: "Server", cpu: "--%", mem: "-", disk: "-", net: "-"))
|
||||||
|
}
|
||||||
|
|
||||||
|
let urls = (defaults.array(forKey: "watch_shared_urls") as? [String]) ?? []
|
||||||
|
let idx = defaults.integer(forKey: "watch_shared_selected_index")
|
||||||
|
var status: Status? = nil
|
||||||
|
|
||||||
|
if !urls.isEmpty {
|
||||||
|
let i = min(max(0, idx), urls.count - 1)
|
||||||
|
let url = urls[i]
|
||||||
|
|
||||||
|
// Load status from shared defaults
|
||||||
|
if let statusMap = defaults.dictionary(forKey: "watch_shared_status_by_url") as? [String: [String: String]],
|
||||||
|
let statusDict = statusMap[url] {
|
||||||
|
status = Status(
|
||||||
|
name: statusDict["name"] ?? "",
|
||||||
|
cpu: statusDict["cpu"] ?? "",
|
||||||
|
mem: statusDict["mem"] ?? "",
|
||||||
|
disk: statusDict["disk"] ?? "",
|
||||||
|
net: statusDict["net"] ?? ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return WatchEntry(
|
||||||
|
date: Date(),
|
||||||
|
status: status ?? Status(name: "Server", cpu: "--%", mem: "-", disk: "-", net: "-")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WatchEntry: TimelineEntry {
|
||||||
|
let date: Date
|
||||||
|
let status: Status
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WatchStatusWidgetEntryView: View {
|
||||||
|
var entry: WatchProvider.Entry
|
||||||
|
|
||||||
|
@Environment(\.widgetFamily) var family
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch family {
|
||||||
|
case .accessoryCircular:
|
||||||
|
ZStack {
|
||||||
|
Circle().stroke(Color.primary.opacity(0.15), lineWidth: 4)
|
||||||
|
CirclePercent(percent: entry.status.cpu)
|
||||||
|
Text(entry.status.cpu.replacingOccurrences(of: "%", with: "")).font(.system(size: 10, weight: .bold, design: .monospaced))
|
||||||
|
}
|
||||||
|
.padding(2)
|
||||||
|
case .accessoryRectangular:
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack {
|
||||||
|
Text(entry.status.name).font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Label(entry.status.cpu, systemImage: "cpu").font(.system(size: 11, design: .monospaced))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .accessoryInline:
|
||||||
|
Text("\(entry.status.name) \(entry.status.cpu)")
|
||||||
|
default:
|
||||||
|
VStack {
|
||||||
|
Text(entry.status.name)
|
||||||
|
Text(entry.status.cpu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WatchStatusWidget: Widget {
|
||||||
|
let kind: String = "WatchStatusWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: kind, provider: WatchProvider()) { entry in
|
||||||
|
WatchStatusWidgetEntryView(entry: entry)
|
||||||
|
}
|
||||||
|
.configurationDisplayName("Server Status")
|
||||||
|
.description("Shows the selected server status.")
|
||||||
|
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WatchStatusWidget_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
WatchStatusWidgetEntryView(entry: WatchEntry(date: Date(), status: Status(name: "Server", cpu: "37%", mem: "1.3g/1.9g", disk: "7.1g/30g", net: "712k/1.2m")))
|
||||||
|
.previewContext(WidgetPreviewContext(family: .accessoryRectangular))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers reused from iOS widget with lightweight versions
|
||||||
|
struct CirclePercent: View {
|
||||||
|
let percent: String
|
||||||
|
var body: some View {
|
||||||
|
let percentD = Double(percent.trimmingCharacters(in: .init(charactersIn: "% "))) ?? 0
|
||||||
|
let p = max(0, min(100, percentD)) / 100.0
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: CGFloat(p))
|
||||||
|
.stroke(Color.primary, style: StrokeStyle(lineWidth: 4, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
17
ios/WatchWidget/WatchStatusWidgetBundle.swift
Normal file
17
ios/WatchWidget/WatchStatusWidgetBundle.swift
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
//
|
||||||
|
// WatchStatusWidgetBundle.swift
|
||||||
|
// WatchStatusWidget Extension
|
||||||
|
//
|
||||||
|
// Created by AI Assistant
|
||||||
|
//
|
||||||
|
|
||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct WatchStatusWidgetBundle: WidgetBundle {
|
||||||
|
var body: some Widget {
|
||||||
|
WatchStatusWidget()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,5 +2,4 @@ arb-dir: lib/l10n
|
|||||||
template-arb-file: app_en.arb
|
template-arb-file: app_en.arb
|
||||||
output-localization-file: l10n.dart
|
output-localization-file: l10n.dart
|
||||||
output-dir: lib/generated/l10n
|
output-dir: lib/generated/l10n
|
||||||
synthetic-package: false
|
|
||||||
untranslated-messages-file: untranlated.json
|
untranslated-messages-file: untranlated.json
|
||||||
65
lib/app.dart
65
lib/app.dart
@@ -3,7 +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:responsive_framework/responsive_framework.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';
|
||||||
@@ -12,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, _) {
|
||||||
@@ -32,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;
|
||||||
|
|
||||||
@@ -54,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);
|
||||||
@@ -79,14 +107,8 @@ class MyApp extends StatelessWidget {
|
|||||||
|
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
key: ValueKey(locale),
|
key: ValueKey(locale),
|
||||||
builder: (context, child) => ResponsiveBreakpoints.builder(
|
navigatorKey: AppNavigator.key,
|
||||||
child: child ?? UIs.placeholder,
|
builder: ResponsivePoints.builder,
|
||||||
breakpoints: const [
|
|
||||||
Breakpoint(start: 0, end: 600, name: MOBILE),
|
|
||||||
Breakpoint(start: 600, end: 1199, name: TABLET),
|
|
||||||
Breakpoint(start: 1199, end: 3840, name: DESKTOP),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
locale: locale,
|
locale: locale,
|
||||||
localizationsDelegates: const [LibLocalizations.delegate, ...AppLocalizations.localizationsDelegates],
|
localizationsDelegates: const [LibLocalizations.delegate, ...AppLocalizations.localizationsDelegates],
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
@@ -96,20 +118,25 @@ class MyApp extends StatelessWidget {
|
|||||||
themeMode: themeMode,
|
themeMode: themeMode,
|
||||||
theme: light.fixWindowsFont,
|
theme: light.fixWindowsFont,
|
||||||
darkTheme: (tMode < 3 ? dark : dark.toAmoled).fixWindowsFont,
|
darkTheme: (tMode < 3 ? dark : dark.toAmoled).fixWindowsFont,
|
||||||
home: Builder(
|
home: FutureBuilder<List<IntroPageBuilder>>(
|
||||||
builder: (context) {
|
future: _introFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
context.setLibL10n();
|
context.setLibL10n();
|
||||||
final appL10n = AppLocalizations.of(context);
|
final appL10n = AppLocalizations.of(context);
|
||||||
if (appL10n != null) l10n = appL10n;
|
if (appL10n != null) l10n = appL10n;
|
||||||
|
|
||||||
Widget child;
|
Widget child;
|
||||||
final intros = _IntroPage.builders;
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
if (intros.isNotEmpty) {
|
child = const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||||
child = _IntroPage(intros);
|
} else {
|
||||||
|
final intros = snapshot.data ?? [];
|
||||||
|
if (intros.isNotEmpty) {
|
||||||
|
child = _IntroPage(intros);
|
||||||
|
} else {
|
||||||
|
child = const HomePage();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
child = const HomePage();
|
|
||||||
|
|
||||||
return VirtualWindowFrame(title: BuildData.name, child: child);
|
return VirtualWindowFrame(title: BuildData.name, child: child);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
8
lib/core/app_navigator.dart
Normal file
8
lib/core/app_navigator.dart
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// Global navigator access used for cross-cutting flows (e.g. dialogs).
|
||||||
|
abstract final class AppNavigator {
|
||||||
|
static final key = GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
|
static BuildContext? get context => key.currentContext;
|
||||||
|
}
|
||||||
@@ -12,19 +12,97 @@ abstract final class MethodChans {
|
|||||||
|
|
||||||
/// Issue #662
|
/// Issue #662
|
||||||
static void startService() {
|
static void startService() {
|
||||||
// if (Stores.setting.fgService.fetch() != true) return;
|
if (Stores.setting.fgService.fetch() != true) return;
|
||||||
// _channel.invokeMethod('startService');
|
_channel.invokeMethod('startService');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Issue #662
|
/// Issue #662
|
||||||
static void stopService() {
|
static void stopService() {
|
||||||
// if (Stores.setting.fgService.fetch() != true) return;
|
if (Stores.setting.fgService.fetch() != true) return;
|
||||||
// _channel.invokeMethod('stopService');
|
_channel.invokeMethod('stopService');
|
||||||
}
|
}
|
||||||
|
|
||||||
static void updateHomeWidget() async {
|
static void updateHomeWidget() async {
|
||||||
if (!isIOS || !isAndroid) return;
|
if (!isIOS && !isAndroid) return;
|
||||||
if (!Stores.setting.autoUpdateHomeWidget.fetch()) return;
|
if (!Stores.setting.autoUpdateHomeWidget.fetch()) return;
|
||||||
await _channel.invokeMethod('updateHomeWidget');
|
await _channel.invokeMethod('updateHomeWidget');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update Android foreground service notifications for SSH sessions
|
||||||
|
/// The [payload] is a JSON string describing sessions list.
|
||||||
|
static Future<void> updateSessions(String payload) async {
|
||||||
|
if (!isAndroid) return;
|
||||||
|
try {
|
||||||
|
Loggers.app.info('Updating Android sessions: $payload');
|
||||||
|
await _channel.invokeMethod('updateSessions', payload);
|
||||||
|
} catch (e, s) {
|
||||||
|
Loggers.app.warning('Failed to update Android sessions', e, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query whether the Android foreground service is currently running.
|
||||||
|
static Future<bool> isServiceRunning() async {
|
||||||
|
if (!isAndroid) return false;
|
||||||
|
try {
|
||||||
|
final res = await _channel.invokeMethod('isServiceRunning');
|
||||||
|
return res == true;
|
||||||
|
} catch (e, s) {
|
||||||
|
Loggers.app.warning('Failed to check if Android service is running', e, s);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// iOS Live Activities controls
|
||||||
|
static Future<void> startLiveActivity(String payload) async {
|
||||||
|
if (!isIOS) return;
|
||||||
|
try {
|
||||||
|
Loggers.app.info('Starting iOS Live Activity: $payload');
|
||||||
|
await _channel.invokeMethod('startLiveActivity', payload);
|
||||||
|
} catch (e, s) {
|
||||||
|
Loggers.app.warning('Failed to start iOS Live Activity', e, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> updateLiveActivity(String payload) async {
|
||||||
|
if (!isIOS) return;
|
||||||
|
try {
|
||||||
|
Loggers.app.info('Updating iOS Live Activity: $payload');
|
||||||
|
await _channel.invokeMethod('updateLiveActivity', payload);
|
||||||
|
} catch (e, s) {
|
||||||
|
Loggers.app.warning('Failed to update iOS Live Activity', e, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> stopLiveActivity() async {
|
||||||
|
if (!isIOS) return;
|
||||||
|
try {
|
||||||
|
Loggers.app.info('Stopping iOS Live Activity');
|
||||||
|
await _channel.invokeMethod('stopLiveActivity');
|
||||||
|
} catch (e, s) {
|
||||||
|
Loggers.app.warning('Failed to stop iOS Live Activity', e, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a handler for native -> Flutter callbacks.
|
||||||
|
/// Currently handles:
|
||||||
|
/// - `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 {
|
||||||
|
switch (call.method) {
|
||||||
|
case 'disconnectSession':
|
||||||
|
final args = call.arguments;
|
||||||
|
final id = args is Map ? args['id'] as String? : args as String?;
|
||||||
|
if (id != null && id.isNotEmpty) {
|
||||||
|
await onDisconnect(id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'stopAllConnections':
|
||||||
|
onStopAll?.call();
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -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';
|
||||||
@@ -132,8 +133,9 @@ extension SSHClientX on SSHClient {
|
|||||||
if (data.contains('[sudo] password for ')) {
|
if (data.contains('[sudo] password for ')) {
|
||||||
isRequestingPwd = true;
|
isRequestingPwd = true;
|
||||||
final user = Miscs.pwdRequestWithUserReg.firstMatch(data)?.group(1);
|
final user = Miscs.pwdRequestWithUserReg.firstMatch(data)?.group(1);
|
||||||
if (context == null) return;
|
final ctx = context ?? WidgetsBinding.instance.focusManager.primaryFocus?.context;
|
||||||
final pwd = context.mounted ? await context.showPwdDialog(title: user, id: id) : null;
|
if (ctx == null) return;
|
||||||
|
final pwd = ctx.mounted ? await ctx.showPwdDialog(title: user, id: id) : null;
|
||||||
if (pwd == null || pwd.isEmpty) {
|
if (pwd == null || pwd.isEmpty) {
|
||||||
session.stdin.close();
|
session.stdin.close();
|
||||||
} else {
|
} else {
|
||||||
@@ -169,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
411
lib/core/service/ssh_discovery.dart
Normal file
411
lib/core/service/ssh_discovery.dart
Normal 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));
|
||||||
|
}
|
||||||
@@ -12,12 +12,25 @@ final class BakSyncer extends SyncIface {
|
|||||||
const BakSyncer._() : super();
|
const BakSyncer._() : super();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> saveToFile() => BackupV2.backup();
|
Future<void> saveToFile() async {
|
||||||
|
final pwd = await SecureStoreProps.bakPwd.read();
|
||||||
|
await BackupV2.backup(null, pwd?.isEmpty == true ? null : pwd);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Mergeable> fromFile(String path) async {
|
Future<Mergeable> fromFile(String path) async {
|
||||||
final content = await File(path).readAsString();
|
final content = await File(path).readAsString();
|
||||||
return MergeableUtils.fromJsonString(content).$1;
|
final pwd = await SecureStoreProps.bakPwd.read();
|
||||||
|
try {
|
||||||
|
if (Cryptor.isEncrypted(content)) {
|
||||||
|
return MergeableUtils.fromJsonString(content, pwd).$1;
|
||||||
|
}
|
||||||
|
return MergeableUtils.fromJsonString(content).$1;
|
||||||
|
} catch (e, s) {
|
||||||
|
Loggers.app.warning('Failed to parse backup file with password, trying without password', e, s);
|
||||||
|
// Fallback: try without password if detection failed
|
||||||
|
return MergeableUtils.fromJsonString(content).$1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -28,6 +41,9 @@ final class BakSyncer extends SyncIface {
|
|||||||
final webdavEnabled = PrefProps.webdavSync.get();
|
final webdavEnabled = PrefProps.webdavSync.get();
|
||||||
if (webdavEnabled) return Webdav.shared;
|
if (webdavEnabled) return Webdav.shared;
|
||||||
|
|
||||||
|
final gistEnabled = PrefProps.gistSync.get();
|
||||||
|
if (gistEnabled) return GistRs.shared;
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class ChainComparator<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ChainComparator<T> reversed() {
|
ChainComparator<T> reversed() {
|
||||||
return ChainComparator._create(null, (a, b) => this.compare(b, a));
|
return ChainComparator._create(null, (a, b) => compare(b, a));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
26
lib/core/utils/host_key_helper.dart
Normal file
26
lib/core/utils/host_key_helper.dart
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import 'package:fl_lib/fl_lib.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:server_box/core/utils/server.dart';
|
||||||
|
import 'package:server_box/core/utils/ssh_auth.dart';
|
||||||
|
import 'package:server_box/data/model/server/server_private_info.dart';
|
||||||
|
import 'package:server_box/data/res/store.dart';
|
||||||
|
|
||||||
|
Future<bool> ensureHostKeyAcceptedForSftp(BuildContext context, Spi spi) async {
|
||||||
|
final known = Stores.setting.sshKnownHostFingerprints.get();
|
||||||
|
final hostId = spi.id.isNotEmpty ? spi.id : spi.oldId;
|
||||||
|
final prefix = '$hostId::';
|
||||||
|
if (known.keys.any((key) => key.startsWith(prefix))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final (result, error) = await context.showLoadingDialog<bool>(
|
||||||
|
fn: () async {
|
||||||
|
await ensureKnownHostKey(
|
||||||
|
spi,
|
||||||
|
onKeyboardInteractive: (_) => KeybordInteractive.defaultHandle(spi, ctx: context),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return error == null && result == true;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
84
lib/core/utils/server_dedup.dart
Normal file
84
lib/core/utils/server_dedup.dart
Normal 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;
|
||||||
|
}
|
||||||
@@ -3,15 +3,11 @@ import 'dart:async';
|
|||||||
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: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/app.dart';
|
|
||||||
|
|
||||||
abstract final class KeybordInteractive {
|
abstract final class KeybordInteractive {
|
||||||
static FutureOr<List<String>?> defaultHandle(
|
static FutureOr<List<String>?> defaultHandle(Spi spi, {BuildContext? ctx}) async {
|
||||||
Spi spi, {
|
|
||||||
BuildContext? ctx,
|
|
||||||
}) async {
|
|
||||||
try {
|
try {
|
||||||
final res = await (ctx ?? AppProvider.ctx)?.showPwdDialog(
|
final res = await (ctx ?? WidgetsBinding.instance.focusManager.primaryFocus?.context)?.showPwdDialog(
|
||||||
title: libL10n.pwd,
|
title: libL10n.pwd,
|
||||||
id: spi.id,
|
id: spi.id,
|
||||||
label: spi.id,
|
label: spi.id,
|
||||||
|
|||||||
190
lib/core/utils/ssh_config.dart
Normal file
190
lib/core/utils/ssh_config.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
lib/data/helper/ssh_decoder.dart
Normal file
66
lib/data/helper/ssh_decoder.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,20 @@
|
|||||||
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';
|
||||||
|
|
||||||
/// Helper class for detecting remote system types
|
/// Helper class for detecting remote system types
|
||||||
class SystemDetector {
|
class SystemDetector {
|
||||||
/// Detects the system type of a remote server
|
/// Detects the system type of a remote server
|
||||||
///
|
///
|
||||||
/// 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(
|
static Future<SystemType> detect(SSHClient client, Spi spi) async {
|
||||||
SSHClient client,
|
|
||||||
Spi spi,
|
|
||||||
) async {
|
|
||||||
// First, check if custom system type is defined
|
// First, check if custom system type is defined
|
||||||
SystemType? detectedSystemType = spi.customSystemType;
|
SystemType? detectedSystemType = spi.customSystemType;
|
||||||
if (detectedSystemType != null) {
|
if (detectedSystemType != null) {
|
||||||
@@ -25,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}');
|
||||||
@@ -45,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
|
||||||
@@ -54,4 +59,4 @@ class SystemDetector {
|
|||||||
dprint('Defaulting to Linux system type for ${spi.oldId}');
|
dprint('Defaulting to Linux system type for ${spi.oldId}');
|
||||||
return detectedSystemType;
|
return detectedSystemType;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
74
lib/data/model/ai/ask_ai_models.dart
Normal file
74
lib/data/model/ai/ask_ai_models.dart
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import 'package:meta/meta.dart';
|
||||||
|
|
||||||
|
/// Chat message exchanged with the Ask AI service.
|
||||||
|
enum AskAiMessageRole { user, assistant }
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class AskAiMessage {
|
||||||
|
const AskAiMessage({
|
||||||
|
required this.role,
|
||||||
|
required this.content,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AskAiMessageRole role;
|
||||||
|
final String content;
|
||||||
|
|
||||||
|
String get apiRole {
|
||||||
|
switch (role) {
|
||||||
|
case AskAiMessageRole.user:
|
||||||
|
return 'user';
|
||||||
|
case AskAiMessageRole.assistant:
|
||||||
|
return 'assistant';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recommended command returned by the AI tool call.
|
||||||
|
@immutable
|
||||||
|
class AskAiCommand {
|
||||||
|
const AskAiCommand({
|
||||||
|
required this.command,
|
||||||
|
this.description = '',
|
||||||
|
this.toolName,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String command;
|
||||||
|
final String description;
|
||||||
|
final String? toolName;
|
||||||
|
}
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
sealed class AskAiEvent {
|
||||||
|
const AskAiEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Incremental text delta emitted while streaming the AI response.
|
||||||
|
class AskAiContentDelta extends AskAiEvent {
|
||||||
|
const AskAiContentDelta(this.delta);
|
||||||
|
final String delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emits when a tool call returns a runnable command suggestion.
|
||||||
|
class AskAiToolSuggestion extends AskAiEvent {
|
||||||
|
const AskAiToolSuggestion(this.command);
|
||||||
|
final AskAiCommand command;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signals that the stream finished successfully.
|
||||||
|
class AskAiCompleted extends AskAiEvent {
|
||||||
|
const AskAiCompleted({
|
||||||
|
required this.fullText,
|
||||||
|
required this.commands,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String fullText;
|
||||||
|
final List<AskAiCommand> commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signals that the stream terminated with an error before completion.
|
||||||
|
class AskAiStreamError extends AskAiEvent {
|
||||||
|
const AskAiStreamError(this.error, this.stackTrace);
|
||||||
|
|
||||||
|
final Object error;
|
||||||
|
final StackTrace? stackTrace;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// dart format width=80
|
|
||||||
// coverage:ignore-file
|
|
||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint
|
// 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
|
// 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
|
||||||
|
|
||||||
@@ -83,6 +82,136 @@ as Map<String, Object?>,
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Adds pattern-matching-related methods to [BackupV2].
|
||||||
|
extension BackupV2Patterns on BackupV2 {
|
||||||
|
/// 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( _BackupV2 value)? $default,{required TResult orElse(),}){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _BackupV2() 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( _BackupV2 value) $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _BackupV2():
|
||||||
|
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( _BackupV2 value)? $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _BackupV2() 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 version, int date, Map<String, Object?> spis, Map<String, Object?> snippets, Map<String, Object?> keys, Map<String, Object?> container, Map<String, Object?> history, Map<String, Object?> settings)? $default,{required TResult orElse(),}) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _BackupV2() when $default != null:
|
||||||
|
return $default(_that.version,_that.date,_that.spis,_that.snippets,_that.keys,_that.container,_that.history,_that.settings);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 version, int date, Map<String, Object?> spis, Map<String, Object?> snippets, Map<String, Object?> keys, Map<String, Object?> container, Map<String, Object?> history, Map<String, Object?> settings) $default,) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _BackupV2():
|
||||||
|
return $default(_that.version,_that.date,_that.spis,_that.snippets,_that.keys,_that.container,_that.history,_that.settings);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 version, int date, Map<String, Object?> spis, Map<String, Object?> snippets, Map<String, Object?> keys, Map<String, Object?> container, Map<String, Object?> history, Map<String, Object?> settings)? $default,) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _BackupV2() when $default != null:
|
||||||
|
return $default(_that.version,_that.date,_that.spis,_that.snippets,_that.keys,_that.container,_that.history,_that.settings);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
/// @nodoc
|
/// @nodoc
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
|
|
||||||
|
|||||||
@@ -5,20 +5,18 @@ import 'package:server_box/core/extension/context/locale.dart';
|
|||||||
import 'package:server_box/data/model/app/bak/backup2.dart';
|
import 'package:server_box/data/model/app/bak/backup2.dart';
|
||||||
import 'package:server_box/data/model/app/bak/backup_source.dart';
|
import 'package:server_box/data/model/app/bak/backup_source.dart';
|
||||||
import 'package:server_box/data/model/app/bak/utils.dart';
|
import 'package:server_box/data/model/app/bak/utils.dart';
|
||||||
import 'package:server_box/data/res/store.dart';
|
|
||||||
|
|
||||||
/// Service class for handling backup operations
|
/// Service class for handling backup operations
|
||||||
class BackupService {
|
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 {
|
||||||
final password = await _getBackupPassword(context);
|
|
||||||
if (password == null) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final path = await BackupV2.backup(null, password.isEmpty ? null : password);
|
final saved = await SecureStoreProps.bakPwd.read();
|
||||||
|
final password = saved?.isEmpty == true ? null : saved;
|
||||||
|
|
||||||
|
final path = await BackupV2.backup(null, password?.isEmpty == true ? null : password);
|
||||||
await source.saveContent(path);
|
await source.saveContent(path);
|
||||||
|
|
||||||
// Show success message for clipboard source
|
|
||||||
if (source is ClipboardBackupSource) {
|
if (source is ClipboardBackupSource) {
|
||||||
context.showSnackBar(libL10n.success);
|
context.showSnackBar(libL10n.success);
|
||||||
}
|
}
|
||||||
@@ -41,38 +39,6 @@ class BackupService {
|
|||||||
await restoreFromText(context, text);
|
await restoreFromText(context, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle password dialog for backup operations
|
|
||||||
static Future<String?> _getBackupPassword(BuildContext context) async {
|
|
||||||
final savedPassword = await Stores.setting.backupasswd.read();
|
|
||||||
String? password;
|
|
||||||
|
|
||||||
if (savedPassword != null && savedPassword.isNotEmpty) {
|
|
||||||
// Use saved password or ask for custom password
|
|
||||||
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 {
|
|
||||||
// No saved password, ask if user wants to set one
|
|
||||||
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
|
||||||
@@ -95,7 +61,7 @@ class BackupService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try with saved password first
|
// Try with saved password first
|
||||||
final savedPassword = await Stores.setting.backupasswd.read();
|
final savedPassword = await SecureStoreProps.bakPwd.read();
|
||||||
if (savedPassword != null && savedPassword.isNotEmpty) {
|
if (savedPassword != null && savedPassword.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
final (backup, err) = await context.showLoadingDialog(
|
final (backup, err) = await context.showLoadingDialog(
|
||||||
@@ -108,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
119
lib/data/model/app/menu/platform.dart
Normal file
119
lib/data/model/app/menu/platform.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,52 @@
|
|||||||
|
import 'package:flutter/material.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/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';
|
||||||
|
|
||||||
|
/// Enum representing different command types for various systems
|
||||||
|
enum CmdTypeSys {
|
||||||
|
linux('Linux'),
|
||||||
|
bsd('BSD'),
|
||||||
|
windows('Windows');
|
||||||
|
|
||||||
|
final String sign;
|
||||||
|
const CmdTypeSys(this.sign);
|
||||||
|
|
||||||
|
IconData get icon {
|
||||||
|
return switch (this) {
|
||||||
|
CmdTypeSys.linux => MingCute.linux_line,
|
||||||
|
CmdTypeSys.bsd => LineAwesome.freebsd,
|
||||||
|
CmdTypeSys.windows => MingCute.windows_line,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Base class for all command type enums
|
/// Base class for all command type enums
|
||||||
abstract class CommandType implements Enum {
|
sealed class ShellCmdType implements Enum {
|
||||||
String get cmd;
|
String get cmd;
|
||||||
|
|
||||||
/// Get command-specific separator
|
/// Get command-specific separator
|
||||||
String get separator;
|
String get separator;
|
||||||
|
|
||||||
/// Get command-specific divider (separator with echo and formatting)
|
/// Get command-specific divider (separator with echo and formatting)
|
||||||
String get divider;
|
String get divider;
|
||||||
|
|
||||||
|
/// Get corresponding system type
|
||||||
|
CmdTypeSys get sysType;
|
||||||
|
|
||||||
|
static Set<ShellCmdType> get all {
|
||||||
|
return {...StatusCmdType.values, ...BSDStatusCmdType.values, ...WindowsStatusCmdType.values};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ShellCmdTypeX on ShellCmdType {
|
||||||
|
/// Display name of the command type
|
||||||
|
String get displayName => '${sysType.sign}.$name';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Linux/Unix status commands
|
/// Linux/Unix status commands
|
||||||
enum StatusCmdType implements CommandType {
|
enum StatusCmdType implements ShellCmdType {
|
||||||
echo('echo ${SystemType.linuxSign}'),
|
echo('echo ${SystemType.linuxSign}'),
|
||||||
time('date +%s'),
|
time('date +%s'),
|
||||||
net('cat /proc/net/dev'),
|
net('cat /proc/net/dev'),
|
||||||
@@ -23,8 +55,8 @@ enum StatusCmdType implements CommandType {
|
|||||||
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'),
|
||||||
@@ -90,16 +122,19 @@ enum StatusCmdType implements CommandType {
|
|||||||
final String cmd;
|
final String cmd;
|
||||||
|
|
||||||
const StatusCmdType(this.cmd);
|
const StatusCmdType(this.cmd);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get separator => ScriptConstants.getCmdSeparator(name);
|
String get separator => ScriptConstants.getCmdSeparator(name);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get divider => ScriptConstants.getCmdDivider(name);
|
String get divider => ScriptConstants.getCmdDivider(name);
|
||||||
|
|
||||||
|
@override
|
||||||
|
CmdTypeSys get sysType => CmdTypeSys.linux;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// BSD/macOS status commands
|
/// BSD/macOS status commands
|
||||||
enum BSDStatusCmdType implements CommandType {
|
enum BSDStatusCmdType implements ShellCmdType {
|
||||||
echo('echo ${SystemType.bsdSign}'),
|
echo('echo ${SystemType.bsdSign}'),
|
||||||
time('date +%s'),
|
time('date +%s'),
|
||||||
net('netstat -ibn'),
|
net('netstat -ibn'),
|
||||||
@@ -115,38 +150,50 @@ enum BSDStatusCmdType implements CommandType {
|
|||||||
final String cmd;
|
final String cmd;
|
||||||
|
|
||||||
const BSDStatusCmdType(this.cmd);
|
const BSDStatusCmdType(this.cmd);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get separator => ScriptConstants.getCmdSeparator(name);
|
String get separator => ScriptConstants.getCmdSeparator(name);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get divider => ScriptConstants.getCmdDivider(name);
|
String get divider => ScriptConstants.getCmdDivider(name);
|
||||||
|
|
||||||
|
@override
|
||||||
|
CmdTypeSys get sysType => CmdTypeSys.bsd;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Windows PowerShell status commands
|
/// Windows PowerShell status commands
|
||||||
enum WindowsStatusCmdType implements CommandType {
|
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 | '
|
||||||
@@ -175,19 +222,19 @@ enum WindowsStatusCmdType implements CommandType {
|
|||||||
),
|
),
|
||||||
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 | '
|
||||||
@@ -244,12 +291,15 @@ enum WindowsStatusCmdType implements CommandType {
|
|||||||
final String cmd;
|
final String cmd;
|
||||||
|
|
||||||
const WindowsStatusCmdType(this.cmd);
|
const WindowsStatusCmdType(this.cmd);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
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
|
||||||
|
CmdTypeSys get sysType => CmdTypeSys.windows;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extensions for StatusCmdType
|
/// Extensions for StatusCmdType
|
||||||
@@ -266,7 +316,7 @@ extension StatusCmdTypeX on StatusCmdType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Extension for CommandType to find content in parsed map
|
/// Extension for CommandType to find content in parsed map
|
||||||
extension CommandTypeX on CommandType {
|
extension CommandTypeX on ShellCmdType {
|
||||||
/// Find the command output from the parsed script output map
|
/// Find the command output from the parsed script output map
|
||||||
String findInMap(Map<String, String> parsedOutput) {
|
String findInMap(Map<String, String> parsedOutput) {
|
||||||
return parsedOutput[name] ?? '';
|
return parsedOutput[name] ?? '';
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ switch (\$args[0]) {
|
|||||||
|
|
||||||
/// Get Windows status command with command-specific separators
|
/// Get Windows status command with command-specific separators
|
||||||
String _getWindowsStatusCommand({required List<String> disabledCmdTypes}) {
|
String _getWindowsStatusCommand({required List<String> disabledCmdTypes}) {
|
||||||
final cmdTypes = WindowsStatusCmdType.values.where((e) => !disabledCmdTypes.contains(e.name));
|
final cmdTypes = WindowsStatusCmdType.values.where((e) => !disabledCmdTypes.contains(e.displayName));
|
||||||
return cmdTypes.map((e) => '${e.divider}${e.cmd}').join('').trimRight(); // Remove trailing divider
|
return cmdTypes.map((e) => '${e.divider}${e.cmd}').join('').trimRight(); // Remove trailing divider
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,10 +196,14 @@ esac''');
|
|||||||
/// Get Unix status command with OS detection
|
/// Get Unix status command with OS detection
|
||||||
String _getUnixStatusCommand({required List<String> disabledCmdTypes}) {
|
String _getUnixStatusCommand({required List<String> disabledCmdTypes}) {
|
||||||
// Generate command lists with command-specific separators, filtering disabled commands
|
// Generate command lists with command-specific separators, filtering disabled commands
|
||||||
final filteredLinuxCmdTypes = StatusCmdType.values.where((e) => !disabledCmdTypes.contains(e.name));
|
final filteredLinuxCmdTypes = StatusCmdType.values.where(
|
||||||
|
(e) => !disabledCmdTypes.contains(e.displayName),
|
||||||
|
);
|
||||||
final linuxCommands = filteredLinuxCmdTypes.map((e) => '${e.divider}${e.cmd}').join('').trimRight();
|
final linuxCommands = filteredLinuxCmdTypes.map((e) => '${e.divider}${e.cmd}').join('').trimRight();
|
||||||
|
|
||||||
final filteredBsdCmdTypes = BSDStatusCmdType.values.where((e) => !disabledCmdTypes.contains(e.name));
|
final filteredBsdCmdTypes = BSDStatusCmdType.values.where(
|
||||||
|
(e) => !disabledCmdTypes.contains(e.displayName),
|
||||||
|
);
|
||||||
final bsdCommands = filteredBsdCmdTypes.map((e) => '${e.divider}${e.cmd}').join('').trimRight();
|
final bsdCommands = filteredBsdCmdTypes.map((e) => '${e.divider}${e.cmd}').join('').trimRight();
|
||||||
|
|
||||||
return '''
|
return '''
|
||||||
|
|||||||
@@ -29,17 +29,20 @@ 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>{};
|
||||||
|
|
||||||
if (raw.isEmpty) return result;
|
if (raw.isEmpty) return result;
|
||||||
|
|
||||||
// Parse line by line to properly handle command-specific separators
|
// Parse line by line to properly handle command-specific separators
|
||||||
final lines = raw.split('\n');
|
final lines = raw.split('\n');
|
||||||
String? currentCmd;
|
String? currentCmd;
|
||||||
final buffer = StringBuffer();
|
final buffer = StringBuffer();
|
||||||
|
|
||||||
for (final line in lines) {
|
for (final line in lines) {
|
||||||
if (line.startsWith('$separator.')) {
|
if (line.startsWith('$separator.')) {
|
||||||
// Save previous command content
|
// Save previous command content
|
||||||
@@ -61,12 +64,12 @@ class ScriptConstants {
|
|||||||
buffer.writeln(line);
|
buffer.writeln(line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't forget the last command
|
// Don't forget the last command
|
||||||
if (currentCmd != null) {
|
if (currentCmd != null) {
|
||||||
result[currentCmd] = buffer.toString().trim();
|
result[currentCmd] = buffer.toString().trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
''';
|
''';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -93,7 +90,11 @@ class ShellFuncManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Generate complete script based on system type
|
/// Generate complete script based on system type
|
||||||
static String allScript(Map<String, String>? customCmds, {SystemType? systemType, List<String>? disabledCmdTypes}) {
|
static String allScript(
|
||||||
|
Map<String, String>? customCmds, {
|
||||||
|
SystemType? systemType,
|
||||||
|
List<String>? disabledCmdTypes,
|
||||||
|
}) {
|
||||||
final isWindows = systemType == SystemType.windows;
|
final isWindows = systemType == SystemType.windows;
|
||||||
final builder = ScriptBuilderFactory.getBuilder(isWindows);
|
final builder = ScriptBuilderFactory.getBuilder(isWindows);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
52
lib/data/model/app/tab.g.dart
Normal file
52
lib/data/model/app/tab.g.dart
Normal 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;
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
70
lib/data/model/container/status.dart
Normal file
70
lib/data/model/container/status.dart
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
79
lib/data/model/server/connection_stat.dart
Normal file
79
lib/data/model/server/connection_stat.dart
Normal 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;
|
||||||
|
}
|
||||||
585
lib/data/model/server/connection_stat.freezed.dart
Normal file
585
lib/data/model/server/connection_stat.freezed.dart
Normal 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
|
||||||
233
lib/data/model/server/connection_stat.g.dart
Normal file
233
lib/data/model/server/connection_stat.g.dart
Normal 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,
|
||||||
|
};
|
||||||
@@ -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) {
|
||||||
@@ -142,16 +151,7 @@ class SingleCpuCore extends TimeSeqIface<SingleCpuCore> {
|
|||||||
final int irq;
|
final int irq;
|
||||||
final int softirq;
|
final int softirq;
|
||||||
|
|
||||||
SingleCpuCore(
|
SingleCpuCore(this.id, this.user, this.sys, this.nice, this.idle, this.iowait, this.irq, this.softirq);
|
||||||
this.id,
|
|
||||||
this.user,
|
|
||||||
this.sys,
|
|
||||||
this.nice,
|
|
||||||
this.idle,
|
|
||||||
this.iowait,
|
|
||||||
this.irq,
|
|
||||||
this.softirq,
|
|
||||||
);
|
|
||||||
|
|
||||||
int get total => user + sys + nice + idle + iowait + irq + softirq;
|
int get total => user + sys + nice + idle + iowait + irq + softirq;
|
||||||
|
|
||||||
@@ -166,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,
|
||||||
@@ -200,11 +201,11 @@ final class CpuBrand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final _bsdCpuPercentReg = RegExp(r'(\d+\.\d+)%');
|
final _bsdCpuPercentReg = RegExp(r'(\d+\.\d+)%');
|
||||||
final _macCpuPercentReg = RegExp(
|
final _macCpuPercentReg = RegExp(r'CPU usage: ([\d.]+)% user, ([\d.]+)% sys, ([\d.]+)% idle');
|
||||||
r'CPU usage: ([\d.]+)% user, ([\d.]+)% sys, ([\d.]+)% idle');
|
|
||||||
final _freebsdCpuPercentReg = RegExp(
|
final _freebsdCpuPercentReg = RegExp(
|
||||||
r'CPU: ([\d.]+)% user, ([\d.]+)% nice, ([\d.]+)% system, '
|
r'CPU: ([\d.]+)% user, ([\d.]+)% nice, ([\d.]+)% system, '
|
||||||
r'([\d.]+)% interrupt, ([\d.]+)% idle');
|
r'([\d.]+)% interrupt, ([\d.]+)% idle',
|
||||||
|
);
|
||||||
|
|
||||||
/// Parse CPU status on BSD system with support for different BSD variants
|
/// Parse CPU status on BSD system with support for different BSD variants
|
||||||
///
|
///
|
||||||
@@ -214,14 +215,14 @@ final _freebsdCpuPercentReg = RegExp(
|
|||||||
/// - Generic BSD: fallback to percentage extraction
|
/// - Generic BSD: fallback to percentage extraction
|
||||||
Cpus parseBsdCpu(String raw) {
|
Cpus parseBsdCpu(String raw) {
|
||||||
final init = InitStatus.cpus;
|
final init = InitStatus.cpus;
|
||||||
|
|
||||||
// Try macOS format first
|
// Try macOS format first
|
||||||
final macMatch = _macCpuPercentReg.firstMatch(raw);
|
final macMatch = _macCpuPercentReg.firstMatch(raw);
|
||||||
if (macMatch != null) {
|
if (macMatch != null) {
|
||||||
final userPercent = double.parse(macMatch.group(1)!).toInt();
|
final userPercent = double.parse(macMatch.group(1)!).toInt();
|
||||||
final sysPercent = double.parse(macMatch.group(2)!).toInt();
|
final sysPercent = double.parse(macMatch.group(2)!).toInt();
|
||||||
final idlePercent = double.parse(macMatch.group(3)!).toInt();
|
final idlePercent = double.parse(macMatch.group(3)!).toInt();
|
||||||
|
|
||||||
init.add([
|
init.add([
|
||||||
SingleCpuCore(
|
SingleCpuCore(
|
||||||
'cpu0',
|
'cpu0',
|
||||||
@@ -236,7 +237,7 @@ Cpus parseBsdCpu(String raw) {
|
|||||||
]);
|
]);
|
||||||
return init;
|
return init;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try FreeBSD format
|
// Try FreeBSD format
|
||||||
final freebsdMatch = _freebsdCpuPercentReg.firstMatch(raw);
|
final freebsdMatch = _freebsdCpuPercentReg.firstMatch(raw);
|
||||||
if (freebsdMatch != null) {
|
if (freebsdMatch != null) {
|
||||||
@@ -245,7 +246,7 @@ Cpus parseBsdCpu(String raw) {
|
|||||||
final sysPercent = double.parse(freebsdMatch.group(3)!).toInt();
|
final sysPercent = double.parse(freebsdMatch.group(3)!).toInt();
|
||||||
final irqPercent = double.parse(freebsdMatch.group(4)!).toInt();
|
final irqPercent = double.parse(freebsdMatch.group(4)!).toInt();
|
||||||
final idlePercent = double.parse(freebsdMatch.group(5)!).toInt();
|
final idlePercent = double.parse(freebsdMatch.group(5)!).toInt();
|
||||||
|
|
||||||
init.add([
|
init.add([
|
||||||
SingleCpuCore(
|
SingleCpuCore(
|
||||||
'cpu0',
|
'cpu0',
|
||||||
@@ -260,20 +261,28 @@ Cpus parseBsdCpu(String raw) {
|
|||||||
]);
|
]);
|
||||||
return init;
|
return init;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to generic percentage extraction
|
// Fallback to generic percentage extraction
|
||||||
final percents = _bsdCpuPercentReg
|
final percents = _bsdCpuPercentReg
|
||||||
.allMatches(raw)
|
.allMatches(raw)
|
||||||
.map((e) => double.parse(e.group(1) ?? '0'))
|
.map((e) {
|
||||||
|
final valueStr = e.group(1) ?? '0';
|
||||||
|
final value = double.tryParse(valueStr);
|
||||||
|
if (value == null) {
|
||||||
|
dprint('Warning: Failed to parse CPU percentage from "$valueStr"');
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
})
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
if (percents.length >= 3) {
|
if (percents.length >= 3) {
|
||||||
// Validate that percentages are reasonable (0-100 range)
|
// Validate that percentages are reasonable (0-100 range)
|
||||||
final validPercents = percents.where((p) => p >= 0 && p <= 100).toList();
|
final validPercents = percents.where((p) => p >= 0 && p <= 100).toList();
|
||||||
if (validPercents.length != percents.length) {
|
if (validPercents.length != percents.length) {
|
||||||
Loggers.app.warning('BSD CPU fallback parsing found invalid percentages in: $raw');
|
Loggers.app.warning('BSD CPU fallback parsing found invalid percentages in: $raw');
|
||||||
}
|
}
|
||||||
|
|
||||||
init.add([
|
init.add([
|
||||||
SingleCpuCore(
|
SingleCpuCore(
|
||||||
'cpu0',
|
'cpu0',
|
||||||
@@ -288,10 +297,12 @@ Cpus parseBsdCpu(String raw) {
|
|||||||
]);
|
]);
|
||||||
return init;
|
return init;
|
||||||
} else if (percents.isNotEmpty) {
|
} else if (percents.isNotEmpty) {
|
||||||
Loggers.app.warning('BSD CPU fallback parsing found ${percents.length} percentages (expected at least 3) in: $raw');
|
Loggers.app.warning(
|
||||||
|
'BSD CPU fallback parsing found ${percents.length} percentages (expected at least 3) in: $raw',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
Loggers.app.warning('BSD CPU fallback parsing found no percentages in: $raw');
|
Loggers.app.warning('BSD CPU fallback parsing found no percentages in: $raw');
|
||||||
}
|
}
|
||||||
|
|
||||||
return init;
|
return init;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ ServerCustom _$ServerCustomFromJson(Map<String, dynamic> json) => ServerCustom(
|
|||||||
|
|
||||||
Map<String, dynamic> _$ServerCustomToJson(ServerCustom instance) =>
|
Map<String, dynamic> _$ServerCustomToJson(ServerCustom instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
if (instance.pveAddr case final value?) 'pveAddr': value,
|
'pveAddr': ?instance.pveAddr,
|
||||||
'pveIgnoreCert': instance.pveIgnoreCert,
|
'pveIgnoreCert': instance.pveIgnoreCert,
|
||||||
if (instance.cmds case final value?) 'cmds': value,
|
'cmds': ?instance.cmds,
|
||||||
if (instance.preferTempDev case final value?) 'preferTempDev': value,
|
'preferTempDev': ?instance.preferTempDev,
|
||||||
if (instance.logoUrl case final value?) 'logoUrl': value,
|
'logoUrl': ?instance.logoUrl,
|
||||||
if (instance.netDev case final value?) 'netDev': value,
|
'netDev': ?instance.netDev,
|
||||||
if (instance.scriptDir case final value?) 'scriptDir': value,
|
'scriptDir': ?instance.scriptDir,
|
||||||
};
|
};
|
||||||
|
|||||||
49
lib/data/model/server/discovery_result.dart
Normal file
49
lib/data/model/server/discovery_result.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
830
lib/data/model/server/discovery_result.freezed.dart
Normal file
830
lib/data/model/server/discovery_result.freezed.dart
Normal 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
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user