mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2026-02-15 12:44:59 +01:00
Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
584af5423a | ||
|
|
95f8e571c1 | ||
|
|
9c9648656d | ||
|
|
6880bcc192 | ||
|
|
3a615449e3 | ||
|
|
46a12bc844 | ||
|
|
8d597294a4 | ||
|
|
682a6e4f2d | ||
|
|
8c3302cf0d | ||
|
|
ec4bf3df24 | ||
|
|
263d4eabb4 | ||
|
|
c6439673b8 | ||
|
|
a35d21981b | ||
|
|
dbc873c0c0 | ||
|
|
e69808a2f6 | ||
|
|
55b3ba63ec | ||
|
|
006e66d825 | ||
|
|
c556c0f1b5 | ||
|
|
c42c701ffc | ||
|
|
e6db2db320 | ||
|
|
66ecb02d9e | ||
|
|
8e7de604ee |
9
.github/workflows/analysis.yml
vendored
9
.github/workflows/analysis.yml
vendored
@@ -17,17 +17,16 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable' # or: 'beta', 'dev' or 'master'
|
||||
channel: 'stable'
|
||||
|
||||
- name: Install dependencies
|
||||
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.
|
||||
- name: Analyze project source
|
||||
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
|
||||
|
||||
29
.github/workflows/release.yml
vendored
29
.github/workflows/release.yml
vendored
@@ -9,6 +9,11 @@ on:
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
# Set by fl_build
|
||||
# env:
|
||||
# APP_NAME: ServerBox
|
||||
# BUILD_NUMBER: ${{ github.ref_name }}
|
||||
|
||||
jobs:
|
||||
releaseAndroid:
|
||||
name: Release android
|
||||
@@ -20,7 +25,7 @@ jobs:
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.32.1"
|
||||
flutter-version: "3.35.3"
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: "zulu"
|
||||
@@ -58,25 +63,17 @@ jobs:
|
||||
run: |
|
||||
sudo apt update
|
||||
# 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
|
||||
sudo apt install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libunwind-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
|
||||
sudo apt install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libunwind-dev libsecret-1-dev
|
||||
- name: Build
|
||||
run: |
|
||||
dart run fl_build
|
||||
dart run flutter_distributor:main package --platform=linux --target=appimage
|
||||
- name: Rename artifacts
|
||||
run: |
|
||||
appimage_name=$(ls dist/*/*.AppImage)
|
||||
mv $appimage_name ${{ env.APP_NAME }}_${{ env.BUILD_NUMBER }}_amd64.appimage
|
||||
dart run fl_build -p linux
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
${{ env.APP_NAME }}_${{ env.BUILD_NUMBER }}_amd64.appimage
|
||||
${{ env.APP_NAME }}_${{ env.BUILD_NUMBER }}_amd64.AppImage
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -106,16 +103,12 @@ jobs:
|
||||
# uses: actions/checkout@v4
|
||||
# - name: Install Flutter
|
||||
# uses: subosito/flutter-action@v2
|
||||
# with:
|
||||
# channel: 'stable'
|
||||
# flutter-version: '3.32.1'
|
||||
# - name: Build
|
||||
# run: dart run fl_build -p ios,mac
|
||||
# run: dart run fl_build -p ios
|
||||
# - name: Create Release
|
||||
# uses: softprops/action-gh-release@v2
|
||||
# with:
|
||||
# files: |
|
||||
# ${{ env.APP_NAME }}_universal_macos.zip
|
||||
# ${{ env.APP_NAME }}_universal.ipa
|
||||
# env:
|
||||
# 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
|
||||
Version 3, 29 June 2007
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
@@ -7,17 +7,15 @@
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
@@ -72,7 +60,7 @@ modification follow.
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
21
README.md
21
README.md
@@ -5,12 +5,12 @@ English | [简体中文](README_zh.md)
|
||||
<div align="center">
|
||||
<a href="https://cdn.lpkt.cn/donate"><img alt="donate" src="https://img.shields.io/badge/donate-me-pink"></a>
|
||||
<img alt="lang" src="https://img.shields.io/badge/lang-dart-cyan">
|
||||
<img alt="license" src="https://img.shields.io/badge/license-GPLv3-yellow">
|
||||
<img alt="license" src="https://img.shields.io/badge/license-AGPLv3-yellow">
|
||||
<a href="https://deepwiki.com/lollipopkit/flutter_server_box"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
A Flutter project which provide charts to display <a href="https://github.com/lollipopkit/flutter_server_box/issues/43">Linux</a> server status and tools to manage server.
|
||||
A Flutter project which provides charts to display Linux, Unix and Windows server status and tools to manage servers.
|
||||
<br>
|
||||
Especially thanks to <a href="https://github.com/TerminalStudio/dartssh2">dartssh2</a> & <a href="https://github.com/TerminalStudio/xterm.dart">xterm.dart</a>.
|
||||
</p>
|
||||
@@ -26,25 +26,26 @@ Especially thanks to <a href="https://github.com/TerminalStudio/dartssh2">dartss
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 📥 Install
|
||||
## 📥 Installation
|
||||
|
||||
| Platform | From |
|
||||
|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
|Platform| From|
|
||||
|--|--|
|
||||
| iOS / macOS | [AppStore](https://apps.apple.com/app/id1586449703) |
|
||||
| Android | [GitHub](https://github.com/lollipopkit/flutter_server_box/releases) / [CDN](https://cdn.lpkt.cn/serverbox/pkg/?sort=time&order=desc&layout=grid) / [F-Droid](https://f-droid.org/packages/tech.lolli.toolbox) / [OpenAPK](https://www.openapk.net/serverbox/tech.lolli.toolbox/) |
|
||||
| Linux / Windows | [GitHub](https://github.com/lollipopkit/flutter_server_box/releases) / [CDN](https://cdn.lpkt.cn/serverbox/pkg/?sort=time&order=desc&layout=grid) |
|
||||
|
||||
Please only download pkgs from the source that **you trust**!
|
||||
|
||||
## 🔖 Feature
|
||||
## 🔖 Features
|
||||
|
||||
- `Status chart` (CPU, Sensors, GPU...), `SSH` Term, `SFTP`, `Docker & Process & Systemd`...
|
||||
- `Status chart` (CPU, Sensors, GPU...), `SSH` Term, `SFTP`, `Docker & Process & Systemd`, `S.M.A.R.T`...
|
||||
- Platform specific: `Bio auth`、`Msg push`、`Home widget`、`watchOS App`...
|
||||
- English, 简体中文; Deutsch [@its-tom](https://github.com/its-tom), 繁體中文 [@kalashnikov](https://github.com/kalashnikov), Indonesian [@azkadev](https://github.com/azkadev), Français [@FrancXPT](https://github.com/FrancXPT), Dutch [@QazCetelic](https://github.com/QazCetelic), Türkçe [@mikropsoft](https://github.com/mikropsoft), Українська мова [@CakesTwix](https://github.com/CakesTwix); Español, Русский язык, Português, 日本語 (Generated by GPT)
|
||||
|
||||
## 🆘 Help
|
||||
|
||||
<div align="center">
|
||||
<a href="https://qm.qq.com/q/daCGa7eShG"><img alt="qq" src="https://img.shields.io/badge/QQ-Group-pink"></a>
|
||||
<a href="https://t.me/lpktg"><img alt="donate" src="https://img.shields.io/badge/Telegram-lpktg-green"></a>
|
||||
<a href="https://discord.gg/SsVNbRhK7w"><img alt="discord" src="https://img.shields.io/badge/Discord-lpkt-purple"></a>
|
||||
</div>
|
||||
@@ -60,10 +61,12 @@ Before you open an issue, please read the following:
|
||||
|
||||
After you read the above, you can open an [issue](https://github.com/lollipopkit/flutter_server_box/issues/new).
|
||||
|
||||
## 🧱 Contribution
|
||||
## 🧱 Contributions
|
||||
|
||||
Any positive contribution is welcome.
|
||||
|
||||
If I forgot to add your name to the contributors list, please add a comment in the issue or PR you opened to let me know, I will add it as soon as possible.
|
||||
|
||||
### Development
|
||||
|
||||
1. Setup [Flutter](https://flutter.dev/docs/get-started/install) environment.
|
||||
@@ -82,4 +85,4 @@ Any positive contribution is welcome.
|
||||
|
||||
## 📝 License
|
||||
|
||||
`GPL v3 lollipopkit`
|
||||
`AGPL v3 lollipopkit & all contributors`
|
||||
|
||||
29
README_zh.md
29
README_zh.md
@@ -5,18 +5,18 @@
|
||||
<div align="center">
|
||||
<a href="https://cdn.lpkt.cn/donate"><img alt="donate" src="https://img.shields.io/badge/捐赠-我-pink"></a>
|
||||
<img alt="语言" src="https://img.shields.io/badge/语言-dart-cyan">
|
||||
<img alt="license" src="https://img.shields.io/badge/证书-GPLv3-yellow">
|
||||
<img alt="license" src="https://img.shields.io/badge/证书-AGPLv3-yellow">
|
||||
<a href="https://deepwiki.com/lollipopkit/flutter_server_box"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
使用 Flutter 开发的 <a href="https://github.com/lollipopkit/flutter_server_box/issues/43">Linux</a> 服务器工具箱,提供服务器状态图表和管理工具。
|
||||
使用 Flutter 开发的 Linux, Unix, Windows 服务器工具箱,提供服务器状态图表和管理工具。
|
||||
<br>
|
||||
特别感谢 <a href="https://github.com/TerminalStudio/dartssh2">dartssh2</a> & <a href="https://github.com/TerminalStudio/xterm.dart">xterm.dart</a>。
|
||||
</p>
|
||||
|
||||
|
||||
## 🏙️ 截屏
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img width="200px" src="https://cdn.lpkt.cn/serverbox/screenshot/1.jpg"></td>
|
||||
@@ -26,20 +26,19 @@
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
## 📥 安装
|
||||
|
||||
平台 | 下载
|
||||
----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
平台|下载
|
||||
--|--
|
||||
iOS / macOS | [AppStore](https://apps.apple.com/app/id1586449703)
|
||||
Android | [GitHub](https://github.com/lollipopkit/flutter_server_box/releases) / [CDN](https://cdn.lpkt.cn/serverbox/pkg/?sort=time&order=desc&layout=grid) / [F-Droid](https://f-droid.org/packages/tech.lolli.toolbox) / [OpenAPK](https://www.openapk.net/serverbox/tech.lolli.toolbox/)
|
||||
Linux / Windows | [GitHub](https://github.com/lollipopkit/flutter_server_box/releases) / [CDN](https://cdn.lpkt.cn/serverbox/pkg/?sort=time&order=desc&layout=grid)
|
||||
|
||||
请从 **信任** 的来源下载!
|
||||
|
||||
|
||||
## 🔖 特点
|
||||
- `状态图表`(CPU、传感器、GPU 等), `SSH` 终端, `SFTP`, `Docker & 进程 & Systemd` 管理...
|
||||
|
||||
- `状态图表`(CPU、传感器、GPU 等), `SSH` 终端, `SFTP`, `Docker & 进程 & Systemd` 管理,`S.M.A.R.T`...
|
||||
- 特殊支持:`生物认证`、`推送`、`桌面小部件`、`watchOS App`、`跟随系统颜色`...
|
||||
- 本地化
|
||||
- English, 简体中文
|
||||
@@ -47,10 +46,10 @@ Linux / Windows | [GitHub](https://github.com/lollipopkit/flutter_server_box/rel
|
||||
- Deutsch [@its-tom](https://github.com/its-tom), 繁體中文 [@kalashnikov](https://github.com/kalashnikov), Indonesian [@azkadev](https://github.com/azkadev), Français [@FrancXPT](https://github.com/FrancXPT), Dutch [@QazCetelic](https://github.com/QazCetelic), Türkçe [@mikropsoft](https://github.com/mikropsoft), Українська мова [@CakesTwix](https://github.com/CakesTwix);
|
||||
- 感谢贡献者们!
|
||||
|
||||
|
||||
## 🆘 帮助
|
||||
|
||||
<div align="center">
|
||||
<a href="https://qm.qq.com/q/daCGa7eShG"><img alt="qq" src="https://img.shields.io/badge/QQ-群-pink"></a>
|
||||
<a href="https://t.me/lpktg"><img alt="donate" src="https://img.shields.io/badge/Telegram-lpktg-green"></a>
|
||||
<a href="https://discord.gg/SsVNbRhK7w"><img alt="discord" src="https://img.shields.io/badge/Discord-lpkt-purple"></a>
|
||||
</div>
|
||||
@@ -59,26 +58,32 @@ Linux / Windows | [GitHub](https://github.com/lollipopkit/flutter_server_box/rel
|
||||
- **常见问题** 可以在 [app wiki](https://github.com/lollipopkit/flutter_server_box/wiki/主页) 查看。
|
||||
|
||||
反馈前须知:
|
||||
|
||||
1. 反馈问题请附带 log(点击首页右上角),并以 bug 模版提交。
|
||||
2. 反馈问题前请检查是否是 serverbox 的问题。
|
||||
3. 欢迎所有有效、正面的反馈,主观(比如你觉得其他UI更好看)的反馈不一定会接受
|
||||
|
||||
|
||||
## 🧱 贡献
|
||||
|
||||
任何正面的贡献都欢迎。
|
||||
|
||||
如果我忘记在贡献者列表中添加你的名字,请在你打开的 issue 或 PR 中添加评论让我知道,我会尽快添加。
|
||||
|
||||
### 开发
|
||||
|
||||
1. 安装 [Flutter](https://flutter.dev/docs/get-started/install)
|
||||
2. 克隆这个仓库, 运行 `flutter run` 启动应用
|
||||
3. 运行 `dart run fl_build -p PLATFORM` 构建应用
|
||||
|
||||
### 翻译
|
||||
|
||||
[指南](https://blog.lpkt.cn/faq/) 可在我的博客中找到。
|
||||
|
||||
## 💡 我的其它 Apps
|
||||
|
||||
- [GPT Box](https://github.com/lollipopkit/flutter_gpt_box) - 支持 OpenAI API 的 第三方全平台客户端。
|
||||
- [更多](https://github.com/lollipopkit) - 工具 & etc.
|
||||
|
||||
|
||||
## 📝 协议
|
||||
`GPL v3 lollipopkit`
|
||||
|
||||
`AGPL v3 lollipopkit & 所有贡献者`
|
||||
|
||||
@@ -92,6 +92,13 @@ android {
|
||||
// No applicationIdSuffix or resValue here
|
||||
}
|
||||
}
|
||||
|
||||
dependenciesInfo {
|
||||
// Disables dependency metadata when building APKs.
|
||||
includeInApk = false
|
||||
// Disables dependency metadata when building Android App Bundles.
|
||||
includeInBundle = false
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
@@ -106,7 +113,7 @@ android.applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
def abiVersionCode = project.ext.abiCodes.get(output.getFilter(OutputFile.ABI))
|
||||
if (abiVersionCode != null) {
|
||||
output.versionCodeOverride = variant.versionCode * 10 + abiVersionCode
|
||||
output.versionCodeOverride = variant.versionCode * 100 + abiVersionCode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
android:label="@string/app_name"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:allowBackup="true"
|
||||
android:allowBackup="true"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:hasFragileUserData="true"
|
||||
android:restoreAnyVersion="true"
|
||||
tools:targetApi="q">
|
||||
@@ -45,6 +46,15 @@
|
||||
android:name="flutterEmbedding"
|
||||
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
|
||||
android:name=".widget.HomeWidget"
|
||||
android:exported="false"
|
||||
|
||||
@@ -2,14 +2,32 @@ package tech.lolli.toolbox
|
||||
|
||||
import android.app.*
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
|
||||
class ForegroundService : Service() {
|
||||
companion object {
|
||||
@Volatile
|
||||
var isRunning: Boolean = false
|
||||
}
|
||||
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) {
|
||||
Log.e("ForegroundService", message, error)
|
||||
@@ -26,48 +44,51 @@ class ForegroundService : Service() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d("ForegroundService", "Service onCreate")
|
||||
isRunning = true
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
try {
|
||||
// Check notification permission for Android 13+
|
||||
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", "Notification permission denied. Stopping service.")
|
||||
stopForegroundService()
|
||||
Log.w("ForegroundService", "Notification permission denied. Stopping service gracefully.")
|
||||
// Don't call stopForegroundService() here as we haven't started foreground yet
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
if (intent == null) {
|
||||
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
|
||||
}
|
||||
|
||||
val action = intent.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) {
|
||||
"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()
|
||||
START_NOT_STICKY
|
||||
}
|
||||
ACTION_UPDATE_SESSIONS -> {
|
||||
val payload = intent.getStringExtra("payload") ?: "{}"
|
||||
handleUpdateSessions(payload)
|
||||
START_STICKY
|
||||
}
|
||||
else -> {
|
||||
// Default bring up foreground with placeholder
|
||||
ensureForeground(createMergedNotification(0, emptyList(), emptyList()))
|
||||
START_STICKY
|
||||
}
|
||||
}
|
||||
@@ -85,68 +106,205 @@ class ForegroundService : Service() {
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
if (manager == null) {
|
||||
Log.e("ForegroundService", "Failed to get NotificationManager")
|
||||
try {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
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
|
||||
}
|
||||
val serviceChannel = NotificationChannel(
|
||||
chanId,
|
||||
"ForegroundServiceChannel",
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
).apply {
|
||||
description = "For foreground service"
|
||||
}
|
||||
manager.createNotificationChannel(serviceChannel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotification(): Notification {
|
||||
try {
|
||||
val notificationIntent = Intent(this, MainActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
notificationIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
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)
|
||||
if (!isFgStarted) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
isFgStarted = true
|
||||
Log.d("ForegroundService", "Foreground service started successfully")
|
||||
} 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")
|
||||
}
|
||||
}
|
||||
|
||||
return builder
|
||||
.setContentTitle("Server Box")
|
||||
.setContentText("Running in background")
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentIntent(pendingIntent)
|
||||
.addAction(android.R.drawable.ic_delete, "Stop", deletePendingIntent)
|
||||
.build()
|
||||
} catch (e: SecurityException) {
|
||||
logError("Security exception when starting foreground service (likely missing permission)", e)
|
||||
stopSelf()
|
||||
} catch (e: Exception) {
|
||||
logError("Error creating notification", e)
|
||||
// Return a basic notification as fallback
|
||||
return Notification.Builder(this)
|
||||
.setContentTitle("Server Box")
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.build()
|
||||
logError("Failed to start/update foreground", e)
|
||||
// Don't stop the service for other exceptions, just log them
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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() {
|
||||
try {
|
||||
stopForeground(true)
|
||||
if (isFgStarted) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
isFgStarted = false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logError("Error stopping foreground", e)
|
||||
}
|
||||
@@ -157,5 +315,6 @@ class ForegroundService : Service() {
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
Log.d("ForegroundService", "Service onDestroy")
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.Manifest
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.IntentFilter
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
@@ -13,20 +16,34 @@ import android.appwidget.AppWidgetManager
|
||||
import tech.lolli.toolbox.widget.HomeWidget
|
||||
|
||||
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) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
val binaryMessenger = flutterEngine.dartExecutor.binaryMessenger
|
||||
|
||||
MethodChannel(binaryMessenger, "tech.lolli.toolbox/main_chan").apply {
|
||||
setMethodCallHandler { method, result ->
|
||||
channel = MethodChannel(binaryMessenger, "tech.lolli.toolbox/main_chan")
|
||||
channel.setMethodCallHandler { method, result ->
|
||||
when (method.method) {
|
||||
"sendToBackground" -> {
|
||||
moveTaskToBack(true)
|
||||
result.success(null)
|
||||
}
|
||||
"isServiceRunning" -> {
|
||||
result.success(ForegroundService.isRunning)
|
||||
}
|
||||
"startService" -> {
|
||||
try {
|
||||
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)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(serviceIntent)
|
||||
@@ -51,31 +68,138 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
sendBroadcast(intent)
|
||||
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 -> {
|
||||
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() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||
|
||||
// Check if we already have the permission to avoid unnecessary prompts
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
try {
|
||||
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 setupStopAllReceiver() {
|
||||
stopAllReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == ACTION_STOP_ALL_CONNECTIONS && ::channel.isInitialized) {
|
||||
try {
|
||||
channel.invokeMethod("stopAllConnections", null)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("MainActivity", "Failed to invoke stopAllConnections: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter(ACTION_STOP_ALL_CONNECTIONS)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
ContextCompat.registerReceiver(this, stopAllReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
registerReceiver(stopAllReceiver, filter)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == 123) {
|
||||
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
android.util.Log.i("MainActivity", "Notification permission granted")
|
||||
} else {
|
||||
android.util.Log.w("MainActivity", "Notification permission denied")
|
||||
// Optionally inform user about the limitation
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
stopAllReceiver?.let {
|
||||
try {
|
||||
unregisterReceiver(it)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("MainActivity", "Failed to unregister receiver: ${e.message}")
|
||||
}
|
||||
stopAllReceiver = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,24 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.json.JSONObject
|
||||
import org.json.JSONException
|
||||
import tech.lolli.toolbox.R
|
||||
import java.net.URL
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.SocketTimeoutException
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
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) {
|
||||
for (appWidgetId in appWidgetIds) {
|
||||
updateAppWidget(context, appWidgetManager, appWidgetId)
|
||||
@@ -27,105 +38,184 @@ class HomeWidget : AppWidgetProvider() {
|
||||
}
|
||||
|
||||
private fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) {
|
||||
val views = RemoteViews(context.packageName, R.layout.home_widget)
|
||||
val sp = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)
|
||||
var url = sp.getString("widget_$appWidgetId", null)
|
||||
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)
|
||||
// Prevent concurrent updates for the same widget
|
||||
if (activeUpdates.putIfAbsent(appWidgetId, true) == true) {
|
||||
Log.d(TAG, "Widget $appWidgetId is already updating, skipping")
|
||||
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 {
|
||||
try {
|
||||
val connection = URL(url).openConnection() as HttpURLConnection
|
||||
connection.requestMethod = "GET"
|
||||
val responseCode = connection.responseCode
|
||||
if (responseCode == HttpURLConnection.HTTP_OK) {
|
||||
val jsonStr = connection.inputStream.bufferedReader().use { it.readText() }
|
||||
val jsonObject = JSONObject(jsonStr)
|
||||
val data = jsonObject.getJSONObject("data")
|
||||
val server = data.getString("name")
|
||||
val cpu = data.getString("cpu")
|
||||
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
|
||||
withTimeoutOrNull(COROUTINE_TIMEOUT) {
|
||||
try {
|
||||
val serverData = fetchServerData(url)
|
||||
if (serverData != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
showSuccessState(views, appWidgetManager, appWidgetId, serverData)
|
||||
}
|
||||
} else {
|
||||
withContext(Dispatchers.Main) {
|
||||
showErrorState(views, appWidgetManager, appWidgetId, "Invalid server data received.")
|
||||
}
|
||||
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 {
|
||||
throw FileNotFoundException("HTTP response code: $responseCode")
|
||||
} catch (e: Exception) {
|
||||
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) {
|
||||
Log.e("HomeWidget", "Error updating widget: ${e.localizedMessage}", e)
|
||||
} ?: run {
|
||||
Log.w(TAG, "Widget update timed out for widget $appWidgetId")
|
||||
withContext(Dispatchers.Main) {
|
||||
views.setTextViewText(R.id.widget_name, "Error")
|
||||
// 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)
|
||||
showErrorState(views, appWidgetManager, appWidgetId, "Update timed out. Please try again.")
|
||||
}
|
||||
}
|
||||
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
|
||||
android:id="@+id/widget_name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/widgetText"
|
||||
android:textSize="23sp"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:alpha="0"
|
||||
android:animateLayoutChanges="true"
|
||||
android:fadingEdge="horizontal"
|
||||
android:singleLine="true"
|
||||
tools:text="Server Name" />
|
||||
|
||||
<!-- Wrap the content in a LinearLayout for easy visibility management -->
|
||||
@@ -27,121 +30,138 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_below="@id/widget_name"
|
||||
android:paddingTop="13dp">
|
||||
android:layout_marginTop="8dp">
|
||||
|
||||
<RelativeLayout
|
||||
android:id="@+id/widget_container_inner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingTop="13dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:animateLayoutChanges="true">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/widget_cpu_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingBottom="2.7dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
android:orientation="horizontal"
|
||||
android:alpha="0"
|
||||
android:animateLayoutChanges="true">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="17dp"
|
||||
android:layout_height="17dp"
|
||||
android:src="@drawable/speed_24">
|
||||
</ImageView>
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:src="@drawable/speed_24"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:contentDescription="CPU usage" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_cpu"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="11dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="8dp"
|
||||
android:singleLine="true"
|
||||
android:ellipsize = "marquee"
|
||||
android:ellipsize="end"
|
||||
android:textColor="@color/widgetSummaryText"
|
||||
android:textSize="12.7sp"
|
||||
tools:text="CPU" />
|
||||
android:textSize="12sp"
|
||||
tools:text="CPU: 25.6%" />
|
||||
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/widget_mem_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingBottom="2.7dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:layout_below="@id/widget_cpu_label"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
android:orientation="horizontal"
|
||||
android:alpha="0"
|
||||
android:animateLayoutChanges="true">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="17dp"
|
||||
android:layout_height="17dp"
|
||||
android:src="@drawable/memory_24">
|
||||
</ImageView>
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:src="@drawable/memory_24"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:contentDescription="Memory usage" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_mem"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="11dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="8dp"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:textColor="@color/widgetSummaryText"
|
||||
android:textSize="12.7sp"
|
||||
tools:text="Mem" />
|
||||
android:textSize="12sp"
|
||||
tools:text="Memory: 4.2GB / 8GB" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/widget_disk_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingBottom="2.7dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:layout_below="@id/widget_mem_label"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
android:orientation="horizontal"
|
||||
android:alpha="0"
|
||||
android:animateLayoutChanges="true">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="17dp"
|
||||
android:layout_height="17dp"
|
||||
android:src="@drawable/storage_24">
|
||||
</ImageView>
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:src="@drawable/storage_24"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:contentDescription="Disk usage" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_disk"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="11dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="8dp"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:textColor="@color/widgetSummaryText"
|
||||
android:textSize="12.7sp"
|
||||
tools:text="Disk" />
|
||||
android:textSize="12sp"
|
||||
tools:text="Disk: 125GB / 250GB" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/widget_net_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_below="@id/widget_disk_label"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
android:orientation="horizontal"
|
||||
android:alpha="0"
|
||||
android:animateLayoutChanges="true">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="17dp"
|
||||
android:layout_height="17dp"
|
||||
android:src="@drawable/net_24">
|
||||
</ImageView>
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:src="@drawable/net_24"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:contentDescription="Network usage" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_net"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="11dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="8dp"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:textColor="@color/widgetSummaryText"
|
||||
android:textSize="12.7sp"
|
||||
tools:text="Net" />
|
||||
android:textSize="12sp"
|
||||
tools:text="Network: 15MB/s ↓ 8MB/s ↑" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -149,29 +169,45 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Add a TextView for error messages -->
|
||||
<!-- Error message display -->
|
||||
<TextView
|
||||
android:id="@+id/error_message"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_below="@id/widget_name"
|
||||
android:layout_marginTop="8dp"
|
||||
android:textColor="@color/widgetSummaryText"
|
||||
android:textSize="12sp"
|
||||
android:textSize="11sp"
|
||||
android:visibility="gone"
|
||||
android:alpha="0"
|
||||
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
|
||||
android:id="@+id/widget_time"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:maxLines="2"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:maxLines="1"
|
||||
android:textColor="@color/widgetSummaryText"
|
||||
android:textSize="11sp"
|
||||
android:textSize="10sp"
|
||||
android:alpha="0"
|
||||
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>
|
||||
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>
|
||||
4
android/app/src/main/res/xml/backup_rules.xml
Normal file
4
android/app/src/main/res/xml/backup_rules.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<full-backup-content>
|
||||
<exclude domain="sharedpref" path="FlutterSecureStorage"/>
|
||||
</full-backup-content>
|
||||
@@ -6,6 +6,7 @@
|
||||
android:minHeight="110dp"
|
||||
android:updatePeriodMillis="1800001"
|
||||
android:initialLayout="@layout/home_widget"
|
||||
android:configure="tech.lolli.toolbox.widget.WidgetConfigureActivity"
|
||||
android:resizeMode="none"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
6505
coverage/lcov.info
Normal file
6505
coverage/lcov.info
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -21,6 +21,6 @@
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>12.0</string>
|
||||
<string>13.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# 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.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
PODS:
|
||||
- app_links (0.0.2):
|
||||
- app_links (6.4.1):
|
||||
- Flutter
|
||||
- camera_avfoundation (0.0.1):
|
||||
- Flutter
|
||||
@@ -8,6 +8,8 @@ PODS:
|
||||
- Flutter (1.0.0)
|
||||
- flutter_native_splash (2.4.3):
|
||||
- Flutter
|
||||
- flutter_secure_storage (6.0.0):
|
||||
- Flutter
|
||||
- icloud_storage (0.0.1):
|
||||
- Flutter
|
||||
- local_auth_darwin (0.0.1):
|
||||
@@ -38,6 +40,7 @@ DEPENDENCIES:
|
||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||
- icloud_storage (from `.symlinks/plugins/icloud_storage/ios`)
|
||||
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
@@ -60,6 +63,8 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter
|
||||
flutter_native_splash:
|
||||
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
||||
flutter_secure_storage:
|
||||
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
||||
icloud_storage:
|
||||
:path: ".symlinks/plugins/icloud_storage/ios"
|
||||
local_auth_darwin:
|
||||
@@ -82,13 +87,14 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/watch_connectivity/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7
|
||||
app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a
|
||||
camera_avfoundation: be3be85408cd4126f250386828e9b1dfa40ab436
|
||||
file_picker: fb04e739ae6239a76ce1f571863a196a922c87d4
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
||||
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
||||
icloud_storage: e55639f0c0d7cb2b0ba9c0b3d5968ccca9cd9aa2
|
||||
local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391
|
||||
local_auth_darwin: d2e8c53ef0c4f43c646462e3415432c4dab3ae19
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||
plain_notification_token: 047876b9d80a5b93565ddcc13a487a7e7b906f7d
|
||||
@@ -98,6 +104,6 @@ SPEC CHECKSUMS:
|
||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||
watch_connectivity: 88e5bea25b473e66ef8d3f960954d154ed0356d6
|
||||
|
||||
PODFILE CHECKSUM: ec6ef69056f066e8b21a3391082f23b5ad2d37f8
|
||||
PODFILE CHECKSUM: 5a0fb6438066e44ab2c77bd223668d351b8d8461
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
/* Begin PBXBuildFile section */
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||
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 */; };
|
||||
7538AEC32BB83FAB002AB82A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7538AEC22BB83FAB002AB82A /* 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 */; };
|
||||
E3AE8AEC2AB601DB000A6459 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AE8AE92AB601DB000A6459 /* Utils.swift */; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -95,6 +101,10 @@
|
||||
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; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -156,6 +166,26 @@
|
||||
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>"; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -233,6 +263,7 @@
|
||||
97C146F01CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F0A1B2C31A2B3C4D5E6F0001 /* Localizable.strings */,
|
||||
7538AEC22BB83FAB002AB82A /* PrivacyInfo.xcprivacy */,
|
||||
E398BF6A29BDB34500FE4FD5 /* Runner.entitlements */,
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||
@@ -242,6 +273,8 @@
|
||||
E39A76AD2AB9A2F70067C641 /* Info-Profile.plist */,
|
||||
E39A76AC2AB9A2F70067C641 /* Info-Release.plist */,
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||
4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */,
|
||||
4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */,
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
E3AE8AE92AB601DB000A6459 /* Utils.swift */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
@@ -263,8 +296,11 @@
|
||||
E33A3E3A2A626DCE009744AB /* StatusWidget */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F0A1B2C31A2B3C4D5E6F1001 /* Localizable.strings */,
|
||||
7538AEC42BB83FC8002AB82A /* PrivacyInfo.xcprivacy */,
|
||||
E33A3E3B2A626DCE009744AB /* StatusWidgetBundle.swift */,
|
||||
4A2DCD6D2E4B128100CF68B7 /* TerminalLiveActivity.swift */,
|
||||
4A2DCD6E2E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift */,
|
||||
E33A3E3F2A626DCE009744AB /* StatusWidget.swift */,
|
||||
E37C48ED2B9C30EE00E542D2 /* StatusWidget.intentdefinition */,
|
||||
E33A3E442A626DD0009744AB /* Info.plist */,
|
||||
@@ -412,6 +448,7 @@
|
||||
E39A76B02AB9A2F70067C641 /* Info-Profile.plist in Resources */,
|
||||
7538AEC32BB83FAB002AB82A /* PrivacyInfo.xcprivacy in Resources */,
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||
F0A1B2C31A2B3C4D5E6F0005 /* Localizable.strings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -420,6 +457,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
7538AEC52BB83FC8002AB82A /* PrivacyInfo.xcprivacy in Resources */,
|
||||
F0A1B2C31A2B3C4D5E6F1005 /* Localizable.strings (StatusWidget) in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -516,6 +554,8 @@
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
E37C48EA2B9C30EE00E542D2 /* StatusWidget.intentdefinition in Sources */,
|
||||
E3AE8AEA2AB601DB000A6459 /* Utils.swift in Sources */,
|
||||
4A2DCD6B2E4B127100CF68B7 /* LiveActivityManager.swift in Sources */,
|
||||
4A2DCD6C2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -525,6 +565,8 @@
|
||||
files = (
|
||||
E33A3E402A626DCE009744AB /* StatusWidget.swift in Sources */,
|
||||
E37C48EB2B9C30EE00E542D2 /* StatusWidget.intentdefinition in Sources */,
|
||||
4A2DCD6F2E4B128100CF68B7 /* TerminalLiveActivity.swift in Sources */,
|
||||
4A2DCD702E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */,
|
||||
E33A3E3C2A626DCE009744AB /* StatusWidgetBundle.swift in Sources */,
|
||||
E3AE8AEB2AB601DB000A6459 /* Utils.swift in Sources */,
|
||||
);
|
||||
@@ -610,6 +652,40 @@
|
||||
name = LaunchScreen.storyboard;
|
||||
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 */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
@@ -655,7 +731,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
@@ -672,17 +748,17 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 1184;
|
||||
CURRENT_PROJECT_VERSION = 1270;
|
||||
DEVELOPMENT_TEAM = BA88US33G6;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1184;
|
||||
MARKETING_VERSION = 1.0.1270;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
@@ -739,7 +815,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -789,7 +865,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
@@ -808,17 +884,17 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 1184;
|
||||
CURRENT_PROJECT_VERSION = 1270;
|
||||
DEVELOPMENT_TEAM = BA88US33G6;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1184;
|
||||
MARKETING_VERSION = 1.0.1270;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
@@ -836,17 +912,17 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 1184;
|
||||
CURRENT_PROJECT_VERSION = 1270;
|
||||
DEVELOPMENT_TEAM = BA88US33G6;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1184;
|
||||
MARKETING_VERSION = 1.0.1270;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
@@ -867,7 +943,7 @@
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1184;
|
||||
CURRENT_PROJECT_VERSION = 1270;
|
||||
DEVELOPMENT_TEAM = BA88US33G6;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -880,7 +956,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1184;
|
||||
MARKETING_VERSION = 1.0.1270;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
|
||||
@@ -906,7 +982,7 @@
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1184;
|
||||
CURRENT_PROJECT_VERSION = 1270;
|
||||
DEVELOPMENT_TEAM = BA88US33G6;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -919,7 +995,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1184;
|
||||
MARKETING_VERSION = 1.0.1270;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -942,7 +1018,7 @@
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1184;
|
||||
CURRENT_PROJECT_VERSION = 1270;
|
||||
DEVELOPMENT_TEAM = BA88US33G6;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -955,7 +1031,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1184;
|
||||
MARKETING_VERSION = 1.0.1270;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -978,7 +1054,7 @@
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1184;
|
||||
CURRENT_PROJECT_VERSION = 1270;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = BA88US33G6;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -990,7 +1066,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1184;
|
||||
MARKETING_VERSION = 1.0.1270;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
|
||||
@@ -1019,7 +1095,7 @@
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1184;
|
||||
CURRENT_PROJECT_VERSION = 1270;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = BA88US33G6;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -1031,7 +1107,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1184;
|
||||
MARKETING_VERSION = 1.0.1270;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
|
||||
PRODUCT_NAME = ServerBox;
|
||||
@@ -1057,7 +1133,7 @@
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1184;
|
||||
CURRENT_PROJECT_VERSION = 1270;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = BA88US33G6;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -1069,7 +1145,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1184;
|
||||
MARKETING_VERSION = 1.0.1270;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
|
||||
PRODUCT_NAME = ServerBox;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import UIKit
|
||||
import WidgetKit
|
||||
import Flutter
|
||||
import ActivityKit
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
@@ -11,14 +12,48 @@ import Flutter
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
|
||||
let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
|
||||
let methodChannel = FlutterMethodChannel(name: "tech.lolli.toolbox/home_widget", binaryMessenger: controller.binaryMessenger)
|
||||
methodChannel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
|
||||
// Home widget channel (legacy)
|
||||
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 #available(iOS 14.0, *) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -30,4 +65,11 @@ import Flutter
|
||||
}
|
||||
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>
|
||||
<string>ConfigurationIntent</string>
|
||||
</array>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true />
|
||||
<key>UIBackgroundModes</key>
|
||||
@@ -78,4 +80,4 @@
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Get QR code and etc.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
</plist>
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
<string>en</string>
|
||||
<string>zh</string>
|
||||
</array>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>CFBundleName</key>
|
||||
<string>ServerBox</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
<string>en</string>
|
||||
<string>zh</string>
|
||||
</array>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>CFBundleName</key>
|
||||
<string>ServerBox</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
@@ -68,4 +70,4 @@
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Get QR code and etc.</string>
|
||||
</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>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>IntentsSupportedIntents</key>
|
||||
<array>
|
||||
<string>ConfigurationIntent</string>
|
||||
</array>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</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 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 {
|
||||
func placeholder(in context: Context) -> SimpleEntry {
|
||||
SimpleEntry(date: Date(), configuration: ConfigurationIntent(), state: .normal(demoStatus))
|
||||
@@ -29,11 +165,13 @@ struct Provider: IntentTimelineProvider {
|
||||
var url = configuration.url
|
||||
|
||||
let family = context.family
|
||||
#if os(iOS)
|
||||
if #available(iOSApplicationExtension 16.0, *) {
|
||||
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 refreshDate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)!
|
||||
@@ -111,7 +249,7 @@ struct StatusWidgetEntryView : View {
|
||||
Button(intent: RefreshIntent()) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.resizable()
|
||||
.frame(width: 10, height: 12.7)
|
||||
.frame(width: WidgetConstants.Dimensions.refreshIconSmall, height: WidgetConstants.Dimensions.refreshIconSmall * 1.27)
|
||||
}.tint(.gray)
|
||||
}
|
||||
}
|
||||
@@ -123,6 +261,37 @@ struct StatusWidgetEntryView : View {
|
||||
case .normal(let data):
|
||||
let sumColor: Color = .primary.opacity(0.7)
|
||||
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:
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
@@ -142,6 +311,7 @@ struct StatusWidgetEntryView : View {
|
||||
.widgetBackground()
|
||||
case .accessoryInline:
|
||||
Text("\(data.name) \(data.cpu)").widgetBackground()
|
||||
#endif
|
||||
default:
|
||||
VStack(alignment: .leading, spacing: 3.7) {
|
||||
if #available(iOS 17.0, *) {
|
||||
@@ -151,7 +321,7 @@ struct StatusWidgetEntryView : View {
|
||||
Button(intent: RefreshIntent()) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.resizable()
|
||||
.frame(width: 10, height: 12.7)
|
||||
.frame(width: WidgetConstants.Dimensions.refreshIconSmall, height: WidgetConstants.Dimensions.refreshIconSmall * 1.27)
|
||||
}.tint(.gray)
|
||||
}
|
||||
} else {
|
||||
@@ -162,9 +332,6 @@ struct StatusWidgetEntryView : View {
|
||||
DetailItem(icon: "memorychip", text: data.mem, color: sumColor)
|
||||
DetailItem(icon: "externaldrive", text: data.disk, 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)
|
||||
.autoPadding()
|
||||
@@ -177,8 +344,16 @@ struct StatusWidgetEntryView : View {
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func widgetBackground() -> some View {
|
||||
// Set bg to black in Night, white in Day
|
||||
let backgroundView = Color(bgColor.resolve())
|
||||
// Modern card-style background with subtle effects
|
||||
let backgroundView = LinearGradient(
|
||||
gradient: Gradient(colors: [
|
||||
Color(bgColor.resolve()),
|
||||
Color(bgColor.resolve()).opacity(0.95)
|
||||
]),
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
if #available(iOS 17.0, *) {
|
||||
containerBackground(for: .widget) {
|
||||
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 {
|
||||
if #available(iOS 17.0, *) {
|
||||
return self
|
||||
return self.padding(.all, WidgetConstants.Spacing.tight)
|
||||
} 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 {
|
||||
@@ -207,11 +397,15 @@ struct StatusWidget: Widget {
|
||||
}
|
||||
.configurationDisplayName("Status")
|
||||
.description("Status of your servers.")
|
||||
if #available(iOSApplicationExtension 16.0, *) {
|
||||
return cfg.supportedFamilies([.systemSmall, .accessoryRectangular, .accessoryInline])
|
||||
#if os(iOS)
|
||||
if #available(iOSApplicationExtension 16.0, *) {
|
||||
return cfg.supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular, .accessoryInline])
|
||||
} 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
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6.7) {
|
||||
Image(systemName: icon).resizable().foregroundColor(color).frame(width: 11, height: 11, alignment: .center)
|
||||
HStack(spacing: WidgetConstants.Spacing.normal) {
|
||||
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)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.font(.system(size: 12, weight: .medium, design: .rounded))
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 空心圆,显示百分比
|
||||
struct CirclePercent: View {
|
||||
// eg: 31.7%
|
||||
let percent: String
|
||||
// Modern gauge tile with enhanced visual design
|
||||
struct GaugeTile: View {
|
||||
let label: 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 {
|
||||
// 31.7% -> 0.317
|
||||
let percentD = Double(percent.trimmingCharacters(in: .init(charactersIn: "%")))
|
||||
let double = (percentD ?? 0) / 100
|
||||
Circle()
|
||||
.trim(from: 0, to: CGFloat(double))
|
||||
.stroke(Color.primary, lineWidth: 3)
|
||||
.animation(.easeInOut(duration: 0.5))
|
||||
VStack(spacing: WidgetConstants.Spacing.normal) {
|
||||
ZStack {
|
||||
// Background circle with subtle shadow effect
|
||||
Circle()
|
||||
.stroke(Color.primary.opacity(0.1), lineWidth: 4)
|
||||
.background(
|
||||
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 {
|
||||
let dark: UIColor
|
||||
let light: UIColor
|
||||
|
||||
@@ -12,5 +12,8 @@ import SwiftUI
|
||||
struct StatusWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
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 {
|
||||
@ObservedObject var _mgr = PhoneConnMgr()
|
||||
@State private var selection: Int = 0
|
||||
@State private var refreshAllCounter: Int = 0
|
||||
|
||||
var body: some View {
|
||||
let _count = _mgr.urls.count == 0 ? 1 : _mgr.urls.count
|
||||
TabView {
|
||||
ForEach(0 ..< _count, id:\.self) { index in
|
||||
let url = _count == 1 && _mgr.urls.count == 0 ? nil : _mgr.urls[index]
|
||||
PageView(url: url, state: .loading)
|
||||
let hasServers = !_mgr.urls.isEmpty
|
||||
let pagesCount = hasServers ? _mgr.urls.count : 1
|
||||
TabView(selection: $selection) {
|
||||
ForEach(0 ..< pagesCount, id:\.self) { index in
|
||||
let url = hasServers ? _mgr.urls[index] : nil
|
||||
PageView(
|
||||
url: url,
|
||||
state: .loading,
|
||||
refreshAllCounter: refreshAllCounter,
|
||||
onRefreshAll: { refreshAllCounter += 1 }
|
||||
)
|
||||
.tag(index)
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
let url: String?
|
||||
@State var state: ContentState
|
||||
// 触发所有页面刷新的计数器
|
||||
let refreshAllCounter: Int
|
||||
let onRefreshAll: () -> Void
|
||||
|
||||
var body: some View {
|
||||
if url == nil {
|
||||
@@ -36,35 +76,50 @@ struct PageView: View {
|
||||
Spacer()
|
||||
}
|
||||
} else {
|
||||
switch state {
|
||||
case .loading:
|
||||
ProgressView().padding().onAppear {
|
||||
getStatus(url: url!)
|
||||
}
|
||||
case .error(let err):
|
||||
Group {
|
||||
switch state {
|
||||
case .loading:
|
||||
ProgressView().padding().onAppear {
|
||||
getStatus(url: url!)
|
||||
}
|
||||
case .error(let err):
|
||||
switch err {
|
||||
case .http(let description):
|
||||
VStack(alignment: .center) {
|
||||
Text(description)
|
||||
Button(action: {
|
||||
state = .loading
|
||||
}){
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}.buttonStyle(.plain)
|
||||
HStack(spacing: 10) {
|
||||
Button(action: {
|
||||
state = .loading
|
||||
}){
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}.buttonStyle(.plain)
|
||||
Button(action: {
|
||||
onRefreshAll()
|
||||
}){
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
}.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
case .url(_):
|
||||
Link("View help", destination: helpUrl)
|
||||
}
|
||||
case .normal(let status):
|
||||
VStack(alignment: .leading) {
|
||||
case .normal(let status):
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Text(status.name).font(.system(.title, design: .monospaced))
|
||||
Spacer()
|
||||
Button(action: {
|
||||
state = .loading
|
||||
}){
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}.buttonStyle(.plain)
|
||||
HStack(spacing: 10) {
|
||||
Button(action: {
|
||||
state = .loading
|
||||
}){
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}.buttonStyle(.plain)
|
||||
Button(action: {
|
||||
onRefreshAll()
|
||||
}){
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
}.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
DetailItem(icon: "cpu", text: status.cpu)
|
||||
@@ -72,6 +127,12 @@ struct PageView: View {
|
||||
DetailItem(icon: "externaldrive", text: status.disk)
|
||||
DetailItem(icon: "network", text: status.net)
|
||||
}.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
|
||||
}
|
||||
let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
|
||||
guard error == nil else {
|
||||
state = .error(.http(error!.localizedDescription))
|
||||
// 所有 UI 状态更新必须在主线程执行,否则可能导致 TabView 跳回第一页等问题
|
||||
func setStateOnMain(_ newState: ContentState) {
|
||||
DispatchQueue.main.async {
|
||||
self.state = newState
|
||||
}
|
||||
}
|
||||
|
||||
if let error = error {
|
||||
setStateOnMain(.error(.http(error.localizedDescription)))
|
||||
return
|
||||
}
|
||||
guard let data = data else {
|
||||
state = .error(.http("empty data"))
|
||||
setStateOnMain(.error(.http("empty data")))
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
guard let code = jsonAll["code"] as? Int else {
|
||||
state = .error(.http("code is nil"))
|
||||
setStateOnMain(.error(.http("code is nil")))
|
||||
return
|
||||
}
|
||||
if (code != 0) {
|
||||
let msg = jsonAll["msg"] as? String ?? ""
|
||||
state = .error(.http(msg))
|
||||
setStateOnMain(.error(.http(msg)))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -115,10 +183,35 @@ struct PageView: View {
|
||||
let cpu = json["cpu"] as? String ?? ""
|
||||
let mem = json["mem"] 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()
|
||||
}
|
||||
|
||||
// 监听“刷新全部”触发器变化,主动刷新当前页
|
||||
@ViewBuilder
|
||||
var _onRefreshAllHook: some View {
|
||||
EmptyView()
|
||||
.onChange(of: refreshAllCounter) { _ in
|
||||
if let url = url {
|
||||
getStatus(url: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView_Previews: PreviewProvider {
|
||||
|
||||
@@ -14,13 +14,20 @@ class PhoneConnMgr: NSObject, WCSessionDelegate, ObservableObject {
|
||||
set {
|
||||
Store.setCtx(newValue)
|
||||
updateUrls(newValue)
|
||||
|
||||
// Notify the view to update, but the [urls] are already published
|
||||
// so the view will automatically update when [urls] changes.
|
||||
// DispatchQueue.main.async {
|
||||
// self.objectWillChange.send()
|
||||
// }
|
||||
}
|
||||
get {
|
||||
return _ctx
|
||||
}
|
||||
}
|
||||
var userInfo: [String: Any] = [:]
|
||||
@Published var urls: [String] = []
|
||||
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
if !WCSession.isSupported() {
|
||||
@@ -29,24 +36,91 @@ class PhoneConnMgr: NSObject, WCSessionDelegate, ObservableObject {
|
||||
session = WCSession.default
|
||||
session?.delegate = self
|
||||
session?.activate()
|
||||
|
||||
ctx = Store.getCtx()
|
||||
|
||||
_ctx = Store.getCtx()
|
||||
updateUrls(_ctx)
|
||||
}
|
||||
|
||||
|
||||
func updateUrls(_ val: [String: Any]) {
|
||||
if let urls = val["urls"] as? [String] {
|
||||
self.urls = urls.filter { !$0.isEmpty }
|
||||
DispatchQueue.main.async {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
||||
|
||||
|
||||
func session(
|
||||
_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState,
|
||||
error: Error?
|
||||
) {
|
||||
// Request latest data when the session is activated
|
||||
if activationState == .activated {
|
||||
requestLatestData()
|
||||
}
|
||||
}
|
||||
|
||||
// implement session:didReceiveApplicationContext:
|
||||
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) {
|
||||
ctx = applicationContext
|
||||
// Receive realtime msgs
|
||||
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
DispatchQueue.main.async {
|
||||
self.ctx = message
|
||||
}
|
||||
}
|
||||
|
||||
// Receive UserInfo
|
||||
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
|
||||
DispatchQueue.main.async {
|
||||
self.ctx = userInfo
|
||||
}
|
||||
}
|
||||
|
||||
// Receive Application Context
|
||||
func session(
|
||||
_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]
|
||||
) {
|
||||
DispatchQueue.main.async {
|
||||
self.ctx = applicationContext
|
||||
}
|
||||
}
|
||||
|
||||
private func requestLatestData(timeout: TimeInterval = 5.0, maxRetries: Int = 1) {
|
||||
guard let session = session, session.isReachable else { return }
|
||||
|
||||
var didReceiveResponse = false
|
||||
var retries = 0
|
||||
|
||||
func sendRequest() {
|
||||
session.sendMessage(["action": "requestData"]) { response in
|
||||
didReceiveResponse = true
|
||||
DispatchQueue.main.async {
|
||||
self.ctx = response
|
||||
}
|
||||
} errorHandler: { error in
|
||||
print("Request data failed: \(error)")
|
||||
// Optionally, handle error UI here
|
||||
}
|
||||
|
||||
// Timeout handling
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if !didReceiveResponse {
|
||||
if retries < maxRetries {
|
||||
retries += 1
|
||||
print("No response, retrying requestLatestData (\(retries))...")
|
||||
sendRequest()
|
||||
} else {
|
||||
print("Request data timed out after \(retries + 1) attempts.")
|
||||
// Optionally, update UI to indicate timeout
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendRequest()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
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
|
||||
output-localization-file: l10n.dart
|
||||
output-dir: lib/generated/l10n
|
||||
synthetic-package: false
|
||||
untranslated-messages-file: untranlated.json
|
||||
77
lib/app.dart
77
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:flutter/material.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/data/res/build_data.dart';
|
||||
import 'package:server_box/data/res/store.dart';
|
||||
@@ -12,9 +12,16 @@ import 'package:server_box/view/page/home.dart';
|
||||
|
||||
part 'intro.dart';
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
class MyApp extends StatefulWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
State<MyApp> createState() => _MyAppState();
|
||||
}
|
||||
|
||||
class _MyAppState extends State<MyApp> {
|
||||
late final Future<List<IntroPageBuilder>> _introFuture = _IntroPage.builders;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_setup(context);
|
||||
@@ -40,17 +47,13 @@ class MyApp extends StatelessWidget {
|
||||
light: ThemeData(
|
||||
useMaterial3: true,
|
||||
colorSchemeSeed: UIs.colorSeed,
|
||||
appBarTheme: AppBarTheme(
|
||||
scrolledUnderElevation: 0.0,
|
||||
),
|
||||
appBarTheme: AppBarTheme(scrolledUnderElevation: 0.0),
|
||||
),
|
||||
dark: ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
colorSchemeSeed: UIs.colorSeed,
|
||||
appBarTheme: AppBarTheme(
|
||||
scrolledUnderElevation: 0.0,
|
||||
),
|
||||
appBarTheme: AppBarTheme(scrolledUnderElevation: 0.0),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -58,19 +61,14 @@ class MyApp extends StatelessWidget {
|
||||
Widget _buildDynamicColor(BuildContext context) {
|
||||
return DynamicColorBuilder(
|
||||
builder: (light, dark) {
|
||||
final lightTheme = ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: light,
|
||||
);
|
||||
final darkTheme = ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
colorScheme: dark,
|
||||
);
|
||||
final lightTheme = ThemeData(useMaterial3: true, colorScheme: light);
|
||||
final darkTheme = ThemeData(useMaterial3: true, brightness: Brightness.dark, colorScheme: dark);
|
||||
if (context.isDark && dark != null) {
|
||||
UIs.primaryColor = dark.primary;
|
||||
UIs.colorSeed = dark.primary;
|
||||
} else if (!context.isDark && light != null) {
|
||||
UIs.primaryColor = light.primary;
|
||||
UIs.colorSeed = light.primary;
|
||||
}
|
||||
|
||||
return _buildApp(context, light: lightTheme, dark: darkTheme);
|
||||
@@ -78,11 +76,7 @@ class MyApp extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildApp(
|
||||
BuildContext ctx, {
|
||||
required ThemeData light,
|
||||
required ThemeData dark,
|
||||
}) {
|
||||
Widget _buildApp(BuildContext ctx, {required ThemeData light, required ThemeData dark}) {
|
||||
final tMode = Stores.setting.themeMode.fetch();
|
||||
// Issue #57
|
||||
final themeMode = switch (tMode) {
|
||||
@@ -94,19 +88,10 @@ class MyApp extends StatelessWidget {
|
||||
|
||||
return MaterialApp(
|
||||
key: ValueKey(locale),
|
||||
builder: (context, child) => ResponsiveBreakpoints.builder(
|
||||
child: child ?? UIs.placeholder,
|
||||
breakpoints: const [
|
||||
Breakpoint(start: 0, end: 450, name: MOBILE),
|
||||
Breakpoint(start: 451, end: 800, name: TABLET),
|
||||
Breakpoint(start: 801, end: 1920, name: DESKTOP),
|
||||
],
|
||||
),
|
||||
navigatorKey: AppNavigator.key,
|
||||
builder: ResponsivePoints.builder,
|
||||
locale: locale,
|
||||
localizationsDelegates: const [
|
||||
LibLocalizations.delegate,
|
||||
...AppLocalizations.localizationsDelegates,
|
||||
],
|
||||
localizationsDelegates: const [LibLocalizations.delegate, ...AppLocalizations.localizationsDelegates],
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
localeListResolutionCallback: LocaleUtil.resolve,
|
||||
navigatorObservers: [AppRouteObserver.instance],
|
||||
@@ -114,24 +99,26 @@ class MyApp extends StatelessWidget {
|
||||
themeMode: themeMode,
|
||||
theme: light.fixWindowsFont,
|
||||
darkTheme: (tMode < 3 ? dark : dark.toAmoled).fixWindowsFont,
|
||||
home: Builder(
|
||||
builder: (context) {
|
||||
home: FutureBuilder<List<IntroPageBuilder>>(
|
||||
future: _introFuture,
|
||||
builder: (context, snapshot) {
|
||||
context.setLibL10n();
|
||||
final appL10n = AppLocalizations.of(context);
|
||||
if (appL10n != null) l10n = appL10n;
|
||||
|
||||
Widget child;
|
||||
final intros = _IntroPage.builders;
|
||||
if (intros.isNotEmpty) {
|
||||
child = _IntroPage(intros);
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
child = const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
} 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,90 @@ abstract final class MethodChans {
|
||||
|
||||
/// Issue #662
|
||||
static void startService() {
|
||||
// if (Stores.setting.fgService.fetch() != true) return;
|
||||
// _channel.invokeMethod('startService');
|
||||
if (Stores.setting.fgService.fetch() != true) return;
|
||||
_channel.invokeMethod('startService');
|
||||
}
|
||||
|
||||
/// Issue #662
|
||||
static void stopService() {
|
||||
// if (Stores.setting.fgService.fetch() != true) return;
|
||||
// _channel.invokeMethod('stopService');
|
||||
if (Stores.setting.fgService.fetch() != true) return;
|
||||
_channel.invokeMethod('stopService');
|
||||
}
|
||||
|
||||
static void updateHomeWidget() async {
|
||||
if (!isIOS || !isAndroid) return;
|
||||
if (!isIOS && !isAndroid) return;
|
||||
if (!Stores.setting.autoUpdateHomeWidget.fetch()) return;
|
||||
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 (_) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 (_) {
|
||||
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 (_) {}
|
||||
}
|
||||
|
||||
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 (_) {}
|
||||
}
|
||||
|
||||
static Future<void> stopLiveActivity() async {
|
||||
if (!isIOS) return;
|
||||
try {
|
||||
Loggers.app.info('Stopping iOS Live Activity');
|
||||
await _channel.invokeMethod('stopLiveActivity');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
21
lib/core/extension/server.dart
Normal file
21
lib/core/extension/server.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:flutter/material.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/provider/server/single.dart';
|
||||
import 'package:server_box/data/res/store.dart';
|
||||
|
||||
extension LogoExt on ServerState {
|
||||
String? getLogoUrl(BuildContext context) {
|
||||
var logoUrl = spi.custom?.logoUrl ?? Stores.setting.serverLogoUrl.fetch().selfNotEmptyOrNull;
|
||||
if (logoUrl == null) {
|
||||
return null;
|
||||
}
|
||||
final dist = status.more[StatusCmdType.sys]?.dist;
|
||||
if (dist != null) {
|
||||
logoUrl = logoUrl.replaceFirst('{DIST}', dist.name);
|
||||
}
|
||||
logoUrl = logoUrl.replaceFirst('{BRIGHT}', context.isDark ? 'dark' : 'light');
|
||||
return logoUrl;
|
||||
}
|
||||
}
|
||||
@@ -12,21 +12,9 @@ extension SftpFileX on SftpFileMode {
|
||||
|
||||
UnixPerm toUnixPerm() {
|
||||
return UnixPerm(
|
||||
user: UnixPermOp(
|
||||
r: userRead,
|
||||
w: userWrite,
|
||||
x: userExecute,
|
||||
),
|
||||
group: UnixPermOp(
|
||||
r: groupRead,
|
||||
w: groupWrite,
|
||||
x: groupExecute,
|
||||
),
|
||||
other: UnixPermOp(
|
||||
r: otherRead,
|
||||
w: otherWrite,
|
||||
x: otherExecute,
|
||||
),
|
||||
user: UnixPermOp(r: userRead, w: userWrite, x: userExecute),
|
||||
group: UnixPermOp(r: groupRead, w: groupWrite, x: groupExecute),
|
||||
other: UnixPermOp(r: otherRead, w: otherWrite, x: otherExecute),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:typed_data';
|
||||
import 'package:dartssh2/dartssh2.dart';
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:server_box/data/model/server/system.dart';
|
||||
|
||||
import 'package:server_box/data/res/misc.dart';
|
||||
|
||||
@@ -13,6 +14,52 @@ typedef OnStdin = void Function(SSHSession session);
|
||||
typedef PwdRequestFunc = Future<String?> Function(String? user);
|
||||
|
||||
extension SSHClientX on SSHClient {
|
||||
/// Create a persistent PowerShell session for Windows commands
|
||||
Future<(SSHSession, String)> execPowerShell(
|
||||
OnStdin onStdin, {
|
||||
SSHPtyConfig? pty,
|
||||
OnStdout? onStdout,
|
||||
OnStdout? onStderr,
|
||||
bool stdout = true,
|
||||
bool stderr = true,
|
||||
Map<String, String>? env,
|
||||
}) async {
|
||||
final session = await execute(
|
||||
'powershell -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass',
|
||||
pty: pty,
|
||||
environment: env,
|
||||
);
|
||||
|
||||
final result = BytesBuilder(copy: false);
|
||||
final stdoutDone = Completer<void>();
|
||||
final stderrDone = Completer<void>();
|
||||
|
||||
session.stdout.listen(
|
||||
(e) {
|
||||
onStdout?.call(e.string, session);
|
||||
if (stdout) result.add(e);
|
||||
},
|
||||
onDone: stdoutDone.complete,
|
||||
onError: stderrDone.completeError,
|
||||
);
|
||||
|
||||
session.stderr.listen(
|
||||
(e) {
|
||||
onStderr?.call(e.string, session);
|
||||
if (stderr) result.add(e);
|
||||
},
|
||||
onDone: stderrDone.complete,
|
||||
onError: stderrDone.completeError,
|
||||
);
|
||||
|
||||
onStdin(session);
|
||||
|
||||
await stdoutDone.future;
|
||||
await stderrDone.future;
|
||||
|
||||
return (session, result.takeBytes().string);
|
||||
}
|
||||
|
||||
Future<(SSHSession, String)> exec(
|
||||
OnStdin onStdin, {
|
||||
String? entry,
|
||||
@@ -22,9 +69,14 @@ extension SSHClientX on SSHClient {
|
||||
bool stdout = true,
|
||||
bool stderr = true,
|
||||
Map<String, String>? env,
|
||||
SystemType? systemType,
|
||||
}) async {
|
||||
final session = await execute(
|
||||
entry ?? 'cat | sh',
|
||||
entry ??
|
||||
switch (systemType) {
|
||||
SystemType.windows => 'powershell -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass',
|
||||
_ => 'cat | sh',
|
||||
},
|
||||
pty: pty,
|
||||
environment: env,
|
||||
);
|
||||
@@ -80,10 +132,9 @@ extension SSHClientX on SSHClient {
|
||||
if (data.contains('[sudo] password for ')) {
|
||||
isRequestingPwd = true;
|
||||
final user = Miscs.pwdRequestWithUserReg.firstMatch(data)?.group(1);
|
||||
if (context == null) return;
|
||||
final pwd = context.mounted
|
||||
? await context.showPwdDialog(title: user, id: id)
|
||||
: null;
|
||||
final ctx = context ?? WidgetsBinding.instance.focusManager.primaryFocus?.context;
|
||||
if (ctx == null) return;
|
||||
final pwd = ctx.mounted ? await ctx.showPwdDialog(title: user, id: id) : null;
|
||||
if (pwd == null || pwd.isEmpty) {
|
||||
session.stdin.close();
|
||||
} else {
|
||||
|
||||
408
lib/core/service/ssh_discovery.dart
Normal file
408
lib/core/service/ssh_discovery.dart
Normal file
@@ -0,0 +1,408 @@
|
||||
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 (_) {
|
||||
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) {
|
||||
lprint(
|
||||
'[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) {
|
||||
// Log unexpected format but continue processing other lines
|
||||
lprint('[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) {
|
||||
lprint('[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 (_) {}
|
||||
} 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 (_) {
|
||||
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,24 @@ final class BakSyncer extends SyncIface {
|
||||
const BakSyncer._() : super();
|
||||
|
||||
@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
|
||||
Future<Mergeable> fromFile(String path) async {
|
||||
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 (_) {
|
||||
// Fallback: try without password if detection failed
|
||||
return MergeableUtils.fromJsonString(content).$1;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -28,6 +40,9 @@ final class BakSyncer extends SyncIface {
|
||||
final webdavEnabled = PrefProps.webdavSync.get();
|
||||
if (webdavEnabled) return Webdav.shared;
|
||||
|
||||
final gistEnabled = PrefProps.gistSync.get();
|
||||
if (gistEnabled) return GistRs.shared;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,8 @@ class ChainComparator<T> {
|
||||
ChainComparator.empty() : this._create(null, (a, b) => 0);
|
||||
ChainComparator.create() : this._create(null, (a, b) => 0);
|
||||
|
||||
static ChainComparator<T> comparing<T, F extends Comparable<F>>(
|
||||
F Function(T) extractor) {
|
||||
return ChainComparator._create(
|
||||
null, (a, b) => extractor(a).compareTo(extractor(b)));
|
||||
static ChainComparator<T> comparing<T, F extends Comparable<F>>(F Function(T) extractor) {
|
||||
return ChainComparator._create(null, (a, b) => extractor(a).compareTo(extractor(b)));
|
||||
}
|
||||
|
||||
int compare(T a, T b) {
|
||||
@@ -26,8 +24,9 @@ class ChainComparator<T> {
|
||||
}
|
||||
|
||||
ChainComparator<T> thenCompareBy<F extends Comparable<F>>(
|
||||
F Function(T) extractor,
|
||||
{bool reversed = false}) {
|
||||
F Function(T) extractor, {
|
||||
bool reversed = false,
|
||||
}) {
|
||||
return ChainComparator._create(
|
||||
this,
|
||||
reversed
|
||||
@@ -36,18 +35,12 @@ class ChainComparator<T> {
|
||||
);
|
||||
}
|
||||
|
||||
ChainComparator<T> thenWithComparator(Comparator<T> comparator,
|
||||
{bool reversed = false}) {
|
||||
return ChainComparator._create(
|
||||
this,
|
||||
!reversed ? comparator : (a, b) => comparator(b, a),
|
||||
);
|
||||
ChainComparator<T> thenWithComparator(Comparator<T> comparator, {bool reversed = false}) {
|
||||
return ChainComparator._create(this, !reversed ? comparator : (a, b) => comparator(b, a));
|
||||
}
|
||||
|
||||
ChainComparator<T> thenCompareByReversed<F extends Comparable<F>>(
|
||||
F Function(T) extractor) {
|
||||
return ChainComparator._create(
|
||||
this, (a, b) => -extractor(a).compareTo(extractor(b)));
|
||||
ChainComparator<T> thenCompareByReversed<F extends Comparable<F>>(F Function(T) extractor) {
|
||||
return ChainComparator._create(this, (a, b) => -extractor(a).compareTo(extractor(b)));
|
||||
}
|
||||
|
||||
ChainComparator<T> thenTrueFirst(bool Function(T) f) {
|
||||
@@ -58,13 +51,12 @@ class ChainComparator<T> {
|
||||
}
|
||||
|
||||
ChainComparator<T> reversed() {
|
||||
return ChainComparator._create(null, (a, b) => this.compare(b, a));
|
||||
return ChainComparator._create(null, (a, b) => compare(b, a));
|
||||
}
|
||||
}
|
||||
|
||||
class Comparators {
|
||||
static Comparator<String> compareStringCaseInsensitive(
|
||||
{bool uppercaseFirst = false}) {
|
||||
static Comparator<String> compareStringCaseInsensitive({bool uppercaseFirst = false}) {
|
||||
return (String a, String b) {
|
||||
final r = a.toLowerCase().compareTo(b.toLowerCase());
|
||||
if (r != 0) return r;
|
||||
|
||||
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:convert';
|
||||
|
||||
import 'package:dartssh2/dartssh2.dart';
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:server_box/core/app_navigator.dart';
|
||||
import 'package:server_box/core/extension/context/locale.dart';
|
||||
import 'package:server_box/data/model/app/error.dart';
|
||||
import 'package:server_box/data/model/server/server_private_info.dart';
|
||||
import 'package:server_box/data/res/store.dart';
|
||||
@@ -24,19 +28,12 @@ String decyptPem(List<String> args) {
|
||||
return sshKey.first.toPem();
|
||||
}
|
||||
|
||||
enum GenSSHClientStatus {
|
||||
socket,
|
||||
key,
|
||||
pwd,
|
||||
}
|
||||
enum GenSSHClientStatus { socket, key, pwd }
|
||||
|
||||
String getPrivateKey(String id) {
|
||||
final pki = Stores.key.fetchOne(id);
|
||||
if (pki == null) {
|
||||
throw SSHErr(
|
||||
type: SSHErrType.noPrivateKey,
|
||||
message: 'key [$id] not found',
|
||||
);
|
||||
throw SSHErr(type: SSHErrType.noPrivateKey, message: l10n.privateKeyNotFoundFmt(id));
|
||||
}
|
||||
return pki.key;
|
||||
}
|
||||
@@ -58,10 +55,17 @@ Future<SSHClient> genClient(
|
||||
Spi? jumpSpi,
|
||||
|
||||
/// Handle keyboard-interactive authentication
|
||||
FutureOr<List<String>?> Function(SSHUserInfoRequest)? onKeyboardInteractive,
|
||||
SSHUserInfoRequestHandler? onKeyboardInteractive,
|
||||
Map<String, String>? knownHostFingerprints,
|
||||
void Function(String storageKey, String fingerprintHex)? onHostKeyAccepted,
|
||||
Future<bool> Function(HostKeyPromptInfo info)? onHostKeyPrompt,
|
||||
}) async {
|
||||
onStatus?.call(GenSSHClientStatus.socket);
|
||||
|
||||
final hostKeyCache = Map<String, String>.from(knownHostFingerprints ?? _loadKnownHostFingerprints());
|
||||
final hostKeyPersist = onHostKeyAccepted ?? _persistHostKeyFingerprint;
|
||||
final hostKeyPrompt = onHostKeyPrompt ?? _defaultHostKeyPrompt;
|
||||
|
||||
String? alterUser;
|
||||
|
||||
final socket = await () async {
|
||||
@@ -77,32 +81,24 @@ Future<SSHClient> genClient(
|
||||
jumpSpi_,
|
||||
privateKey: jumpPrivateKey,
|
||||
timeout: timeout,
|
||||
knownHostFingerprints: hostKeyCache,
|
||||
onHostKeyAccepted: hostKeyPersist,
|
||||
onHostKeyPrompt: onHostKeyPrompt,
|
||||
);
|
||||
|
||||
return await jumpClient.forwardLocal(
|
||||
spi.ip,
|
||||
spi.port,
|
||||
);
|
||||
return await jumpClient.forwardLocal(spi.ip, spi.port);
|
||||
}
|
||||
|
||||
// Direct
|
||||
try {
|
||||
return await SSHSocket.connect(
|
||||
spi.ip,
|
||||
spi.port,
|
||||
timeout: timeout,
|
||||
);
|
||||
return await SSHSocket.connect(spi.ip, spi.port, timeout: timeout);
|
||||
} catch (e) {
|
||||
Loggers.app.warning('genClient', e);
|
||||
if (spi.alterUrl == null) rethrow;
|
||||
try {
|
||||
final res = spi.fromStringUrl();
|
||||
final res = spi.parseAlterUrl();
|
||||
alterUser = res.$2;
|
||||
return await SSHSocket.connect(
|
||||
res.$1,
|
||||
res.$3,
|
||||
timeout: timeout,
|
||||
);
|
||||
return await SSHSocket.connect(res.$1, res.$3, timeout: timeout);
|
||||
} catch (e) {
|
||||
Loggers.app.warning('genClient alterUrl', e);
|
||||
rethrow;
|
||||
@@ -110,6 +106,13 @@ Future<SSHClient> genClient(
|
||||
}
|
||||
}();
|
||||
|
||||
final hostKeyVerifier = _HostKeyVerifier(
|
||||
spi: spi,
|
||||
cache: hostKeyCache,
|
||||
persistCallback: hostKeyPersist,
|
||||
prompt: hostKeyPrompt,
|
||||
);
|
||||
|
||||
final keyId = spi.keyId;
|
||||
if (keyId == null) {
|
||||
onStatus?.call(GenSSHClientStatus.pwd);
|
||||
@@ -118,6 +121,7 @@ Future<SSHClient> genClient(
|
||||
username: alterUser ?? spi.user,
|
||||
onPasswordRequest: () => spi.pwd,
|
||||
onUserInfoRequest: onKeyboardInteractive,
|
||||
onVerifyHostKey: hostKeyVerifier.call,
|
||||
// printDebug: debugPrint,
|
||||
// printTrace: debugPrint,
|
||||
);
|
||||
@@ -131,7 +135,220 @@ Future<SSHClient> genClient(
|
||||
// Must use [compute] here, instead of [Computer.shared.start]
|
||||
identities: await compute(loadIndentity, privateKey),
|
||||
onUserInfoRequest: onKeyboardInteractive,
|
||||
onVerifyHostKey: hostKeyVerifier.call,
|
||||
// printDebug: debugPrint,
|
||||
// printTrace: debugPrint,
|
||||
);
|
||||
}
|
||||
|
||||
typedef _HostKeyPersistCallback = void Function(String storageKey, String fingerprintHex);
|
||||
|
||||
class HostKeyPromptInfo {
|
||||
HostKeyPromptInfo({
|
||||
required this.spi,
|
||||
required this.keyType,
|
||||
required this.fingerprintHex,
|
||||
required this.fingerprintBase64,
|
||||
required this.isMismatch,
|
||||
this.previousFingerprintHex,
|
||||
});
|
||||
|
||||
final Spi spi;
|
||||
final String keyType;
|
||||
final String fingerprintHex;
|
||||
final String fingerprintBase64;
|
||||
final bool isMismatch;
|
||||
final String? previousFingerprintHex;
|
||||
}
|
||||
|
||||
class _HostKeyVerifier {
|
||||
_HostKeyVerifier({
|
||||
required this.spi,
|
||||
required Map<String, String> cache,
|
||||
required this.prompt,
|
||||
this.persistCallback,
|
||||
}) : _cache = cache;
|
||||
|
||||
final Spi spi;
|
||||
final Map<String, String> _cache;
|
||||
final _HostKeyPersistCallback? persistCallback;
|
||||
final Future<bool> Function(HostKeyPromptInfo info) prompt;
|
||||
|
||||
Future<bool> call(String keyType, Uint8List fingerprintBytes) async {
|
||||
final storageKey = _hostKeyStorageKey(spi, keyType);
|
||||
final fingerprintHex = _fingerprintToHex(fingerprintBytes);
|
||||
final fingerprintBase64 = _fingerprintToBase64(fingerprintBytes);
|
||||
final existing = _cache[storageKey];
|
||||
|
||||
if (existing == null) {
|
||||
final accepted = await prompt(
|
||||
HostKeyPromptInfo(
|
||||
spi: spi,
|
||||
keyType: keyType,
|
||||
fingerprintHex: fingerprintHex,
|
||||
fingerprintBase64: fingerprintBase64,
|
||||
isMismatch: false,
|
||||
),
|
||||
);
|
||||
if (!accepted) {
|
||||
Loggers.app.warning('User rejected new SSH host key for ${spi.name} ($keyType).');
|
||||
return false;
|
||||
}
|
||||
_cache[storageKey] = fingerprintHex;
|
||||
persistCallback?.call(storageKey, fingerprintHex);
|
||||
Loggers.app.info('Trusted SSH host key for ${spi.name} ($keyType).');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (existing == fingerprintHex) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final accepted = await prompt(
|
||||
HostKeyPromptInfo(
|
||||
spi: spi,
|
||||
keyType: keyType,
|
||||
fingerprintHex: fingerprintHex,
|
||||
fingerprintBase64: fingerprintBase64,
|
||||
isMismatch: true,
|
||||
previousFingerprintHex: existing,
|
||||
),
|
||||
);
|
||||
if (!accepted) {
|
||||
Loggers.app.warning(
|
||||
'SSH host key mismatch for ${spi.name}',
|
||||
'expected $existing but received $fingerprintHex ($keyType)',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
_cache[storageKey] = fingerprintHex;
|
||||
persistCallback?.call(storageKey, fingerprintHex);
|
||||
Loggers.app.warning('Updated stored SSH host key for ${spi.name} ($keyType) after user confirmation.');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, String> _loadKnownHostFingerprints() {
|
||||
try {
|
||||
final prop = Stores.setting.sshKnownHostFingerprints;
|
||||
return Map<String, String>.from(prop.get());
|
||||
} catch (e, stack) {
|
||||
Loggers.app.warning('Load SSH host key fingerprints failed', e, stack);
|
||||
return <String, String>{};
|
||||
}
|
||||
}
|
||||
|
||||
void _persistHostKeyFingerprint(String storageKey, String fingerprintHex) {
|
||||
try {
|
||||
final prop = Stores.setting.sshKnownHostFingerprints;
|
||||
final updated = Map<String, String>.from(prop.get());
|
||||
if (updated[storageKey] == fingerprintHex) {
|
||||
return;
|
||||
}
|
||||
updated[storageKey] = fingerprintHex;
|
||||
prop.put(updated);
|
||||
Loggers.app.info('Stored SSH host key fingerprint for $storageKey');
|
||||
} catch (e, stack) {
|
||||
Loggers.app.warning('Persist SSH host key fingerprint failed', e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _defaultHostKeyPrompt(HostKeyPromptInfo info) async {
|
||||
final ctx = AppNavigator.context;
|
||||
if (ctx == null) {
|
||||
Loggers.app.warning('Host key prompt skipped: navigator context unavailable.');
|
||||
return false;
|
||||
}
|
||||
|
||||
final hostLine = '${info.spi.user}@${info.spi.ip}:${info.spi.port}';
|
||||
final description = info.isMismatch
|
||||
? l10n.sshHostKeyChangedDesc(info.spi.name)
|
||||
: l10n.sshHostKeyNewDesc(info.spi.name);
|
||||
|
||||
final result = await ctx.showRoundDialog<bool>(
|
||||
title: libL10n.attention,
|
||||
barrierDismiss: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(description),
|
||||
const SizedBox(height: 12),
|
||||
SelectableText('${l10n.server}: ${info.spi.name}'),
|
||||
SelectableText('${libL10n.addr}: $hostLine'),
|
||||
SelectableText('${l10n.sshHostKeyType}: ${info.keyType}'),
|
||||
SelectableText(l10n.sshHostKeyFingerprintMd5Hex(info.fingerprintHex)),
|
||||
SelectableText(l10n.sshHostKeyFingerprintMd5Base64(info.fingerprintBase64)),
|
||||
if (info.previousFingerprintHex != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
SelectableText(l10n.sshHostKeyStoredFingerprint(info.previousFingerprintHex!)),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => ctx.pop(false), child: Text(libL10n.cancel)),
|
||||
TextButton(onPressed: () => ctx.pop(true), child: Text(libL10n.ok)),
|
||||
],
|
||||
);
|
||||
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
Future<void> ensureKnownHostKey(
|
||||
Spi spi, {
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
SSHUserInfoRequestHandler? onKeyboardInteractive,
|
||||
}) async {
|
||||
final cache = _loadKnownHostFingerprints();
|
||||
if (_hasKnownHostFingerprintForSpi(spi, cache)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final jumpSpi = spi.jumpId != null ? Stores.server.box.get(spi.jumpId) : null;
|
||||
if (jumpSpi != null && !_hasKnownHostFingerprintForSpi(jumpSpi, cache)) {
|
||||
await ensureKnownHostKey(
|
||||
jumpSpi,
|
||||
timeout: timeout,
|
||||
onKeyboardInteractive: onKeyboardInteractive,
|
||||
);
|
||||
cache.addAll(_loadKnownHostFingerprints());
|
||||
if (_hasKnownHostFingerprintForSpi(spi, cache)) return;
|
||||
}
|
||||
|
||||
final client = await genClient(
|
||||
spi,
|
||||
timeout: timeout,
|
||||
onKeyboardInteractive: onKeyboardInteractive,
|
||||
knownHostFingerprints: cache,
|
||||
);
|
||||
|
||||
try {
|
||||
await client.authenticated;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
bool _hasKnownHostFingerprintForSpi(Spi spi, Map<String, String> cache) {
|
||||
final prefix = '${_hostIdentifier(spi)}::';
|
||||
return cache.keys.any((key) => key.startsWith(prefix));
|
||||
}
|
||||
|
||||
String _hostKeyStorageKey(Spi spi, String keyType) {
|
||||
final base = _hostIdentifier(spi);
|
||||
return '$base::$keyType';
|
||||
}
|
||||
|
||||
String _hostIdentifier(Spi spi) => spi.id.isNotEmpty ? spi.id : spi.oldId;
|
||||
|
||||
String _fingerprintToHex(Uint8List fingerprint) {
|
||||
final buffer = StringBuffer();
|
||||
for (var i = 0; i < fingerprint.length; i++) {
|
||||
if (i > 0) buffer.write(':');
|
||||
buffer.write(fingerprint[i].toRadixString(16).padLeft(2, '0'));
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String _fingerprintToBase64(Uint8List fingerprint) => base64.encode(fingerprint);
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -2,18 +2,13 @@ import 'dart:async';
|
||||
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:server_box/core/extension/context/locale.dart';
|
||||
import 'package:server_box/data/model/server/server_private_info.dart';
|
||||
import 'package:server_box/data/provider/app.dart';
|
||||
|
||||
abstract final class KeybordInteractive {
|
||||
static FutureOr<List<String>?> defaultHandle(
|
||||
Spi spi, {
|
||||
BuildContext? ctx,
|
||||
}) async {
|
||||
static FutureOr<List<String>?> defaultHandle(Spi spi, {BuildContext? ctx}) async {
|
||||
try {
|
||||
final res = await (ctx ?? AppProvider.ctx)?.showPwdDialog(
|
||||
title: l10n.pwd,
|
||||
final res = await (ctx ?? WidgetsBinding.instance.focusManager.primaryFocus?.context)?.showPwdDialog(
|
||||
title: libL10n.pwd,
|
||||
id: spi.id,
|
||||
label: spi.id,
|
||||
);
|
||||
|
||||
188
lib/core/utils/ssh_config.dart
Normal file
188
lib/core/utils/ssh_config.dart
Normal file
@@ -0,0 +1,188 @@
|
||||
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) {
|
||||
// For ProxyJump, the format is usually: user@host:port
|
||||
// For ProxyCommand, it's more complex and might need custom parsing
|
||||
if (value.contains('@')) {
|
||||
return value.split(' ').first;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
54
lib/data/helper/system_detector.dart
Normal file
54
lib/data/helper/system_detector.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'package:dartssh2/dartssh2.dart';
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:server_box/data/model/server/server_private_info.dart';
|
||||
import 'package:server_box/data/model/server/system.dart';
|
||||
|
||||
/// Helper class for detecting remote system types
|
||||
class SystemDetector {
|
||||
/// Detects the system type of a remote server
|
||||
///
|
||||
/// First checks if a custom system type is configured in [spi].
|
||||
/// If not, attempts to detect the system by running commands:
|
||||
/// 1. '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.
|
||||
static Future<SystemType> detect(SSHClient client, Spi spi) async {
|
||||
// First, check if custom system type is defined
|
||||
SystemType? detectedSystemType = spi.customSystemType;
|
||||
if (detectedSystemType != null) {
|
||||
dprint('Using custom system type ${detectedSystemType.name} for ${spi.oldId}');
|
||||
return detectedSystemType;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to detect Unix/Linux/BSD systems first (more reliable and doesn't create files)
|
||||
final unixResult = await client.run('uname -a 2>/dev/null').string;
|
||||
if (unixResult.contains('Linux')) {
|
||||
detectedSystemType = SystemType.linux;
|
||||
dprint('Detected Linux system type for ${spi.oldId}');
|
||||
return detectedSystemType;
|
||||
} else if (unixResult.contains('Darwin') || unixResult.contains('BSD')) {
|
||||
detectedSystemType = SystemType.bsd;
|
||||
dprint('Detected BSD system type for ${spi.oldId}');
|
||||
return detectedSystemType;
|
||||
}
|
||||
|
||||
// If uname fails, try to detect Windows systems
|
||||
final powershellResult = await client.run('ver 2>nul').string;
|
||||
if (powershellResult.isNotEmpty &&
|
||||
(powershellResult.contains('Windows') || powershellResult.contains('NT'))) {
|
||||
detectedSystemType = SystemType.windows;
|
||||
dprint('Detected Windows system type for ${spi.oldId}');
|
||||
return detectedSystemType;
|
||||
}
|
||||
} catch (e) {
|
||||
Loggers.app.warning('System detection failed for ${spi.oldId}: $e');
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
detectedSystemType = SystemType.linux;
|
||||
dprint('Defaulting to Linux system type for ${spi.oldId}');
|
||||
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;
|
||||
}
|
||||
@@ -213,13 +213,10 @@ class Backup implements Mergeable {
|
||||
_logger.info('Restore success');
|
||||
}
|
||||
|
||||
factory Backup.fromJsonString(String raw) =>
|
||||
Backup.fromJson(json.decode(_diyDecrypt(raw)));
|
||||
factory Backup.fromJsonString(String raw) => Backup.fromJson(json.decode(_diyDecrypt(raw)));
|
||||
}
|
||||
|
||||
String _diyEncrypt(String raw) => json.encode(
|
||||
raw.codeUnits.map((e) => e * 2 + 1).toList(growable: false),
|
||||
);
|
||||
String _diyEncrypt(String raw) => json.encode(raw.codeUnits.map((e) => e * 2 + 1).toList(growable: false));
|
||||
|
||||
String _diyDecrypt(String raw) {
|
||||
try {
|
||||
@@ -234,4 +231,3 @@ String _diyDecrypt(String raw) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ import 'dart:io';
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.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/store.dart';
|
||||
|
||||
@@ -44,17 +47,17 @@ abstract class BackupV2 with _$BackupV2 implements Mergeable {
|
||||
Future<void> merge({bool force = false}) async {
|
||||
_loggerV2.info('Merging...');
|
||||
|
||||
// Merge each store
|
||||
await Mergeable.mergeStore(backupData: spis, store: Stores.server, force: force);
|
||||
await Mergeable.mergeStore(backupData: snippets, store: Stores.snippet, force: force);
|
||||
await Mergeable.mergeStore(backupData: keys, store: Stores.key, force: force);
|
||||
// Merge each store and check if changes were made
|
||||
final serverChanged = await Mergeable.mergeStore(backupData: spis, store: Stores.server, force: force);
|
||||
final snippetChanged = await Mergeable.mergeStore(backupData: snippets, store: Stores.snippet, 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: history, store: Stores.history, force: force);
|
||||
await Mergeable.mergeStore(backupData: settings, store: Stores.setting, force: force);
|
||||
|
||||
// Reload providers and notify listeners
|
||||
Provider.reload();
|
||||
RNodes.app.notify();
|
||||
if (serverChanged) GlobalRef.gRef?.read(serversProvider.notifier).reload();
|
||||
if (snippetChanged) GlobalRef.gRef?.read(snippetProvider.notifier).reload();
|
||||
if (keyChanged) GlobalRef.gRef?.read(privateKeyProvider.notifier).reload();
|
||||
|
||||
_loggerV2.info('Merge completed');
|
||||
}
|
||||
@@ -74,15 +77,27 @@ abstract class BackupV2 with _$BackupV2 implements Mergeable {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<String> backup([String? name]) async {
|
||||
static Future<String> backup([String? name, String? password]) async {
|
||||
final bak = await BackupV2.loadFromStore();
|
||||
final result = json.encode(bak.toJson());
|
||||
var result = json.encode(bak.toJson());
|
||||
|
||||
if (password != null && password.isNotEmpty) {
|
||||
result = Cryptor.encrypt(result, password);
|
||||
}
|
||||
|
||||
final path = Paths.doc.joinPath(name ?? Miscs.bakFileName);
|
||||
await File(path).writeAsString(result);
|
||||
return path;
|
||||
}
|
||||
|
||||
factory BackupV2.fromJsonString(String jsonString) {
|
||||
factory BackupV2.fromJsonString(String jsonString, [String? password]) {
|
||||
if (Cryptor.isEncrypted(jsonString)) {
|
||||
if (password == null || password.isEmpty) {
|
||||
throw Exception('Backup is encrypted but no password provided');
|
||||
}
|
||||
jsonString = Cryptor.decrypt(jsonString, password);
|
||||
}
|
||||
|
||||
final map = json.decode(jsonString) as Map<String, dynamic>;
|
||||
return BackupV2.fromJson(map);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// coverage:ignore-file
|
||||
// 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
|
||||
|
||||
@@ -9,364 +9,326 @@ part of 'backup2.dart';
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
|
||||
);
|
||||
|
||||
BackupV2 _$BackupV2FromJson(Map<String, dynamic> json) {
|
||||
return _BackupV2.fromJson(json);
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$BackupV2 {
|
||||
int get version => throw _privateConstructorUsedError;
|
||||
int get date => throw _privateConstructorUsedError;
|
||||
Map<String, Object?> get spis => throw _privateConstructorUsedError;
|
||||
Map<String, Object?> get snippets => throw _privateConstructorUsedError;
|
||||
Map<String, Object?> get keys => throw _privateConstructorUsedError;
|
||||
Map<String, Object?> get container => throw _privateConstructorUsedError;
|
||||
Map<String, Object?> get history => throw _privateConstructorUsedError;
|
||||
Map<String, Object?> get settings => throw _privateConstructorUsedError;
|
||||
|
||||
int get version; int get date; Map<String, Object?> get spis; Map<String, Object?> get snippets; Map<String, Object?> get keys; Map<String, Object?> get container; Map<String, Object?> get history; Map<String, Object?> get settings;
|
||||
/// Create a copy of BackupV2
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$BackupV2CopyWith<BackupV2> get copyWith => _$BackupV2CopyWithImpl<BackupV2>(this as BackupV2, _$identity);
|
||||
|
||||
/// Serializes this BackupV2 to a JSON map.
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is BackupV2&&(identical(other.version, version) || other.version == version)&&(identical(other.date, date) || other.date == date)&&const DeepCollectionEquality().equals(other.spis, spis)&&const DeepCollectionEquality().equals(other.snippets, snippets)&&const DeepCollectionEquality().equals(other.keys, keys)&&const DeepCollectionEquality().equals(other.container, container)&&const DeepCollectionEquality().equals(other.history, history)&&const DeepCollectionEquality().equals(other.settings, settings));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,version,date,const DeepCollectionEquality().hash(spis),const DeepCollectionEquality().hash(snippets),const DeepCollectionEquality().hash(keys),const DeepCollectionEquality().hash(container),const DeepCollectionEquality().hash(history),const DeepCollectionEquality().hash(settings));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BackupV2(version: $version, date: $date, spis: $spis, snippets: $snippets, keys: $keys, container: $container, history: $history, settings: $settings)';
|
||||
}
|
||||
|
||||
|
||||
/// Create a copy of BackupV2
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$BackupV2CopyWith<BackupV2> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $BackupV2CopyWith<$Res> {
|
||||
factory $BackupV2CopyWith(BackupV2 value, $Res Function(BackupV2) then) =
|
||||
_$BackupV2CopyWithImpl<$Res, BackupV2>;
|
||||
@useResult
|
||||
$Res call({
|
||||
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,
|
||||
});
|
||||
}
|
||||
abstract mixin class $BackupV2CopyWith<$Res> {
|
||||
factory $BackupV2CopyWith(BackupV2 value, $Res Function(BackupV2) _then) = _$BackupV2CopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
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
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$BackupV2CopyWithImpl<$Res, $Val extends BackupV2>
|
||||
class _$BackupV2CopyWithImpl<$Res>
|
||||
implements $BackupV2CopyWith<$Res> {
|
||||
_$BackupV2CopyWithImpl(this._value, this._then);
|
||||
_$BackupV2CopyWithImpl(this._self, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
final BackupV2 _self;
|
||||
final $Res Function(BackupV2) _then;
|
||||
|
||||
/// Create a copy of BackupV2
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? version = null,
|
||||
Object? date = null,
|
||||
Object? spis = null,
|
||||
Object? snippets = null,
|
||||
Object? keys = null,
|
||||
Object? container = null,
|
||||
Object? history = null,
|
||||
Object? settings = null,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
version: null == version
|
||||
? _value.version
|
||||
: version // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
date: null == date
|
||||
? _value.date
|
||||
: date // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
spis: null == spis
|
||||
? _value.spis
|
||||
: spis // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, Object?>,
|
||||
snippets: null == snippets
|
||||
? _value.snippets
|
||||
: snippets // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, Object?>,
|
||||
keys: null == keys
|
||||
? _value.keys
|
||||
: keys // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, Object?>,
|
||||
container: null == container
|
||||
? _value.container
|
||||
: container // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, Object?>,
|
||||
history: null == history
|
||||
? _value.history
|
||||
: history // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, Object?>,
|
||||
settings: null == settings
|
||||
? _value.settings
|
||||
: settings // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, Object?>,
|
||||
)
|
||||
as $Val,
|
||||
);
|
||||
}
|
||||
/// Create a copy of BackupV2
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? version = null,Object? date = null,Object? spis = null,Object? snippets = null,Object? keys = null,Object? container = null,Object? history = null,Object? settings = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
version: null == version ? _self.version : version // ignore: cast_nullable_to_non_nullable
|
||||
as int,date: null == date ? _self.date : date // ignore: cast_nullable_to_non_nullable
|
||||
as int,spis: null == spis ? _self.spis : spis // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, Object?>,snippets: null == snippets ? _self.snippets : snippets // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, Object?>,keys: null == keys ? _self.keys : keys // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, Object?>,container: null == container ? _self.container : container // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, Object?>,history: null == history ? _self.history : history // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, Object?>,settings: null == settings ? _self.settings : settings // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, Object?>,
|
||||
));
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$BackupV2ImplCopyWith<$Res>
|
||||
implements $BackupV2CopyWith<$Res> {
|
||||
factory _$$BackupV2ImplCopyWith(
|
||||
_$BackupV2Impl value,
|
||||
$Res Function(_$BackupV2Impl) then,
|
||||
) = __$$BackupV2ImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call({
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$BackupV2ImplCopyWithImpl<$Res>
|
||||
extends _$BackupV2CopyWithImpl<$Res, _$BackupV2Impl>
|
||||
implements _$$BackupV2ImplCopyWith<$Res> {
|
||||
__$$BackupV2ImplCopyWithImpl(
|
||||
_$BackupV2Impl _value,
|
||||
$Res Function(_$BackupV2Impl) _then,
|
||||
) : super(_value, _then);
|
||||
|
||||
/// Create a copy of BackupV2
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? version = null,
|
||||
Object? date = null,
|
||||
Object? spis = null,
|
||||
Object? snippets = null,
|
||||
Object? keys = null,
|
||||
Object? container = null,
|
||||
Object? history = null,
|
||||
Object? settings = null,
|
||||
}) {
|
||||
return _then(
|
||||
_$BackupV2Impl(
|
||||
version: null == version
|
||||
? _value.version
|
||||
: version // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
date: null == date
|
||||
? _value.date
|
||||
: date // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
spis: null == spis
|
||||
? _value._spis
|
||||
: spis // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, Object?>,
|
||||
snippets: null == snippets
|
||||
? _value._snippets
|
||||
: snippets // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, Object?>,
|
||||
keys: null == keys
|
||||
? _value._keys
|
||||
: keys // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, Object?>,
|
||||
container: null == container
|
||||
? _value._container
|
||||
: container // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, Object?>,
|
||||
history: null == history
|
||||
? _value._history
|
||||
: history // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, Object?>,
|
||||
settings: null == settings
|
||||
? _value._settings
|
||||
: settings // ignore: cast_nullable_to_non_nullable
|
||||
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
|
||||
@JsonSerializable()
|
||||
class _$BackupV2Impl extends _BackupV2 {
|
||||
const _$BackupV2Impl({
|
||||
required this.version,
|
||||
required this.date,
|
||||
required final Map<String, Object?> spis,
|
||||
required final Map<String, Object?> snippets,
|
||||
required final Map<String, Object?> keys,
|
||||
required final Map<String, Object?> container,
|
||||
required final Map<String, Object?> history,
|
||||
required final Map<String, Object?> settings,
|
||||
}) : _spis = spis,
|
||||
_snippets = snippets,
|
||||
_keys = keys,
|
||||
_container = container,
|
||||
_history = history,
|
||||
_settings = settings,
|
||||
super._();
|
||||
|
||||
factory _$BackupV2Impl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$BackupV2ImplFromJson(json);
|
||||
class _BackupV2 extends BackupV2 {
|
||||
const _BackupV2({required this.version, required this.date, required final Map<String, Object?> spis, required final Map<String, Object?> snippets, required final Map<String, Object?> keys, required final Map<String, Object?> container, required final Map<String, Object?> history, required final Map<String, Object?> settings}): _spis = spis,_snippets = snippets,_keys = keys,_container = container,_history = history,_settings = settings,super._();
|
||||
factory _BackupV2.fromJson(Map<String, dynamic> json) => _$BackupV2FromJson(json);
|
||||
|
||||
@override
|
||||
final int version;
|
||||
@override
|
||||
final int date;
|
||||
final Map<String, Object?> _spis;
|
||||
@override
|
||||
Map<String, Object?> get spis {
|
||||
if (_spis is EqualUnmodifiableMapView) return _spis;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableMapView(_spis);
|
||||
}
|
||||
|
||||
final Map<String, Object?> _snippets;
|
||||
@override
|
||||
Map<String, Object?> get snippets {
|
||||
if (_snippets is EqualUnmodifiableMapView) return _snippets;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableMapView(_snippets);
|
||||
}
|
||||
|
||||
final Map<String, Object?> _keys;
|
||||
@override
|
||||
Map<String, Object?> get keys {
|
||||
if (_keys is EqualUnmodifiableMapView) return _keys;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableMapView(_keys);
|
||||
}
|
||||
|
||||
final Map<String, Object?> _container;
|
||||
@override
|
||||
Map<String, Object?> get container {
|
||||
if (_container is EqualUnmodifiableMapView) return _container;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableMapView(_container);
|
||||
}
|
||||
|
||||
final Map<String, Object?> _history;
|
||||
@override
|
||||
Map<String, Object?> get history {
|
||||
if (_history is EqualUnmodifiableMapView) return _history;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableMapView(_history);
|
||||
}
|
||||
|
||||
final Map<String, Object?> _settings;
|
||||
@override
|
||||
Map<String, Object?> get settings {
|
||||
if (_settings is EqualUnmodifiableMapView) return _settings;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableMapView(_settings);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BackupV2(version: $version, date: $date, spis: $spis, snippets: $snippets, keys: $keys, container: $container, history: $history, settings: $settings)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$BackupV2Impl &&
|
||||
(identical(other.version, version) || other.version == version) &&
|
||||
(identical(other.date, date) || other.date == date) &&
|
||||
const DeepCollectionEquality().equals(other._spis, _spis) &&
|
||||
const DeepCollectionEquality().equals(other._snippets, _snippets) &&
|
||||
const DeepCollectionEquality().equals(other._keys, _keys) &&
|
||||
const DeepCollectionEquality().equals(
|
||||
other._container,
|
||||
_container,
|
||||
) &&
|
||||
const DeepCollectionEquality().equals(other._history, _history) &&
|
||||
const DeepCollectionEquality().equals(other._settings, _settings));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
version,
|
||||
date,
|
||||
const DeepCollectionEquality().hash(_spis),
|
||||
const DeepCollectionEquality().hash(_snippets),
|
||||
const DeepCollectionEquality().hash(_keys),
|
||||
const DeepCollectionEquality().hash(_container),
|
||||
const DeepCollectionEquality().hash(_history),
|
||||
const DeepCollectionEquality().hash(_settings),
|
||||
);
|
||||
|
||||
/// Create a copy of BackupV2
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$BackupV2ImplCopyWith<_$BackupV2Impl> get copyWith =>
|
||||
__$$BackupV2ImplCopyWithImpl<_$BackupV2Impl>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$$BackupV2ImplToJson(this);
|
||||
}
|
||||
@override final int version;
|
||||
@override final int date;
|
||||
final Map<String, Object?> _spis;
|
||||
@override Map<String, Object?> get spis {
|
||||
if (_spis is EqualUnmodifiableMapView) return _spis;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableMapView(_spis);
|
||||
}
|
||||
|
||||
abstract class _BackupV2 extends BackupV2 {
|
||||
const factory _BackupV2({
|
||||
required final int version,
|
||||
required final int date,
|
||||
required final Map<String, Object?> spis,
|
||||
required final Map<String, Object?> snippets,
|
||||
required final Map<String, Object?> keys,
|
||||
required final Map<String, Object?> container,
|
||||
required final Map<String, Object?> history,
|
||||
required final Map<String, Object?> settings,
|
||||
}) = _$BackupV2Impl;
|
||||
const _BackupV2._() : super._();
|
||||
|
||||
factory _BackupV2.fromJson(Map<String, dynamic> json) =
|
||||
_$BackupV2Impl.fromJson;
|
||||
|
||||
@override
|
||||
int get version;
|
||||
@override
|
||||
int get date;
|
||||
@override
|
||||
Map<String, Object?> get spis;
|
||||
@override
|
||||
Map<String, Object?> get snippets;
|
||||
@override
|
||||
Map<String, Object?> get keys;
|
||||
@override
|
||||
Map<String, Object?> get container;
|
||||
@override
|
||||
Map<String, Object?> get history;
|
||||
@override
|
||||
Map<String, Object?> get settings;
|
||||
|
||||
/// Create a copy of BackupV2
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$BackupV2ImplCopyWith<_$BackupV2Impl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
final Map<String, Object?> _snippets;
|
||||
@override Map<String, Object?> get snippets {
|
||||
if (_snippets is EqualUnmodifiableMapView) return _snippets;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableMapView(_snippets);
|
||||
}
|
||||
|
||||
final Map<String, Object?> _keys;
|
||||
@override Map<String, Object?> get keys {
|
||||
if (_keys is EqualUnmodifiableMapView) return _keys;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableMapView(_keys);
|
||||
}
|
||||
|
||||
final Map<String, Object?> _container;
|
||||
@override Map<String, Object?> get container {
|
||||
if (_container is EqualUnmodifiableMapView) return _container;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableMapView(_container);
|
||||
}
|
||||
|
||||
final Map<String, Object?> _history;
|
||||
@override Map<String, Object?> get history {
|
||||
if (_history is EqualUnmodifiableMapView) return _history;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableMapView(_history);
|
||||
}
|
||||
|
||||
final Map<String, Object?> _settings;
|
||||
@override Map<String, Object?> get settings {
|
||||
if (_settings is EqualUnmodifiableMapView) return _settings;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableMapView(_settings);
|
||||
}
|
||||
|
||||
|
||||
/// Create a copy of BackupV2
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$BackupV2CopyWith<_BackupV2> get copyWith => __$BackupV2CopyWithImpl<_BackupV2>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$BackupV2ToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _BackupV2&&(identical(other.version, version) || other.version == version)&&(identical(other.date, date) || other.date == date)&&const DeepCollectionEquality().equals(other._spis, _spis)&&const DeepCollectionEquality().equals(other._snippets, _snippets)&&const DeepCollectionEquality().equals(other._keys, _keys)&&const DeepCollectionEquality().equals(other._container, _container)&&const DeepCollectionEquality().equals(other._history, _history)&&const DeepCollectionEquality().equals(other._settings, _settings));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,version,date,const DeepCollectionEquality().hash(_spis),const DeepCollectionEquality().hash(_snippets),const DeepCollectionEquality().hash(_keys),const DeepCollectionEquality().hash(_container),const DeepCollectionEquality().hash(_history),const DeepCollectionEquality().hash(_settings));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BackupV2(version: $version, date: $date, spis: $spis, snippets: $snippets, keys: $keys, container: $container, history: $history, settings: $settings)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$BackupV2CopyWith<$Res> implements $BackupV2CopyWith<$Res> {
|
||||
factory _$BackupV2CopyWith(_BackupV2 value, $Res Function(_BackupV2) _then) = __$BackupV2CopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
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
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$BackupV2CopyWithImpl<$Res>
|
||||
implements _$BackupV2CopyWith<$Res> {
|
||||
__$BackupV2CopyWithImpl(this._self, this._then);
|
||||
|
||||
final _BackupV2 _self;
|
||||
final $Res Function(_BackupV2) _then;
|
||||
|
||||
/// Create a copy of BackupV2
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? version = null,Object? date = null,Object? spis = null,Object? snippets = null,Object? keys = null,Object? container = null,Object? history = null,Object? settings = null,}) {
|
||||
return _then(_BackupV2(
|
||||
version: null == version ? _self.version : version // ignore: cast_nullable_to_non_nullable
|
||||
as int,date: null == date ? _self.date : date // ignore: cast_nullable_to_non_nullable
|
||||
as int,spis: null == spis ? _self._spis : spis // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, Object?>,snippets: null == snippets ? _self._snippets : snippets // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, Object?>,keys: null == keys ? _self._keys : keys // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, Object?>,container: null == container ? _self._container : container // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, Object?>,history: null == history ? _self._history : history // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, Object?>,settings: null == settings ? _self._settings : settings // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, Object?>,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
|
||||
@@ -6,26 +6,24 @@ part of 'backup2.dart';
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$BackupV2Impl _$$BackupV2ImplFromJson(Map<String, dynamic> json) =>
|
||||
_$BackupV2Impl(
|
||||
version: (json['version'] as num).toInt(),
|
||||
date: (json['date'] as num).toInt(),
|
||||
spis: json['spis'] as Map<String, dynamic>,
|
||||
snippets: json['snippets'] as Map<String, dynamic>,
|
||||
keys: json['keys'] as Map<String, dynamic>,
|
||||
container: json['container'] as Map<String, dynamic>,
|
||||
history: json['history'] as Map<String, dynamic>,
|
||||
settings: json['settings'] as Map<String, dynamic>,
|
||||
);
|
||||
_BackupV2 _$BackupV2FromJson(Map<String, dynamic> json) => _BackupV2(
|
||||
version: (json['version'] as num).toInt(),
|
||||
date: (json['date'] as num).toInt(),
|
||||
spis: json['spis'] as Map<String, dynamic>,
|
||||
snippets: json['snippets'] as Map<String, dynamic>,
|
||||
keys: json['keys'] as Map<String, dynamic>,
|
||||
container: json['container'] as Map<String, dynamic>,
|
||||
history: json['history'] as Map<String, dynamic>,
|
||||
settings: json['settings'] as Map<String, dynamic>,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$BackupV2ImplToJson(_$BackupV2Impl instance) =>
|
||||
<String, dynamic>{
|
||||
'version': instance.version,
|
||||
'date': instance.date,
|
||||
'spis': instance.spis,
|
||||
'snippets': instance.snippets,
|
||||
'keys': instance.keys,
|
||||
'container': instance.container,
|
||||
'history': instance.history,
|
||||
'settings': instance.settings,
|
||||
};
|
||||
Map<String, dynamic> _$BackupV2ToJson(_BackupV2 instance) => <String, dynamic>{
|
||||
'version': instance.version,
|
||||
'date': instance.date,
|
||||
'spis': instance.spis,
|
||||
'snippets': instance.snippets,
|
||||
'keys': instance.keys,
|
||||
'container': instance.container,
|
||||
'history': instance.history,
|
||||
'settings': instance.settings,
|
||||
};
|
||||
|
||||
164
lib/data/model/app/bak/backup_service.dart
Normal file
164
lib/data/model/app/bak/backup_service.dart
Normal file
@@ -0,0 +1,164 @@
|
||||
import 'package:computer/computer.dart';
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
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/backup_source.dart';
|
||||
import 'package:server_box/data/model/app/bak/utils.dart';
|
||||
|
||||
/// Service class for handling backup operations
|
||||
class BackupService {
|
||||
/// Perform backup operation with the given source
|
||||
static Future<void> backup(BuildContext context, BackupSource source) async {
|
||||
try {
|
||||
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);
|
||||
|
||||
if (source is ClipboardBackupSource) {
|
||||
context.showSnackBar(libL10n.success);
|
||||
}
|
||||
} catch (e, s) {
|
||||
context.showErrDialog(e, s, libL10n.backup);
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform restore operation with the given source
|
||||
static Future<void> restore(BuildContext context, BackupSource source) async {
|
||||
final text = await source.getContent();
|
||||
if (text == null) {
|
||||
// Show empty message for clipboard source
|
||||
if (source is ClipboardBackupSource) {
|
||||
context.showSnackBar(libL10n.empty);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await restoreFromText(context, text);
|
||||
}
|
||||
|
||||
/// Handle restore from text with decryption support
|
||||
static Future<void> restoreFromText(BuildContext context, String text) async {
|
||||
// Check if backup is encrypted
|
||||
final isEncrypted = Cryptor.isEncrypted(text);
|
||||
String? password;
|
||||
|
||||
if (!isEncrypted) {
|
||||
try {
|
||||
final (backup, err) = await context.showLoadingDialog(
|
||||
fn: () => Computer.shared.start(MergeableUtils.fromJsonString, text),
|
||||
);
|
||||
if (err != null || backup == null) return;
|
||||
|
||||
await _confirmAndRestore(context, backup);
|
||||
} catch (e, s) {
|
||||
Loggers.app.warning('Import backup failed', e, s);
|
||||
context.showErrDialog(e, s, libL10n.restore);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Try with saved password first
|
||||
final savedPassword = await SecureStoreProps.bakPwd.read();
|
||||
if (savedPassword != null && savedPassword.isNotEmpty) {
|
||||
try {
|
||||
final (backup, err) = await context.showLoadingDialog(
|
||||
fn: () => Computer.shared.start((args) => MergeableUtils.fromJsonString(args.$1, args.$2), (
|
||||
text,
|
||||
savedPassword,
|
||||
)),
|
||||
);
|
||||
if (err == null && backup != null) {
|
||||
await _confirmAndRestore(context, backup);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// Saved password failed, will prompt for manual input
|
||||
}
|
||||
}
|
||||
|
||||
// Prompt for password with retry logic
|
||||
while (true) {
|
||||
password = await _showPasswordDialog(context, title: libL10n.pwd, hint: l10n.backupEncrypted);
|
||||
if (password == null) return; // User cancelled
|
||||
|
||||
try {
|
||||
final (backup, err) = await context.showLoadingDialog(
|
||||
fn: () => Computer.shared.start((args) => MergeableUtils.fromJsonString(args.$1, args.$2), (
|
||||
text,
|
||||
password,
|
||||
)),
|
||||
);
|
||||
if (err != null || backup == null) continue;
|
||||
|
||||
await _confirmAndRestore(context, backup);
|
||||
return;
|
||||
} catch (e) {
|
||||
if (e.toString().contains('incorrect password') || e.toString().contains('Failed to decrypt')) {
|
||||
final retry = await context.showRoundDialog<bool>(
|
||||
title: l10n.backupPasswordWrong,
|
||||
child: Text(l10n.backupPasswordWrong),
|
||||
actions: [
|
||||
TextButton(onPressed: () => context.pop(false), child: Text(libL10n.cancel)),
|
||||
TextButton(onPressed: () => context.pop(true), child: Text(libL10n.retry)),
|
||||
],
|
||||
);
|
||||
if (retry != true) return;
|
||||
continue; // Try again
|
||||
} else {
|
||||
// Other error, show and exit
|
||||
context.showErrDialog(e, null, libL10n.restore);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirm and execute restore operation
|
||||
static Future<void> _confirmAndRestore(BuildContext context, (dynamic, String) backup) async {
|
||||
await context.showRoundDialog(
|
||||
title: libL10n.restore,
|
||||
child: Text(libL10n.askContinue('${libL10n.restore} ${libL10n.backup}(${backup.$2})')),
|
||||
actions: Btn.ok(
|
||||
onTap: () async {
|
||||
await backup.$1.merge(force: true);
|
||||
context.pop();
|
||||
},
|
||||
).toList,
|
||||
);
|
||||
}
|
||||
|
||||
/// Show password input dialog
|
||||
static Future<String?> _showPasswordDialog(
|
||||
BuildContext context, {
|
||||
String? initial,
|
||||
String? title,
|
||||
String? hint,
|
||||
}) async {
|
||||
final controller = TextEditingController(text: initial ?? '');
|
||||
final result = await context.showRoundDialog<String>(
|
||||
title: title ?? libL10n.pwd,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(hint ?? l10n.backupPasswordTip, style: UIs.textGrey),
|
||||
UIs.height13,
|
||||
Input(
|
||||
label: l10n.backupPassword,
|
||||
controller: controller,
|
||||
obscureText: true,
|
||||
onSubmitted: (_) => context.pop(controller.text),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
Btn.cancel(),
|
||||
TextButton(onPressed: () => context.pop(controller.text), child: Text(libL10n.ok)),
|
||||
],
|
||||
);
|
||||
controller.dispose();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
62
lib/data/model/app/bak/backup_source.dart
Normal file
62
lib/data/model/app/bak/backup_source.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Abstract interface for backup content sources
|
||||
abstract class BackupSource {
|
||||
/// Get content from this source for restore
|
||||
Future<String?> getContent();
|
||||
|
||||
/// Save content to this source for backup
|
||||
Future<void> saveContent(String filePath);
|
||||
|
||||
/// Display name for this source
|
||||
String get displayName;
|
||||
|
||||
/// Icon for this source
|
||||
IconData get icon;
|
||||
}
|
||||
|
||||
/// File-based backup source
|
||||
class FileBackupSource implements BackupSource {
|
||||
@override
|
||||
Future<String?> getContent() async {
|
||||
return await Pfs.pickFileString();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveContent(String filePath) async {
|
||||
await Pfs.sharePaths(paths: [filePath]);
|
||||
}
|
||||
|
||||
@override
|
||||
String get displayName => libL10n.file;
|
||||
|
||||
@override
|
||||
IconData get icon => Icons.file_open;
|
||||
}
|
||||
|
||||
/// Clipboard-based backup source
|
||||
class ClipboardBackupSource implements BackupSource {
|
||||
@override
|
||||
Future<String?> getContent() async {
|
||||
final text = await Pfs.paste();
|
||||
if (text == null || text.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveContent(String filePath) async {
|
||||
final content = await File(filePath).readAsString();
|
||||
Pfs.copy(content);
|
||||
}
|
||||
|
||||
@override
|
||||
String get displayName => libL10n.clipboard;
|
||||
|
||||
@override
|
||||
IconData get icon => Icons.content_paste;
|
||||
}
|
||||
@@ -3,9 +3,9 @@ import 'package:server_box/data/model/app/bak/backup.dart';
|
||||
import 'package:server_box/data/model/app/bak/backup2.dart';
|
||||
|
||||
abstract final class MergeableUtils {
|
||||
static (Mergeable, String) fromJsonString(String json) {
|
||||
static (Mergeable, String) fromJsonString(String json, [String? password]) {
|
||||
try {
|
||||
final bak = BackupV2.fromJsonString(json);
|
||||
final bak = BackupV2.fromJsonString(json, password);
|
||||
return (bak, DateTime.fromMillisecondsSinceEpoch(bak.date).hms());
|
||||
} catch (e) {
|
||||
final bak = Backup.fromJsonString(json);
|
||||
|
||||
@@ -1,29 +1,19 @@
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
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> {
|
||||
SSHErr({required super.type, super.message});
|
||||
const SSHErr({required super.type, super.message});
|
||||
|
||||
@override
|
||||
String? get solution => switch (type) {
|
||||
SSHErrType.chdir => l10n.needHomeDir,
|
||||
SSHErrType.auth => l10n.authFailTip,
|
||||
SSHErrType.writeScript => l10n.writeScriptFailTip,
|
||||
SSHErrType.noPrivateKey => l10n.noPrivateKeyTip,
|
||||
_ => null,
|
||||
};
|
||||
SSHErrType.chdir => l10n.needHomeDir,
|
||||
SSHErrType.auth => l10n.authFailTip,
|
||||
SSHErrType.writeScript => l10n.writeScriptFailTip,
|
||||
SSHErrType.noPrivateKey => l10n.noPrivateKeyTip,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
enum ContainerErrType {
|
||||
@@ -39,47 +29,34 @@ enum ContainerErrType {
|
||||
}
|
||||
|
||||
class ContainerErr extends Err<ContainerErrType> {
|
||||
ContainerErr({required super.type, super.message});
|
||||
const ContainerErr({required super.type, super.message});
|
||||
|
||||
@override
|
||||
String? get solution => null;
|
||||
}
|
||||
|
||||
enum ICloudErrType {
|
||||
generic,
|
||||
notFound,
|
||||
multipleFiles,
|
||||
}
|
||||
enum ICloudErrType { generic, notFound, multipleFiles }
|
||||
|
||||
class ICloudErr extends Err<ICloudErrType> {
|
||||
ICloudErr({required super.type, super.message});
|
||||
const ICloudErr({required super.type, super.message});
|
||||
|
||||
@override
|
||||
String? get solution => null;
|
||||
}
|
||||
|
||||
enum WebdavErrType {
|
||||
generic,
|
||||
notFound,
|
||||
;
|
||||
}
|
||||
enum WebdavErrType { generic, notFound }
|
||||
|
||||
class WebdavErr extends Err<WebdavErrType> {
|
||||
WebdavErr({required super.type, super.message});
|
||||
const WebdavErr({required super.type, super.message});
|
||||
|
||||
@override
|
||||
String? get solution => null;
|
||||
}
|
||||
|
||||
enum PveErrType {
|
||||
unknown,
|
||||
net,
|
||||
loginFailed,
|
||||
;
|
||||
}
|
||||
enum PveErrType { unknown, net, loginFailed }
|
||||
|
||||
class PveErr extends Err<PveErrType> {
|
||||
PveErr({required super.type, super.message});
|
||||
const PveErr({required super.type, super.message});
|
||||
|
||||
@override
|
||||
String? get solution => null;
|
||||
|
||||
@@ -8,7 +8,7 @@ enum ContainerMenu {
|
||||
restart,
|
||||
rm,
|
||||
logs,
|
||||
terminal,
|
||||
terminal
|
||||
//stats,
|
||||
;
|
||||
|
||||
@@ -27,22 +27,22 @@ enum ContainerMenu {
|
||||
}
|
||||
|
||||
IconData get icon => switch (this) {
|
||||
ContainerMenu.start => Icons.play_arrow,
|
||||
ContainerMenu.stop => Icons.stop,
|
||||
ContainerMenu.restart => Icons.restart_alt,
|
||||
ContainerMenu.rm => Icons.delete,
|
||||
ContainerMenu.logs => Icons.logo_dev,
|
||||
ContainerMenu.terminal => Icons.terminal,
|
||||
// DockerMenuType.stats => Icons.bar_chart,
|
||||
};
|
||||
ContainerMenu.start => Icons.play_arrow,
|
||||
ContainerMenu.stop => Icons.stop,
|
||||
ContainerMenu.restart => Icons.restart_alt,
|
||||
ContainerMenu.rm => Icons.delete,
|
||||
ContainerMenu.logs => Icons.logo_dev,
|
||||
ContainerMenu.terminal => Icons.terminal,
|
||||
// DockerMenuType.stats => Icons.bar_chart,
|
||||
};
|
||||
|
||||
String get toStr => switch (this) {
|
||||
ContainerMenu.start => l10n.start,
|
||||
ContainerMenu.stop => l10n.stop,
|
||||
ContainerMenu.restart => l10n.restart,
|
||||
ContainerMenu.rm => libL10n.delete,
|
||||
ContainerMenu.logs => libL10n.log,
|
||||
ContainerMenu.terminal => l10n.terminal,
|
||||
// DockerMenuType.stats => s.stats,
|
||||
};
|
||||
ContainerMenu.start => l10n.start,
|
||||
ContainerMenu.stop => l10n.stop,
|
||||
ContainerMenu.restart => l10n.restart,
|
||||
ContainerMenu.rm => libL10n.delete,
|
||||
ContainerMenu.logs => libL10n.log,
|
||||
ContainerMenu.terminal => l10n.terminal,
|
||||
// DockerMenuType.stats => s.stats,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@ enum ServerFuncBtn {
|
||||
snippet(),
|
||||
iperf(),
|
||||
// pve(),
|
||||
systemd(1058),
|
||||
;
|
||||
systemd(1058);
|
||||
|
||||
final int? addedVersion;
|
||||
|
||||
@@ -41,24 +40,24 @@ enum ServerFuncBtn {
|
||||
].map((e) => e.index).toList();
|
||||
|
||||
IconData get icon => switch (this) {
|
||||
sftp => Icons.insert_drive_file,
|
||||
snippet => Icons.code,
|
||||
//pkg => Icons.system_security_update,
|
||||
container => FontAwesome.docker_brand,
|
||||
process => Icons.list_alt_outlined,
|
||||
terminal => Icons.terminal,
|
||||
iperf => Icons.speed,
|
||||
systemd => MingCute.plugin_2_fill,
|
||||
};
|
||||
sftp => Icons.insert_drive_file,
|
||||
snippet => Icons.code,
|
||||
//pkg => Icons.system_security_update,
|
||||
container => FontAwesome.docker_brand,
|
||||
process => Icons.list_alt_outlined,
|
||||
terminal => Icons.terminal,
|
||||
iperf => Icons.speed,
|
||||
systemd => MingCute.plugin_2_fill,
|
||||
};
|
||||
|
||||
String get toStr => switch (this) {
|
||||
sftp => 'SFTP',
|
||||
snippet => l10n.snippet,
|
||||
//pkg => l10n.pkg,
|
||||
container => l10n.container,
|
||||
process => l10n.process,
|
||||
terminal => l10n.terminal,
|
||||
iperf => 'iperf',
|
||||
systemd => 'Systemd',
|
||||
};
|
||||
sftp => 'SFTP',
|
||||
snippet => l10n.snippet,
|
||||
//pkg => l10n.pkg,
|
||||
container => l10n.container,
|
||||
process => l10n.process,
|
||||
terminal => l10n.terminal,
|
||||
iperf => 'iperf',
|
||||
systemd => 'Systemd',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,16 +8,16 @@ enum NetViewType {
|
||||
traffic;
|
||||
|
||||
NetViewType get next => switch (this) {
|
||||
conn => speed,
|
||||
speed => traffic,
|
||||
traffic => conn,
|
||||
};
|
||||
conn => speed,
|
||||
speed => traffic,
|
||||
traffic => conn,
|
||||
};
|
||||
|
||||
String get toStr => switch (this) {
|
||||
NetViewType.conn => l10n.conn,
|
||||
NetViewType.traffic => l10n.traffic,
|
||||
NetViewType.speed => l10n.speed,
|
||||
};
|
||||
NetViewType.conn => l10n.conn,
|
||||
NetViewType.traffic => l10n.traffic,
|
||||
NetViewType.speed => l10n.speed,
|
||||
};
|
||||
|
||||
/// If no device is specified, return the cached value (only real devices,
|
||||
/// such as ethX, wlanX...).
|
||||
@@ -26,32 +26,17 @@ enum NetViewType {
|
||||
try {
|
||||
switch (this) {
|
||||
case NetViewType.conn:
|
||||
return (
|
||||
'${l10n.conn}:\n${ss.tcp.maxConn}',
|
||||
'${libL10n.fail}:\n${ss.tcp.fail}',
|
||||
);
|
||||
return ('${l10n.conn}:\n${ss.tcp.maxConn}', '${libL10n.fail}:\n${ss.tcp.fail}');
|
||||
case NetViewType.speed:
|
||||
if (notSepcifyDev) {
|
||||
return (
|
||||
'↓:\n${ss.netSpeed.cachedVals.speedIn}',
|
||||
'↑:\n${ss.netSpeed.cachedVals.speedOut}',
|
||||
);
|
||||
return ('↓:\n${ss.netSpeed.cachedVals.speedIn}', '↑:\n${ss.netSpeed.cachedVals.speedOut}');
|
||||
}
|
||||
return (
|
||||
'↓:\n${ss.netSpeed.speedIn(device: dev)}',
|
||||
'↑:\n${ss.netSpeed.speedOut(device: dev)}',
|
||||
);
|
||||
return ('↓:\n${ss.netSpeed.speedIn(device: dev)}', '↑:\n${ss.netSpeed.speedOut(device: dev)}');
|
||||
case NetViewType.traffic:
|
||||
if (notSepcifyDev) {
|
||||
return (
|
||||
'↓:\n${ss.netSpeed.cachedVals.sizeIn}',
|
||||
'↑:\n${ss.netSpeed.cachedVals.sizeOut}',
|
||||
);
|
||||
return ('↓:\n${ss.netSpeed.cachedVals.sizeIn}', '↑:\n${ss.netSpeed.cachedVals.sizeOut}');
|
||||
}
|
||||
return (
|
||||
'↓:\n${ss.netSpeed.sizeIn(device: dev)}',
|
||||
'↑:\n${ss.netSpeed.sizeOut(device: dev)}',
|
||||
);
|
||||
return ('↓:\n${ss.netSpeed.sizeIn(device: dev)}', '↑:\n${ss.netSpeed.sizeOut(device: dev)}');
|
||||
}
|
||||
} catch (e, s) {
|
||||
Loggers.app.warning('NetViewType.build', e, s);
|
||||
@@ -60,14 +45,14 @@ enum NetViewType {
|
||||
}
|
||||
|
||||
int toJson() => switch (this) {
|
||||
NetViewType.conn => 0,
|
||||
NetViewType.speed => 1,
|
||||
NetViewType.traffic => 2,
|
||||
};
|
||||
NetViewType.conn => 0,
|
||||
NetViewType.speed => 1,
|
||||
NetViewType.traffic => 2,
|
||||
};
|
||||
|
||||
static NetViewType fromJson(int json) => switch (json) {
|
||||
0 => NetViewType.conn,
|
||||
1 => NetViewType.speed,
|
||||
_ => NetViewType.traffic,
|
||||
};
|
||||
0 => NetViewType.conn,
|
||||
1 => NetViewType.speed,
|
||||
_ => NetViewType.traffic,
|
||||
};
|
||||
}
|
||||
|
||||
315
lib/data/model/app/scripts/cmd_types.dart
Normal file
315
lib/data/model/app/scripts/cmd_types.dart
Normal file
@@ -0,0 +1,315 @@
|
||||
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/data/model/app/scripts/script_consts.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
|
||||
sealed class ShellCmdType implements Enum {
|
||||
String get cmd;
|
||||
|
||||
/// Get command-specific separator
|
||||
String get separator;
|
||||
|
||||
/// Get command-specific divider (separator with echo and formatting)
|
||||
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
|
||||
enum StatusCmdType implements ShellCmdType {
|
||||
echo('echo ${SystemType.linuxSign}'),
|
||||
time('date +%s'),
|
||||
net('cat /proc/net/dev'),
|
||||
sys('cat /etc/*-release | grep ^PRETTY_NAME'),
|
||||
cpu('cat /proc/stat | grep cpu'),
|
||||
uptime('uptime'),
|
||||
conn('cat /proc/net/snmp'),
|
||||
disk(
|
||||
'(lsblk --bytes --json --output '
|
||||
'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'"),
|
||||
tempType('cat /sys/class/thermal/thermal_zone*/type'),
|
||||
tempVal('cat /sys/class/thermal/thermal_zone*/temp'),
|
||||
host('cat /etc/hostname'),
|
||||
diskio('cat /proc/diskstats'),
|
||||
|
||||
/// Get battery information from Linux power supply subsystem
|
||||
///
|
||||
/// Reads battery data from sysfs power supply interface:
|
||||
/// - Iterates through all power supply devices in /sys/class/power_supply/
|
||||
/// - Each device has a uevent file with key-value pairs of power supply properties
|
||||
/// - Includes battery level, status, technology type, and other attributes
|
||||
/// - Works with laptops, UPS devices, and other power supplies
|
||||
/// - Adds echo after each file to separate multiple power supplies
|
||||
/// - Returns empty if no power supplies are detected (e.g., desktop systems)
|
||||
battery('for f in /sys/class/power_supply/*/uevent; do cat "\$f"; echo; done'),
|
||||
|
||||
/// Get NVIDIA GPU information using nvidia-smi in XML format
|
||||
/// Requires NVIDIA drivers and nvidia-smi utility to be installed
|
||||
nvidia('nvidia-smi -q -x'),
|
||||
|
||||
/// Get AMD GPU information using multiple fallback methods
|
||||
///
|
||||
/// This command tries three different AMD monitoring tools in order of preference:
|
||||
/// 1. amd-smi: Modern AMD System Management Interface (ROCm 5.0+)
|
||||
/// - Uses 'amd-smi list --json' to get GPU list
|
||||
/// - Uses 'amd-smi metric --json' to get performance metrics
|
||||
/// 2. rocm-smi: ROCm System Management Interface (older versions)
|
||||
/// - First tries '--json' output format if supported
|
||||
/// - Falls back to human-readable format with comprehensive metrics
|
||||
/// 3. radeontop: Real-time GPU usage monitor for older AMD cards
|
||||
/// - Uses 2-second timeout to avoid hanging
|
||||
/// - Skips header line with 'tail -n +2'
|
||||
/// - Outputs single line of usage data
|
||||
///
|
||||
/// If none of these tools are available, outputs error message
|
||||
amd(
|
||||
'if command -v amd-smi >/dev/null 2>&1; then '
|
||||
'amd-smi list --json && amd-smi metric --json; '
|
||||
'elif command -v rocm-smi >/dev/null 2>&1; then '
|
||||
'rocm-smi --json || rocm-smi --showunique --showuse --showtemp '
|
||||
'--showfan --showclocks --showmemuse --showpower; '
|
||||
'elif command -v radeontop >/dev/null 2>&1; then '
|
||||
'timeout 2s radeontop -d - -l 1 | tail -n +2; '
|
||||
'else echo "No AMD GPU monitoring tools found"; fi',
|
||||
),
|
||||
sensors('sensors'),
|
||||
|
||||
/// Get SMART disk health information for all storage devices
|
||||
///
|
||||
/// Uses a combination of lsblk and smartctl to collect disk health data:
|
||||
/// - lsblk -dn -o KNAME lists all block devices (kernel names only, no dependencies)
|
||||
/// - For each device, runs smartctl with -a (all info) and -j (JSON output)
|
||||
/// - Targets raw device nodes in /dev/ (e.g., /dev/sda, /dev/nvme0n1)
|
||||
/// - Adds echo after each device to separate output blocks
|
||||
/// - May require elevated privileges for some drives
|
||||
/// - smartctl must be installed (part of smartmontools package)
|
||||
diskSmart('for d in \$(lsblk -dn -o KNAME); do smartctl -a -j /dev/\$d; echo; done'),
|
||||
cpuBrand('cat /proc/cpuinfo | grep "model name"');
|
||||
|
||||
@override
|
||||
final String cmd;
|
||||
|
||||
const StatusCmdType(this.cmd);
|
||||
|
||||
@override
|
||||
String get separator => ScriptConstants.getCmdSeparator(name);
|
||||
|
||||
@override
|
||||
String get divider => ScriptConstants.getCmdDivider(name);
|
||||
|
||||
@override
|
||||
CmdTypeSys get sysType => CmdTypeSys.linux;
|
||||
}
|
||||
|
||||
/// BSD/macOS status commands
|
||||
enum BSDStatusCmdType implements ShellCmdType {
|
||||
echo('echo ${SystemType.bsdSign}'),
|
||||
time('date +%s'),
|
||||
net('netstat -ibn'),
|
||||
sys('uname -or'),
|
||||
cpu('top -l 1 | grep "CPU usage"'),
|
||||
uptime('uptime'),
|
||||
disk('df -k'), // Keep df -k for BSD systems as lsblk is not available on macOS/BSD
|
||||
mem('top -l 1 | grep PhysMem'),
|
||||
host('hostname'),
|
||||
cpuBrand('sysctl -n machdep.cpu.brand_string');
|
||||
|
||||
@override
|
||||
final String cmd;
|
||||
|
||||
const BSDStatusCmdType(this.cmd);
|
||||
|
||||
@override
|
||||
String get separator => ScriptConstants.getCmdSeparator(name);
|
||||
|
||||
@override
|
||||
String get divider => ScriptConstants.getCmdDivider(name);
|
||||
|
||||
@override
|
||||
CmdTypeSys get sysType => CmdTypeSys.bsd;
|
||||
}
|
||||
|
||||
/// Windows PowerShell status commands
|
||||
enum WindowsStatusCmdType implements ShellCmdType {
|
||||
echo('echo ${SystemType.windowsSign}'),
|
||||
time('[DateTimeOffset]::UtcNow.ToUnixTimeSeconds()'),
|
||||
|
||||
/// Get network interface statistics using Windows Performance Counters
|
||||
///
|
||||
/// Uses Get-Counter to collect network I/O metrics from all network interfaces:
|
||||
/// - Collects bytes received and sent per second for all network interfaces
|
||||
/// - Takes 2 samples with 1 second interval to calculate rates
|
||||
/// - Outputs results in JSON format for easy parsing
|
||||
/// - Counter paths use double backslashes to escape PowerShell string literals
|
||||
net(
|
||||
r'Get-Counter -Counter '
|
||||
r'"\\NetworkInterface(*)\\Bytes Received/sec", '
|
||||
r'"\\NetworkInterface(*)\\Bytes Sent/sec" '
|
||||
r'-SampleInterval 1 -MaxSamples 2 | ConvertTo-Json',
|
||||
),
|
||||
sys('(Get-ComputerInfo).OsName'),
|
||||
cpu(
|
||||
'Get-WmiObject -Class Win32_Processor | '
|
||||
'Select-Object Name, LoadPercentage | ConvertTo-Json',
|
||||
),
|
||||
uptime('(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime'),
|
||||
conn('(netstat -an | findstr ESTABLISHED | Measure-Object -Line).Count'),
|
||||
disk(
|
||||
'Get-WmiObject -Class Win32_LogicalDisk | '
|
||||
'Select-Object DeviceID, Size, FreeSpace, FileSystem | ConvertTo-Json',
|
||||
),
|
||||
mem(
|
||||
'Get-WmiObject -Class Win32_OperatingSystem | '
|
||||
'Select-Object TotalVisibleMemorySize, FreePhysicalMemory | ConvertTo-Json',
|
||||
),
|
||||
|
||||
/// Get system temperature using Windows Management Instrumentation (WMI)
|
||||
///
|
||||
/// Queries the MSAcpi_ThermalZoneTemperature class from the WMI root/wmi namespace:
|
||||
/// - Uses Get-CimInstance to access ACPI thermal zone data
|
||||
/// - ErrorAction SilentlyContinue prevents errors on systems without thermal sensors
|
||||
/// - Converts temperature from 10ths of Kelvin to Celsius: (temp - 2732) / 10
|
||||
/// - Uses calculated property to perform the temperature conversion
|
||||
/// - Returns JSON with InstanceName and converted Temperature values
|
||||
/// - May return empty result on systems without ACPI thermal sensor support
|
||||
temp(
|
||||
'Get-CimInstance -ClassName MSAcpi_ThermalZoneTemperature '
|
||||
'-Namespace root/wmi -ErrorAction SilentlyContinue | '
|
||||
'Select-Object InstanceName, @{Name=\'Temperature\';'
|
||||
'Expression={[math]::Round((\$_.CurrentTemperature - 2732) / 10, 1)}} | '
|
||||
'ConvertTo-Json',
|
||||
),
|
||||
host(r'Write-Output $env:COMPUTERNAME'),
|
||||
|
||||
/// Get disk I/O statistics using Windows Performance Counters
|
||||
///
|
||||
/// Uses Get-Counter to collect disk I/O metrics from 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
|
||||
/// - Physical disk counters provide hardware-level I/O statistics
|
||||
/// - Outputs results in JSON format for parsing
|
||||
/// - Counter names use wildcard (*) to capture all disk instances
|
||||
diskio(
|
||||
r'Get-Counter -Counter '
|
||||
r'"\\PhysicalDisk(*)\\Disk Read Bytes/sec", '
|
||||
r'"\\PhysicalDisk(*)\\Disk Write Bytes/sec" '
|
||||
r'-SampleInterval 1 -MaxSamples 2 | ConvertTo-Json',
|
||||
),
|
||||
battery(
|
||||
'Get-WmiObject -Class Win32_Battery | '
|
||||
'Select-Object EstimatedChargeRemaining, BatteryStatus | ConvertTo-Json',
|
||||
),
|
||||
|
||||
/// Get NVIDIA GPU information on Windows
|
||||
///
|
||||
/// Checks if nvidia-smi is available before attempting to use it:
|
||||
/// - Uses Get-Command to test if nvidia-smi.exe exists in PATH
|
||||
/// - ErrorAction SilentlyContinue prevents PowerShell errors if not found
|
||||
/// - If available, runs nvidia-smi with -q (query) and -x (XML output) flags
|
||||
/// - If not available, outputs standard error message for consistent handling
|
||||
nvidia(
|
||||
'if (Get-Command nvidia-smi -ErrorAction SilentlyContinue) { '
|
||||
'nvidia-smi -q -x } else { echo "NVIDIA driver not found" }',
|
||||
),
|
||||
|
||||
/// Get AMD GPU information on Windows
|
||||
///
|
||||
/// Checks for AMD monitoring tools using similar pattern to Linux version:
|
||||
/// - Uses Get-Command to test if amd-smi.exe exists in PATH
|
||||
/// - ErrorAction SilentlyContinue prevents PowerShell errors if not found
|
||||
/// - If available, runs amd-smi list command with JSON output
|
||||
/// - If not available, outputs standard error message for consistent handling
|
||||
/// - Windows version is simpler than Linux due to fewer AMD tool variations
|
||||
amd(
|
||||
'if (Get-Command amd-smi -ErrorAction SilentlyContinue) { '
|
||||
'amd-smi list --json } else { echo "AMD driver not found" }',
|
||||
),
|
||||
sensors(
|
||||
'Get-CimInstance -ClassName Win32_TemperatureProbe '
|
||||
'-ErrorAction SilentlyContinue | '
|
||||
'Select-Object Name, CurrentReading | ConvertTo-Json',
|
||||
),
|
||||
|
||||
/// Get SMART disk health information on Windows using Storage cmdlets
|
||||
///
|
||||
/// Uses Windows PowerShell storage management cmdlets:
|
||||
/// - Get-PhysicalDisk retrieves all physical storage devices
|
||||
/// - Get-StorageReliabilityCounter gets SMART health data via pipeline
|
||||
/// - Selects key health metrics: DeviceId, Temperature, TemperatureMax, Wear, PowerOnHours
|
||||
/// - Outputs results in JSON format for consistent parsing
|
||||
/// - Works with NVMe, SATA, and other storage interfaces supported by Windows
|
||||
/// - May require elevated privileges on some systems
|
||||
diskSmart(
|
||||
'Get-PhysicalDisk | Get-StorageReliabilityCounter | '
|
||||
'Select-Object DeviceId, Temperature, TemperatureMax, Wear, PowerOnHours | '
|
||||
'ConvertTo-Json',
|
||||
),
|
||||
cpuBrand('(Get-WmiObject -Class Win32_Processor).Name');
|
||||
|
||||
@override
|
||||
final String cmd;
|
||||
|
||||
const WindowsStatusCmdType(this.cmd);
|
||||
|
||||
@override
|
||||
String get separator => ScriptConstants.getCmdSeparator(name);
|
||||
|
||||
@override
|
||||
String get divider => ScriptConstants.getCmdDivider(name);
|
||||
|
||||
@override
|
||||
CmdTypeSys get sysType => CmdTypeSys.windows;
|
||||
}
|
||||
|
||||
/// Extensions for StatusCmdType
|
||||
extension StatusCmdTypeX on StatusCmdType {
|
||||
String get i18n => switch (this) {
|
||||
StatusCmdType.sys => l10n.system,
|
||||
StatusCmdType.host => l10n.host,
|
||||
StatusCmdType.uptime => l10n.uptime,
|
||||
StatusCmdType.battery => l10n.battery,
|
||||
StatusCmdType.sensors => l10n.sensors,
|
||||
StatusCmdType.disk => l10n.disk,
|
||||
final val => val.name,
|
||||
};
|
||||
}
|
||||
|
||||
/// Extension for CommandType to find content in parsed map
|
||||
extension CommandTypeX on ShellCmdType {
|
||||
/// Find the command output from the parsed script output map
|
||||
String findInMap(Map<String, String> parsedOutput) {
|
||||
return parsedOutput[name] ?? '';
|
||||
}
|
||||
}
|
||||
275
lib/data/model/app/scripts/script_builders.dart
Normal file
275
lib/data/model/app/scripts/script_builders.dart
Normal file
@@ -0,0 +1,275 @@
|
||||
import 'package:server_box/data/model/app/scripts/cmd_types.dart';
|
||||
import 'package:server_box/data/model/app/scripts/script_consts.dart';
|
||||
import 'package:server_box/data/model/app/scripts/shell_func.dart';
|
||||
|
||||
/// Abstract base class for platform-specific script builders
|
||||
sealed class ScriptBuilder {
|
||||
const ScriptBuilder();
|
||||
|
||||
/// Generate a complete script for all shell functions
|
||||
String buildScript(Map<String, String>? customCmds, [List<String>? disabledCmdTypes]);
|
||||
|
||||
/// Get the script file name for this platform
|
||||
String get scriptFileName;
|
||||
|
||||
/// Get the command to install the script
|
||||
String getInstallCommand(String scriptDir, String scriptPath);
|
||||
|
||||
/// Get the execution command for a specific function
|
||||
String getExecCommand(String scriptPath, ShellFunc func);
|
||||
|
||||
/// Get custom commands string for this platform
|
||||
String getCustomCmdsString(ShellFunc func, Map<String, String>? customCmds);
|
||||
|
||||
/// Get the script header for this platform
|
||||
String get scriptHeader;
|
||||
}
|
||||
|
||||
/// Windows PowerShell script builder
|
||||
class WindowsScriptBuilder extends ScriptBuilder {
|
||||
const WindowsScriptBuilder();
|
||||
|
||||
@override
|
||||
String get scriptFileName => ScriptConstants.scriptFileWindows;
|
||||
|
||||
@override
|
||||
String get scriptHeader => ScriptConstants.windowsScriptHeader;
|
||||
|
||||
@override
|
||||
String getInstallCommand(String scriptDir, String scriptPath) {
|
||||
return 'New-Item -ItemType Directory -Force -Path \'$scriptDir\' | Out-Null; '
|
||||
'\$content = [System.Console]::In.ReadToEnd(); '
|
||||
'Set-Content -Path \'$scriptPath\' -Value \$content -Encoding UTF8';
|
||||
}
|
||||
|
||||
@override
|
||||
String getExecCommand(String scriptPath, ShellFunc func) {
|
||||
return 'powershell -ExecutionPolicy Bypass -File "$scriptPath" -${func.flag}';
|
||||
}
|
||||
|
||||
@override
|
||||
String getCustomCmdsString(ShellFunc func, Map<String, String>? customCmds) {
|
||||
if (func == ShellFunc.status && customCmds != null && customCmds.isNotEmpty) {
|
||||
final sb = StringBuffer();
|
||||
for (final e in customCmds.entries) {
|
||||
final cmdDivider = ScriptConstants.getCustomCmdSeparator(e.key);
|
||||
sb.writeln(' Write-Host "$cmdDivider"');
|
||||
sb.writeln(' ${e.value}');
|
||||
}
|
||||
return '\n$sb';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@override
|
||||
String buildScript(Map<String, String>? customCmds, [List<String>? disabledCmdTypes]) {
|
||||
final sb = StringBuffer();
|
||||
sb.write(scriptHeader);
|
||||
|
||||
// Write each function
|
||||
for (final func in ShellFunc.values) {
|
||||
final customCmdsStr = getCustomCmdsString(func, customCmds);
|
||||
|
||||
sb.write('''
|
||||
function ${func.name} {
|
||||
${_getWindowsCommand(func, disabledCmdTypes).split('\n').map((e) => e.isEmpty ? '' : ' $e').join('\n')}$customCmdsStr
|
||||
}
|
||||
|
||||
''');
|
||||
}
|
||||
|
||||
// Write switch case
|
||||
sb.write('''
|
||||
switch (\$args[0]) {
|
||||
''');
|
||||
for (final func in ShellFunc.values) {
|
||||
sb.write('''
|
||||
"-${func.flag}" { ${func.name} }
|
||||
''');
|
||||
}
|
||||
sb.write('''
|
||||
default { Write-Host "Invalid argument \$(\$args[0])" }
|
||||
}
|
||||
''');
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/// Get Windows-specific command for a shell function
|
||||
String _getWindowsCommand(ShellFunc func, [List<String>? disabledCmdTypes]) => switch (func) {
|
||||
ShellFunc.status => _getWindowsStatusCommand(disabledCmdTypes: disabledCmdTypes ?? []),
|
||||
ShellFunc.process => 'Get-Process | Select-Object ProcessName, Id, CPU, WorkingSet | ConvertTo-Json',
|
||||
ShellFunc.shutdown => 'Stop-Computer -Force',
|
||||
ShellFunc.reboot => 'Restart-Computer -Force',
|
||||
ShellFunc.suspend =>
|
||||
'Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Application]::SetSuspendState(\'Suspend\', \$false, \$false)',
|
||||
};
|
||||
|
||||
/// Get Windows status command with command-specific separators
|
||||
String _getWindowsStatusCommand({required List<String> disabledCmdTypes}) {
|
||||
final cmdTypes = WindowsStatusCmdType.values.where((e) => !disabledCmdTypes.contains(e.displayName));
|
||||
return cmdTypes.map((e) => '${e.divider}${e.cmd}').join('').trimRight(); // Remove trailing divider
|
||||
}
|
||||
}
|
||||
|
||||
/// Unix shell script builder
|
||||
class UnixScriptBuilder extends ScriptBuilder {
|
||||
const UnixScriptBuilder();
|
||||
|
||||
@override
|
||||
String get scriptFileName => ScriptConstants.scriptFile;
|
||||
|
||||
@override
|
||||
String get scriptHeader => ScriptConstants.unixScriptHeader;
|
||||
|
||||
@override
|
||||
String getInstallCommand(String scriptDir, String scriptPath) {
|
||||
return '''
|
||||
mkdir -p $scriptDir
|
||||
cat > $scriptPath
|
||||
chmod 755 $scriptPath
|
||||
''';
|
||||
}
|
||||
|
||||
@override
|
||||
String getExecCommand(String scriptPath, ShellFunc func) {
|
||||
return 'sh $scriptPath -${func.flag}';
|
||||
}
|
||||
|
||||
@override
|
||||
String getCustomCmdsString(ShellFunc func, Map<String, String>? customCmds) {
|
||||
if (func == ShellFunc.status && customCmds != null && customCmds.isNotEmpty) {
|
||||
final sb = StringBuffer();
|
||||
for (final e in customCmds.entries) {
|
||||
final cmdDivider = ScriptConstants.getCustomCmdSeparator(e.key);
|
||||
sb.writeln('echo "$cmdDivider"');
|
||||
sb.writeln(e.value);
|
||||
}
|
||||
return '\n$sb';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@override
|
||||
String buildScript(Map<String, String>? customCmds, [List<String>? disabledCmdTypes]) {
|
||||
final sb = StringBuffer();
|
||||
sb.write(scriptHeader);
|
||||
// Write each function
|
||||
for (final func in ShellFunc.values) {
|
||||
final customCmdsStr = getCustomCmdsString(func, customCmds);
|
||||
sb.write('''
|
||||
${func.name}() {
|
||||
${_getUnixCommand(func, disabledCmdTypes).split('\n').map((e) => '\t$e').join('\n')}
|
||||
$customCmdsStr
|
||||
}
|
||||
|
||||
''');
|
||||
}
|
||||
|
||||
// Write switch case
|
||||
sb.write('case \$1 in\n');
|
||||
for (final func in ShellFunc.values) {
|
||||
sb.write('''
|
||||
'-${func.flag}')
|
||||
${func.name}
|
||||
;;
|
||||
''');
|
||||
}
|
||||
sb.write('''
|
||||
*)
|
||||
echo "Invalid argument \$1"
|
||||
;;
|
||||
esac''');
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/// Get Unix-specific command for a shell function
|
||||
String _getUnixCommand(ShellFunc func, [List<String>? disabledCmdTypes]) {
|
||||
return switch (func) {
|
||||
ShellFunc.status => _getUnixStatusCommand(disabledCmdTypes: disabledCmdTypes ?? []),
|
||||
ShellFunc.process => _getUnixProcessCommand(),
|
||||
ShellFunc.shutdown => _getUnixShutdownCommand(),
|
||||
ShellFunc.reboot => _getUnixRebootCommand(),
|
||||
ShellFunc.suspend => _getUnixSuspendCommand(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Get Unix status command with OS detection
|
||||
String _getUnixStatusCommand({required List<String> disabledCmdTypes}) {
|
||||
// Generate command lists with command-specific separators, filtering disabled commands
|
||||
final filteredLinuxCmdTypes = StatusCmdType.values.where(
|
||||
(e) => !disabledCmdTypes.contains(e.displayName),
|
||||
);
|
||||
final linuxCommands = filteredLinuxCmdTypes.map((e) => '${e.divider}${e.cmd}').join('').trimRight();
|
||||
|
||||
final filteredBsdCmdTypes = BSDStatusCmdType.values.where(
|
||||
(e) => !disabledCmdTypes.contains(e.displayName),
|
||||
);
|
||||
final bsdCommands = filteredBsdCmdTypes.map((e) => '${e.divider}${e.cmd}').join('').trimRight();
|
||||
|
||||
return '''
|
||||
if [ "\$macSign" = "" ] && [ "\$bsdSign" = "" ]; then
|
||||
\t$linuxCommands
|
||||
else
|
||||
\t$bsdCommands
|
||||
fi''';
|
||||
}
|
||||
|
||||
/// Get Unix process command with busybox detection
|
||||
String _getUnixProcessCommand() {
|
||||
return '''
|
||||
if [ "\$macSign" = "" ] && [ "\$bsdSign" = "" ]; then
|
||||
\tif [ "\$isBusybox" != "" ]; then
|
||||
\t\tps w
|
||||
\telse
|
||||
\t\tps -aux
|
||||
\tfi
|
||||
else
|
||||
\tps -ax
|
||||
fi''';
|
||||
}
|
||||
|
||||
/// Get Unix shutdown command with privilege detection
|
||||
String _getUnixShutdownCommand() {
|
||||
return '''
|
||||
if [ "\$userId" = "0" ]; then
|
||||
\tshutdown -h now
|
||||
else
|
||||
\tsudo -S shutdown -h now
|
||||
fi''';
|
||||
}
|
||||
|
||||
/// Get Unix reboot command with privilege detection
|
||||
String _getUnixRebootCommand() {
|
||||
return '''
|
||||
if [ "\$userId" = "0" ]; then
|
||||
\treboot
|
||||
else
|
||||
\tsudo -S reboot
|
||||
fi''';
|
||||
}
|
||||
|
||||
/// Get Unix suspend command with privilege detection
|
||||
String _getUnixSuspendCommand() {
|
||||
return '''
|
||||
if [ "\$userId" = "0" ]; then
|
||||
\tsystemctl suspend
|
||||
else
|
||||
\tsudo -S systemctl suspend
|
||||
fi''';
|
||||
}
|
||||
}
|
||||
|
||||
/// Factory class to get appropriate script builder for platform
|
||||
class ScriptBuilderFactory {
|
||||
const ScriptBuilderFactory._();
|
||||
|
||||
/// Get the appropriate script builder based on platform
|
||||
static ScriptBuilder getBuilder(bool isWindows) {
|
||||
return isWindows ? const WindowsScriptBuilder() : const UnixScriptBuilder();
|
||||
}
|
||||
|
||||
/// Get all available builders (useful for testing)
|
||||
static List<ScriptBuilder> getAllBuilders() {
|
||||
return const [WindowsScriptBuilder(), UnixScriptBuilder()];
|
||||
}
|
||||
}
|
||||
150
lib/data/model/app/scripts/script_consts.dart
Normal file
150
lib/data/model/app/scripts/script_consts.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
import 'package:server_box/data/res/build_data.dart';
|
||||
|
||||
/// Constants used throughout the script system
|
||||
class ScriptConstants {
|
||||
const ScriptConstants._();
|
||||
|
||||
// Script file names
|
||||
static const String scriptFile = 'srvboxm_v${BuildData.script}.sh';
|
||||
static const String scriptFileWindows = 'srvboxm_v${BuildData.script}.ps1';
|
||||
|
||||
// Script directories
|
||||
static const String scriptDirHome = '~/.config/server_box';
|
||||
static const String scriptDirTmp = '/tmp/server_box';
|
||||
static const String scriptDirHomeWindows = '%USERPROFILE%/.config/server_box';
|
||||
static const String scriptDirTmpWindows = '%TEMP%/server_box';
|
||||
|
||||
// Command separators and dividers
|
||||
static const String separator = 'SrvBoxSep';
|
||||
|
||||
/// Custom command separator
|
||||
static const String customCmdSep = 'SrvBoxCusCmdSep';
|
||||
|
||||
/// Generate command-specific separator
|
||||
static String getCmdSeparator(String cmdName) => '$separator.$cmdName';
|
||||
|
||||
/// Generate command-specific divider for custom commands
|
||||
static String getCustomCmdSeparator(String cmdName) => '$customCmdSep.$cmdName';
|
||||
|
||||
/// Generate command-specific divider
|
||||
static String getCmdDivider(String cmdName) => '\necho ${getCmdSeparator(cmdName)}\n\t';
|
||||
|
||||
/// Parse script output into command-specific map
|
||||
static Map<String, String> parseScriptOutput(String raw) {
|
||||
final result = <String, String>{};
|
||||
|
||||
if (raw.isEmpty) return result;
|
||||
|
||||
// Parse line by line to properly handle command-specific separators
|
||||
final lines = raw.split('\n');
|
||||
String? currentCmd;
|
||||
final buffer = StringBuffer();
|
||||
|
||||
for (final line in lines) {
|
||||
if (line.startsWith('$separator.')) {
|
||||
// Save previous command content
|
||||
if (currentCmd != null) {
|
||||
result[currentCmd] = buffer.toString().trim();
|
||||
buffer.clear();
|
||||
}
|
||||
// Start new command
|
||||
currentCmd = line.substring('$separator.'.length);
|
||||
} else if (line.startsWith('$customCmdSep.')) {
|
||||
// Save previous command content
|
||||
if (currentCmd != null) {
|
||||
result[currentCmd] = buffer.toString().trim();
|
||||
buffer.clear();
|
||||
}
|
||||
// Start new custom command
|
||||
currentCmd = line.substring('$customCmdSep.'.length);
|
||||
} else if (currentCmd != null) {
|
||||
buffer.writeln(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last command
|
||||
if (currentCmd != null) {
|
||||
result[currentCmd] = buffer.toString().trim();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Path separators
|
||||
static const String unixPathSeparator = '/';
|
||||
static const String windowsPathSeparator = '\\';
|
||||
|
||||
// Script headers
|
||||
static const String unixScriptHeader =
|
||||
'''
|
||||
#!/bin/sh
|
||||
# Script for ServerBox app v1.0.${BuildData.build}
|
||||
# DO NOT delete this file while app is running
|
||||
|
||||
export LANG=en_US.UTF-8
|
||||
|
||||
# If macSign & bsdSign are both empty, then it's linux
|
||||
macSign=\$(uname -a 2>&1 | grep "Darwin")
|
||||
bsdSign=\$(uname -a 2>&1 | grep "BSD")
|
||||
|
||||
# Link /bin/sh to busybox?
|
||||
isBusybox=\$(ls -l /bin/sh | grep "busybox")
|
||||
|
||||
userId=\$(id -u)
|
||||
|
||||
exec 2>/dev/null
|
||||
|
||||
''';
|
||||
|
||||
static const String windowsScriptHeader =
|
||||
'''
|
||||
# PowerShell script for ServerBox app v1.0.${BuildData.build}
|
||||
# DO NOT delete this file while app is running
|
||||
|
||||
\$ErrorActionPreference = "SilentlyContinue"
|
||||
|
||||
''';
|
||||
}
|
||||
|
||||
/// Script path configuration and management
|
||||
class ScriptPaths {
|
||||
ScriptPaths._();
|
||||
|
||||
static final Map<String, String> _scriptDirMap = <String, String>{};
|
||||
|
||||
/// Get the script directory for the given [id].
|
||||
///
|
||||
/// Default is [ScriptConstants.scriptDirTmp]/[ScriptConstants.scriptFile],
|
||||
/// if this path is not accessible, it will be changed to
|
||||
/// [ScriptConstants.scriptDirHome]/[ScriptConstants.scriptFile].
|
||||
static String getScriptDir(String id, {bool isWindows = false}) {
|
||||
final defaultTmpDir = isWindows ? ScriptConstants.scriptDirTmpWindows : ScriptConstants.scriptDirTmp;
|
||||
_scriptDirMap[id] ??= defaultTmpDir;
|
||||
return _scriptDirMap[id]!;
|
||||
}
|
||||
|
||||
/// Switch between tmp and home directories for script storage
|
||||
static String switchScriptDir(String id, {bool isWindows = false}) {
|
||||
return switch (_scriptDirMap[id]) {
|
||||
ScriptConstants.scriptDirTmp => _scriptDirMap[id] = ScriptConstants.scriptDirHome,
|
||||
ScriptConstants.scriptDirTmpWindows => _scriptDirMap[id] = ScriptConstants.scriptDirHomeWindows,
|
||||
ScriptConstants.scriptDirHome => _scriptDirMap[id] = ScriptConstants.scriptDirTmp,
|
||||
ScriptConstants.scriptDirHomeWindows => _scriptDirMap[id] = ScriptConstants.scriptDirTmpWindows,
|
||||
_ =>
|
||||
_scriptDirMap[id] = isWindows ? ScriptConstants.scriptDirHomeWindows : ScriptConstants.scriptDirHome,
|
||||
};
|
||||
}
|
||||
|
||||
/// Get the full script path for the given [id]
|
||||
static String getScriptPath(String id, {bool isWindows = false}) {
|
||||
final dir = getScriptDir(id, isWindows: isWindows);
|
||||
final fileName = isWindows ? ScriptConstants.scriptFileWindows : ScriptConstants.scriptFile;
|
||||
final separator = isWindows ? ScriptConstants.windowsPathSeparator : ScriptConstants.unixPathSeparator;
|
||||
return '$dir$separator$fileName';
|
||||
}
|
||||
|
||||
/// Clear cached script directories (useful for testing)
|
||||
static void clearCache() {
|
||||
_scriptDirMap.clear();
|
||||
}
|
||||
}
|
||||
103
lib/data/model/app/scripts/shell_func.dart
Normal file
103
lib/data/model/app/scripts/shell_func.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
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/server/system.dart';
|
||||
|
||||
/// Shell functions available in the ServerBox application
|
||||
enum ShellFunc {
|
||||
status('SbStatus'),
|
||||
process('SbProcess'),
|
||||
shutdown('SbShutdown'),
|
||||
reboot('SbReboot'),
|
||||
suspend('SbSuspend');
|
||||
|
||||
/// The function name used in scripts
|
||||
final String name;
|
||||
|
||||
const ShellFunc(this.name);
|
||||
|
||||
/// Get the command line flag for this function
|
||||
String get flag => switch (this) {
|
||||
ShellFunc.process => 'p',
|
||||
ShellFunc.shutdown => 'sd',
|
||||
ShellFunc.reboot => 'r',
|
||||
ShellFunc.suspend => 'sp',
|
||||
ShellFunc.status => 's',
|
||||
};
|
||||
|
||||
/// Execute this shell function on the specified server
|
||||
String exec(String id, {SystemType? systemType, required String? customDir}) {
|
||||
final scriptPath = ShellFuncManager.getScriptPath(id, systemType: systemType, customDir: customDir);
|
||||
final isWindows = systemType == SystemType.windows;
|
||||
final builder = ScriptBuilderFactory.getBuilder(isWindows);
|
||||
|
||||
return builder.getExecCommand(scriptPath, this);
|
||||
}
|
||||
}
|
||||
|
||||
/// Manager class for shell function operations
|
||||
class ShellFuncManager {
|
||||
const ShellFuncManager._();
|
||||
|
||||
/// Normalize a directory path to ensure it doesn't end with trailing separators
|
||||
static String _normalizeDir(String dir, bool isWindows) {
|
||||
final separator = isWindows ? ScriptConstants.windowsPathSeparator : ScriptConstants.unixPathSeparator;
|
||||
|
||||
// Remove all trailing separators
|
||||
final pattern = RegExp('${RegExp.escape(separator)}+\$');
|
||||
return dir.replaceAll(pattern, '');
|
||||
}
|
||||
|
||||
/// Get the script directory for the given [id].
|
||||
///
|
||||
/// Checks for custom script directory first, then falls back to default.
|
||||
static String getScriptDir(String id, {SystemType? systemType, required String? customDir}) {
|
||||
final isWindows = systemType == SystemType.windows;
|
||||
|
||||
if (customDir != null) return _normalizeDir(customDir, isWindows);
|
||||
return ScriptPaths.getScriptDir(id, isWindows: isWindows);
|
||||
}
|
||||
|
||||
/// Switch between tmp and home directories for script storage
|
||||
static void switchScriptDir(String id, {SystemType? systemType}) {
|
||||
final isWindows = systemType == SystemType.windows;
|
||||
ScriptPaths.switchScriptDir(id, isWindows: isWindows);
|
||||
}
|
||||
|
||||
/// Get the full script path for the given [id]
|
||||
static String getScriptPath(String id, {SystemType? systemType, required String? customDir}) {
|
||||
if (customDir != null) {
|
||||
final isWindows = systemType == SystemType.windows;
|
||||
final normalizedDir = _normalizeDir(customDir, isWindows);
|
||||
final fileName = isWindows ? ScriptConstants.scriptFileWindows : ScriptConstants.scriptFile;
|
||||
final separator = isWindows ? ScriptConstants.windowsPathSeparator : ScriptConstants.unixPathSeparator;
|
||||
return '$normalizedDir$separator$fileName';
|
||||
}
|
||||
|
||||
final isWindows = systemType == SystemType.windows;
|
||||
return ScriptPaths.getScriptPath(id, isWindows: isWindows);
|
||||
}
|
||||
|
||||
/// Get the installation shell command for the script
|
||||
static String getInstallShellCmd(String id, {SystemType? systemType, required String? customDir}) {
|
||||
final scriptDir = getScriptDir(id, systemType: systemType, customDir: customDir);
|
||||
final isWindows = systemType == SystemType.windows;
|
||||
final normalizedDir = _normalizeDir(scriptDir, isWindows);
|
||||
final builder = ScriptBuilderFactory.getBuilder(isWindows);
|
||||
final separator = isWindows ? ScriptConstants.windowsPathSeparator : ScriptConstants.unixPathSeparator;
|
||||
final scriptPath = '$normalizedDir$separator${builder.scriptFileName}';
|
||||
|
||||
return builder.getInstallCommand(normalizedDir, scriptPath);
|
||||
}
|
||||
|
||||
/// Generate complete script based on system type
|
||||
static String allScript(
|
||||
Map<String, String>? customCmds, {
|
||||
SystemType? systemType,
|
||||
List<String>? disabledCmdTypes,
|
||||
}) {
|
||||
final isWindows = systemType == SystemType.windows;
|
||||
final builder = ScriptBuilderFactory.getBuilder(isWindows);
|
||||
|
||||
return builder.buildScript(customCmds, disabledCmdTypes);
|
||||
}
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
import 'package:server_box/core/extension/context/locale.dart';
|
||||
import 'package:server_box/data/model/server/system.dart';
|
||||
import 'package:server_box/data/provider/server.dart';
|
||||
import 'package:server_box/data/res/build_data.dart';
|
||||
|
||||
enum ShellFunc {
|
||||
status,
|
||||
//docker,
|
||||
process,
|
||||
shutdown,
|
||||
reboot,
|
||||
suspend;
|
||||
|
||||
static const seperator = 'SrvBoxSep';
|
||||
|
||||
/// The suffix `\t` is for formatting
|
||||
static const cmdDivider = '\necho $seperator\n\t';
|
||||
|
||||
/// srvboxm -> ServerBox Mobile
|
||||
static const scriptFile = 'srvboxm_v${BuildData.script}.sh';
|
||||
static const scriptDirHome = '~/.config/server_box';
|
||||
static const scriptDirTmp = '/tmp/server_box';
|
||||
|
||||
static final _scriptDirMap = <String, String>{};
|
||||
|
||||
/// Get the script directory for the given [id].
|
||||
///
|
||||
/// Default is [scriptDirTmp]/[scriptFile], if this path is not accessible,
|
||||
/// it will be changed to [scriptDirHome]/[scriptFile].
|
||||
static String getScriptDir(String id) {
|
||||
final customScriptDir = ServerProvider.pick(
|
||||
id: id,
|
||||
)?.value.spi.custom?.scriptDir;
|
||||
if (customScriptDir != null) return customScriptDir;
|
||||
return _scriptDirMap.putIfAbsent(id, () {
|
||||
return scriptDirTmp;
|
||||
});
|
||||
}
|
||||
|
||||
static void switchScriptDir(String id) => switch (_scriptDirMap[id]) {
|
||||
scriptDirTmp => _scriptDirMap[id] = scriptDirHome,
|
||||
scriptDirHome => _scriptDirMap[id] = scriptDirTmp,
|
||||
_ => _scriptDirMap[id] = scriptDirHome,
|
||||
};
|
||||
|
||||
static String getScriptPath(String id) {
|
||||
return '${getScriptDir(id)}/$scriptFile';
|
||||
}
|
||||
|
||||
static String getInstallShellCmd(String id) {
|
||||
final scriptDir = getScriptDir(id);
|
||||
final scriptPath = '$scriptDir/$scriptFile';
|
||||
return '''
|
||||
mkdir -p $scriptDir
|
||||
cat > $scriptPath
|
||||
chmod 755 $scriptPath
|
||||
''';
|
||||
}
|
||||
|
||||
String get flag => switch (this) {
|
||||
ShellFunc.process => 'p',
|
||||
ShellFunc.shutdown => 'sd',
|
||||
ShellFunc.reboot => 'r',
|
||||
ShellFunc.suspend => 'sp',
|
||||
ShellFunc.status => 's',
|
||||
// ShellFunc.docker=> 'd',
|
||||
};
|
||||
|
||||
String exec(String id) => 'sh ${getScriptPath(id)} -$flag';
|
||||
|
||||
String get name {
|
||||
switch (this) {
|
||||
case ShellFunc.status:
|
||||
return 'status';
|
||||
// case ShellFunc.docker:
|
||||
// // `dockeR` -> avoid conflict with `docker` command
|
||||
// return 'dockeR';
|
||||
case ShellFunc.process:
|
||||
return 'process';
|
||||
case ShellFunc.shutdown:
|
||||
return 'ShutDown';
|
||||
case ShellFunc.reboot:
|
||||
return 'Reboot';
|
||||
case ShellFunc.suspend:
|
||||
return 'Suspend';
|
||||
}
|
||||
}
|
||||
|
||||
String get _cmd {
|
||||
switch (this) {
|
||||
case ShellFunc.status:
|
||||
return '''
|
||||
if [ "\$macSign" = "" ] && [ "\$bsdSign" = "" ]; then
|
||||
\t${StatusCmdType.values.map((e) => e.cmd).join(cmdDivider)}
|
||||
else
|
||||
\t${BSDStatusCmdType.values.map((e) => e.cmd).join(cmdDivider)}
|
||||
fi''';
|
||||
// case ShellFunc.docker:
|
||||
// return '''
|
||||
// result=\$(docker version 2>&1 | grep "permission denied")
|
||||
// if [ "\$result" != "" ]; then
|
||||
// \t${_dockerCmds.join(_cmdDivider)}
|
||||
// else
|
||||
// \t${_dockerCmds.map((e) => "sudo -S $e").join(_cmdDivider)}
|
||||
// fi''';
|
||||
case ShellFunc.process:
|
||||
return '''
|
||||
if [ "\$macSign" = "" ] && [ "\$bsdSign" = "" ]; then
|
||||
\tif [ "\$isBusybox" != "" ]; then
|
||||
\t\tps w
|
||||
\telse
|
||||
\t\tps -aux
|
||||
\tfi
|
||||
else
|
||||
\tps -ax
|
||||
fi
|
||||
''';
|
||||
case ShellFunc.shutdown:
|
||||
return '''
|
||||
if [ "\$userId" = "0" ]; then
|
||||
\tshutdown -h now
|
||||
else
|
||||
\tsudo -S shutdown -h now
|
||||
fi''';
|
||||
case ShellFunc.reboot:
|
||||
return '''
|
||||
if [ "\$userId" = "0" ]; then
|
||||
\treboot
|
||||
else
|
||||
\tsudo -S reboot
|
||||
fi''';
|
||||
case ShellFunc.suspend:
|
||||
return '''
|
||||
if [ "\$userId" = "0" ]; then
|
||||
\tsystemctl suspend
|
||||
else
|
||||
\tsudo -S systemctl suspend
|
||||
fi''';
|
||||
}
|
||||
}
|
||||
|
||||
static String allScript(Map<String, String>? customCmds) {
|
||||
final sb = StringBuffer();
|
||||
sb.write('''
|
||||
#!/bin/sh
|
||||
# Script for ServerBox app v1.0.${BuildData.build}
|
||||
# DO NOT delete this file while app is running
|
||||
|
||||
export LANG=en_US.UTF-8
|
||||
|
||||
# If macSign & bsdSign are both empty, then it's linux
|
||||
macSign=\$(uname -a 2>&1 | grep "Darwin")
|
||||
bsdSign=\$(uname -a 2>&1 | grep "BSD")
|
||||
|
||||
# Link /bin/sh to busybox?
|
||||
isBusybox=\$(ls -l /bin/sh | grep "busybox")
|
||||
|
||||
userId=\$(id -u)
|
||||
|
||||
exec 2>/dev/null
|
||||
|
||||
''');
|
||||
// Write each func
|
||||
for (final func in values) {
|
||||
final customCmdsStr = () {
|
||||
if (func == ShellFunc.status &&
|
||||
customCmds != null &&
|
||||
customCmds.isNotEmpty) {
|
||||
return '$cmdDivider\n\t${customCmds.values.join(cmdDivider)}';
|
||||
}
|
||||
return '';
|
||||
}();
|
||||
sb.write('''
|
||||
${func.name}() {
|
||||
${func._cmd.split('\n').map((e) => '\t$e').join('\n')}
|
||||
$customCmdsStr
|
||||
}
|
||||
|
||||
''');
|
||||
}
|
||||
|
||||
// Write switch case
|
||||
sb.write('case \$1 in\n');
|
||||
for (final func in values) {
|
||||
sb.write('''
|
||||
'-${func.flag}')
|
||||
${func.name}
|
||||
;;
|
||||
''');
|
||||
}
|
||||
sb.write('''
|
||||
*)
|
||||
echo "Invalid argument \$1"
|
||||
;;
|
||||
esac''');
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
|
||||
extension EnumX on Enum {
|
||||
/// Find out the required segment from [segments]
|
||||
String find(List<String> segments) {
|
||||
return segments[index];
|
||||
}
|
||||
}
|
||||
|
||||
enum StatusCmdType {
|
||||
echo._('echo ${SystemType.linuxSign}'),
|
||||
time._('date +%s'),
|
||||
net._('cat /proc/net/dev'),
|
||||
sys._('cat /etc/*-release | grep ^PRETTY_NAME'),
|
||||
cpu._('cat /proc/stat | grep cpu'),
|
||||
uptime._('uptime'),
|
||||
conn._('cat /proc/net/snmp'),
|
||||
disk._(
|
||||
'lsblk --bytes --json --output FSTYPE,PATH,NAME,KNAME,MOUNTPOINT,FSSIZE,FSUSED,FSAVAIL,FSUSE%,UUID',
|
||||
),
|
||||
mem._("cat /proc/meminfo | grep -E 'Mem|Swap'"),
|
||||
tempType._('cat /sys/class/thermal/thermal_zone*/type'),
|
||||
tempVal._('cat /sys/class/thermal/thermal_zone*/temp'),
|
||||
host._('cat /etc/hostname'),
|
||||
diskio._('cat /proc/diskstats'),
|
||||
battery._(
|
||||
'for f in /sys/class/power_supply/*/uevent; do cat "\$f"; echo; done',
|
||||
),
|
||||
nvidia._('nvidia-smi -q -x'),
|
||||
sensors._('sensors'),
|
||||
diskSmart._('for d in \$(lsblk -dn -o KNAME); do smartctl -j /dev/\$d; echo; done'),
|
||||
cpuBrand._('cat /proc/cpuinfo | grep "model name"');
|
||||
|
||||
final String cmd;
|
||||
|
||||
const StatusCmdType._(this.cmd);
|
||||
}
|
||||
|
||||
enum BSDStatusCmdType {
|
||||
echo._('echo ${SystemType.bsdSign}'),
|
||||
time._('date +%s'),
|
||||
net._('netstat -ibn'),
|
||||
sys._('uname -or'),
|
||||
cpu._('top -l 1 | grep "CPU usage"'),
|
||||
uptime._('uptime'),
|
||||
// Keep df -k for BSD systems as lsblk is not available on macOS/BSD
|
||||
disk._('df -k'),
|
||||
mem._('top -l 1 | grep PhysMem'),
|
||||
//temp,
|
||||
host._('hostname'),
|
||||
cpuBrand._('sysctl -n machdep.cpu.brand_string');
|
||||
|
||||
final String cmd;
|
||||
|
||||
const BSDStatusCmdType._(this.cmd);
|
||||
}
|
||||
|
||||
extension StatusCmdTypeX on StatusCmdType {
|
||||
String get i18n => switch (this) {
|
||||
StatusCmdType.sys => l10n.system,
|
||||
StatusCmdType.host => l10n.host,
|
||||
StatusCmdType.uptime => l10n.uptime,
|
||||
StatusCmdType.battery => l10n.battery,
|
||||
final val => val.name,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_ce_flutter/adapters.dart';
|
||||
import 'package:icons_plus/icons_plus.dart';
|
||||
import 'package:server_box/core/extension/context/locale.dart';
|
||||
import 'package:server_box/view/page/server/tab/tab.dart';
|
||||
@@ -8,11 +9,18 @@ import 'package:server_box/view/page/snippet/list.dart';
|
||||
import 'package:server_box/view/page/ssh/tab.dart';
|
||||
import 'package:server_box/view/page/storage/local.dart';
|
||||
|
||||
part 'tab.g.dart';
|
||||
|
||||
@HiveType(typeId: 103)
|
||||
enum AppTab {
|
||||
@HiveField(0)
|
||||
server,
|
||||
@HiveField(1)
|
||||
ssh,
|
||||
@HiveField(2)
|
||||
file,
|
||||
snippet,
|
||||
@HiveField(3)
|
||||
snippet
|
||||
//settings,
|
||||
;
|
||||
|
||||
@@ -29,60 +37,60 @@ enum AppTab {
|
||||
NavigationDestination get navDestination {
|
||||
return switch (this) {
|
||||
server => NavigationDestination(
|
||||
icon: const Icon(BoxIcons.bx_server),
|
||||
label: l10n.server,
|
||||
selectedIcon: const Icon(BoxIcons.bxs_server),
|
||||
),
|
||||
icon: const Icon(BoxIcons.bx_server),
|
||||
label: l10n.server,
|
||||
selectedIcon: const Icon(BoxIcons.bxs_server),
|
||||
),
|
||||
// settings => NavigationDestination(
|
||||
// icon: const Icon(Icons.settings),
|
||||
// label: libL10n.setting,
|
||||
// selectedIcon: const Icon(Icons.settings),
|
||||
// ),
|
||||
ssh => const NavigationDestination(
|
||||
icon: Icon(Icons.terminal_outlined),
|
||||
label: 'SSH',
|
||||
selectedIcon: Icon(Icons.terminal),
|
||||
),
|
||||
icon: Icon(Icons.terminal_outlined),
|
||||
label: 'SSH',
|
||||
selectedIcon: Icon(Icons.terminal),
|
||||
),
|
||||
snippet => NavigationDestination(
|
||||
icon: const Icon(Icons.code),
|
||||
label: l10n.snippet,
|
||||
selectedIcon: const Icon(Icons.code),
|
||||
),
|
||||
icon: const Icon(Icons.code),
|
||||
label: l10n.snippet,
|
||||
selectedIcon: const Icon(Icons.code),
|
||||
),
|
||||
file => NavigationDestination(
|
||||
icon: const Icon(Icons.folder_open),
|
||||
label: libL10n.file,
|
||||
selectedIcon: const Icon(Icons.folder),
|
||||
),
|
||||
icon: const Icon(Icons.folder_open),
|
||||
label: libL10n.file,
|
||||
selectedIcon: const Icon(Icons.folder),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
NavigationRailDestination get navRailDestination {
|
||||
return switch (this) {
|
||||
server => NavigationRailDestination(
|
||||
icon: const Icon(BoxIcons.bx_server),
|
||||
label: Text(l10n.server),
|
||||
selectedIcon: const Icon(BoxIcons.bxs_server),
|
||||
),
|
||||
icon: const Icon(BoxIcons.bx_server),
|
||||
label: Text(l10n.server),
|
||||
selectedIcon: const Icon(BoxIcons.bxs_server),
|
||||
),
|
||||
// settings => NavigationRailDestination(
|
||||
// icon: const Icon(Icons.settings),
|
||||
// label: libL10n.setting,
|
||||
// selectedIcon: const Icon(Icons.settings),
|
||||
// ),
|
||||
ssh => const NavigationRailDestination(
|
||||
icon: Icon(Icons.terminal_outlined),
|
||||
label: Text('SSH'),
|
||||
selectedIcon: Icon(Icons.terminal),
|
||||
),
|
||||
icon: Icon(Icons.terminal_outlined),
|
||||
label: Text('SSH'),
|
||||
selectedIcon: Icon(Icons.terminal),
|
||||
),
|
||||
snippet => NavigationRailDestination(
|
||||
icon: const Icon(Icons.code),
|
||||
label: Text(l10n.snippet),
|
||||
selectedIcon: const Icon(Icons.code),
|
||||
),
|
||||
icon: const Icon(Icons.code),
|
||||
label: Text(l10n.snippet),
|
||||
selectedIcon: const Icon(Icons.code),
|
||||
),
|
||||
file => NavigationRailDestination(
|
||||
icon: const Icon(Icons.folder_open),
|
||||
label: Text(libL10n.file),
|
||||
selectedIcon: const Icon(Icons.folder),
|
||||
),
|
||||
icon: const Icon(Icons.folder_open),
|
||||
label: Text(libL10n.file),
|
||||
selectedIcon: const Icon(Icons.folder),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,4 +101,35 @@ enum AppTab {
|
||||
static List<NavigationRailDestination> get navRailDestinations {
|
||||
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;
|
||||
}
|
||||
@@ -24,14 +24,7 @@ final class PodmanImg implements ContainerImg {
|
||||
final int? size;
|
||||
final int? containers;
|
||||
|
||||
PodmanImg({
|
||||
this.repository,
|
||||
this.tag,
|
||||
this.id,
|
||||
this.created,
|
||||
this.size,
|
||||
this.containers,
|
||||
});
|
||||
PodmanImg({this.repository, this.tag, this.id, this.created, this.size, this.containers});
|
||||
|
||||
@override
|
||||
String? get sizeMB => size?.bytes2Str;
|
||||
@@ -39,28 +32,27 @@ final class PodmanImg implements ContainerImg {
|
||||
@override
|
||||
int? get containersCount => containers;
|
||||
|
||||
factory PodmanImg.fromRawJson(String str) =>
|
||||
PodmanImg.fromJson(json.decode(str));
|
||||
factory PodmanImg.fromRawJson(String str) => PodmanImg.fromJson(json.decode(str));
|
||||
|
||||
String toRawJson() => json.encode(toJson());
|
||||
|
||||
factory PodmanImg.fromJson(Map<String, dynamic> json) => PodmanImg(
|
||||
repository: json['repository'],
|
||||
tag: json['tag'],
|
||||
id: json['Id'],
|
||||
created: json['Created'],
|
||||
size: json['Size'],
|
||||
containers: json['Containers'],
|
||||
);
|
||||
repository: json['repository'],
|
||||
tag: json['tag'],
|
||||
id: json['Id'],
|
||||
created: json['Created'],
|
||||
size: json['Size'],
|
||||
containers: json['Containers'],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'repository': repository,
|
||||
'tag': tag,
|
||||
'Id': id,
|
||||
'Created': created,
|
||||
'Size': size,
|
||||
'Containers': containers,
|
||||
};
|
||||
'repository': repository,
|
||||
'tag': tag,
|
||||
'Id': id,
|
||||
'Created': created,
|
||||
'Size': size,
|
||||
'Containers': containers,
|
||||
};
|
||||
}
|
||||
|
||||
final class DockerImg implements ContainerImg {
|
||||
@@ -87,11 +79,9 @@ final class DockerImg implements ContainerImg {
|
||||
String? get sizeMB => size;
|
||||
|
||||
@override
|
||||
int? get containersCount =>
|
||||
containers == 'N/A' ? 0 : int.tryParse(containers);
|
||||
int? get containersCount => containers == 'N/A' ? 0 : int.tryParse(containers);
|
||||
|
||||
factory DockerImg.fromRawJson(String str) =>
|
||||
DockerImg.fromJson(json.decode(str));
|
||||
factory DockerImg.fromRawJson(String str) => DockerImg.fromJson(json.decode(str));
|
||||
|
||||
String toRawJson() => json.encode(toJson());
|
||||
|
||||
@@ -121,11 +111,11 @@ final class DockerImg implements ContainerImg {
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'Containers': containers,
|
||||
'CreatedAt': createdAt,
|
||||
'ID': id,
|
||||
'Repository': repository,
|
||||
'Size': size,
|
||||
'Tag': tag,
|
||||
};
|
||||
'Containers': containers,
|
||||
'CreatedAt': createdAt,
|
||||
'ID': id,
|
||||
'Repository': repository,
|
||||
'Size': size,
|
||||
'Tag': tag,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:convert';
|
||||
|
||||
import 'package:fl_lib/fl_lib.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/res/misc.dart';
|
||||
|
||||
@@ -10,7 +11,7 @@ sealed class ContainerPs {
|
||||
final String? image = null;
|
||||
String? get name;
|
||||
String? get cmd;
|
||||
bool get running;
|
||||
ContainerStatus get status;
|
||||
|
||||
String? cpu;
|
||||
String? mem;
|
||||
@@ -42,15 +43,7 @@ final class PodmanPs implements ContainerPs {
|
||||
@override
|
||||
String? disk;
|
||||
|
||||
PodmanPs({
|
||||
this.command,
|
||||
this.created,
|
||||
this.exited,
|
||||
this.id,
|
||||
this.image,
|
||||
this.names,
|
||||
this.startedAt,
|
||||
});
|
||||
PodmanPs({this.command, this.created, this.exited, this.id, this.image, this.names, this.startedAt});
|
||||
|
||||
@override
|
||||
String? get name => names?.firstOrNull;
|
||||
@@ -59,7 +52,7 @@ final class PodmanPs implements ContainerPs {
|
||||
String? get cmd => command?.firstOrNull;
|
||||
|
||||
@override
|
||||
bool get running => exited != true;
|
||||
ContainerStatus get status => ContainerStatus.fromPodmanExited(exited);
|
||||
|
||||
@override
|
||||
void parseStats(String s) {
|
||||
@@ -78,36 +71,29 @@ final class PodmanPs implements ContainerPs {
|
||||
disk = '${l10n.read} $diskOut / ${l10n.write} $diskIn';
|
||||
}
|
||||
|
||||
factory PodmanPs.fromRawJson(String str) =>
|
||||
PodmanPs.fromJson(json.decode(str));
|
||||
factory PodmanPs.fromRawJson(String str) => PodmanPs.fromJson(json.decode(str));
|
||||
|
||||
String toRawJson() => json.encode(toJson());
|
||||
|
||||
factory PodmanPs.fromJson(Map<String, dynamic> json) => PodmanPs(
|
||||
command: json['Command'] == null
|
||||
? []
|
||||
: List<String>.from(json['Command']!.map((x) => x)),
|
||||
created:
|
||||
json['Created'] == null ? null : DateTime.parse(json['Created']),
|
||||
exited: json['Exited'],
|
||||
id: json['Id'],
|
||||
image: json['Image'],
|
||||
names: json['Names'] == null
|
||||
? []
|
||||
: List<String>.from(json['Names']!.map((x) => x)),
|
||||
startedAt: json['StartedAt'],
|
||||
);
|
||||
command: json['Command'] == null ? [] : List<String>.from(json['Command']!.map((x) => x)),
|
||||
created: json['Created'] == null ? null : DateTime.parse(json['Created']),
|
||||
exited: json['Exited'],
|
||||
id: json['Id'],
|
||||
image: json['Image'],
|
||||
names: json['Names'] == null ? [] : List<String>.from(json['Names']!.map((x) => x)),
|
||||
startedAt: json['StartedAt'],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'Command':
|
||||
command == null ? [] : List<dynamic>.from(command!.map((x) => x)),
|
||||
'Created': created?.toIso8601String(),
|
||||
'Exited': exited,
|
||||
'Id': id,
|
||||
'Image': image,
|
||||
'Names': names == null ? [] : List<dynamic>.from(names!.map((x) => x)),
|
||||
'StartedAt': startedAt,
|
||||
};
|
||||
'Command': command == null ? [] : List<dynamic>.from(command!.map((x) => x)),
|
||||
'Created': created?.toIso8601String(),
|
||||
'Exited': exited,
|
||||
'Id': id,
|
||||
'Image': image,
|
||||
'Names': names == null ? [] : List<dynamic>.from(names!.map((x) => x)),
|
||||
'StartedAt': startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
final class DockerPs implements ContainerPs {
|
||||
@@ -127,12 +113,7 @@ final class DockerPs implements ContainerPs {
|
||||
@override
|
||||
String? disk;
|
||||
|
||||
DockerPs({
|
||||
this.id,
|
||||
this.image,
|
||||
this.names,
|
||||
this.state,
|
||||
});
|
||||
DockerPs({this.id, this.image, this.names, this.state});
|
||||
|
||||
@override
|
||||
String? get name => names;
|
||||
@@ -141,10 +122,7 @@ final class DockerPs implements ContainerPs {
|
||||
String? get cmd => null;
|
||||
|
||||
@override
|
||||
bool get running {
|
||||
if (state?.contains('Exited') == true) return false;
|
||||
return true;
|
||||
}
|
||||
ContainerStatus get status => ContainerStatus.fromDockerState(state);
|
||||
|
||||
@override
|
||||
void parseStats(String s) {
|
||||
@@ -159,11 +137,6 @@ final class DockerPs implements ContainerPs {
|
||||
/// a049d689e7a1 aria2-pro p3terx/aria2-pro Up 3 weeks
|
||||
factory DockerPs.parse(String raw) {
|
||||
final parts = raw.split(Miscs.multiBlankreg);
|
||||
return DockerPs(
|
||||
id: parts[0],
|
||||
state: parts[1],
|
||||
names: parts[2],
|
||||
image: parts[3].trim(),
|
||||
);
|
||||
return DockerPs(id: parts[0], state: parts[1], names: parts[2], image: parts[3].trim());
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,15 @@ import 'package:server_box/data/model/container/ps.dart';
|
||||
|
||||
enum ContainerType {
|
||||
docker,
|
||||
podman,
|
||||
;
|
||||
podman;
|
||||
|
||||
ContainerPs Function(String str) get ps => switch (this) {
|
||||
ContainerType.docker => DockerPs.parse,
|
||||
ContainerType.podman => PodmanPs.fromRawJson,
|
||||
};
|
||||
ContainerType.docker => DockerPs.parse,
|
||||
ContainerType.podman => PodmanPs.fromRawJson,
|
||||
};
|
||||
|
||||
ContainerImg Function(String str) get img => switch (this) {
|
||||
ContainerType.docker => DockerImg.fromRawJson,
|
||||
ContainerType.podman => PodmanImg.fromRawJson,
|
||||
};
|
||||
ContainerType.docker => DockerImg.fromRawJson,
|
||||
ContainerType.podman => PodmanImg.fromRawJson,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,8 +62,7 @@ enum PkgManager {
|
||||
case PkgManager.yum:
|
||||
list = list.sublist(2);
|
||||
list.removeWhere((element) => element.isEmpty);
|
||||
final endLine = list.lastIndexWhere(
|
||||
(element) => element.contains('Obsoleting Packages'));
|
||||
final endLine = list.lastIndexWhere((element) => element.contains('Obsoleting Packages'));
|
||||
if (endLine != -1 && list.isNotEmpty) {
|
||||
list = list.sublist(0, endLine);
|
||||
}
|
||||
@@ -71,8 +70,7 @@ enum PkgManager {
|
||||
case PkgManager.apt:
|
||||
// avoid other outputs
|
||||
// such as: [Could not chdir to home directory /home/test: No such file or directory, , WARNING: apt does not have a stable CLI interface. Use with caution in scripts., , Listing...]
|
||||
final idx =
|
||||
list.indexWhere((element) => element.contains('[upgradable from:'));
|
||||
final idx = list.indexWhere((element) => element.contains('[upgradable from:'));
|
||||
if (idx == -1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
188
lib/data/model/server/amd.dart
Normal file
188
lib/data/model/server/amd.dart
Normal file
@@ -0,0 +1,188 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// AMD GPU monitoring data structures
|
||||
/// Supports both amd-smi and rocm-smi tools
|
||||
/// Example JSON output:
|
||||
/// [
|
||||
/// {
|
||||
/// "name": "AMD Radeon RX 7900 XTX",
|
||||
/// "device_id": "0",
|
||||
/// "temp": 45,
|
||||
/// "power": "120W / 355W",
|
||||
/// "memory": {
|
||||
/// "total": 24576,
|
||||
/// "used": 1024,
|
||||
/// "unit": "MB",
|
||||
/// "processes": [
|
||||
/// {
|
||||
/// "pid": 2456,
|
||||
/// "name": "firefox",
|
||||
/// "memory": 512
|
||||
/// }
|
||||
/// ]
|
||||
/// },
|
||||
/// "utilization": 75,
|
||||
/// "fan_speed": 1200,
|
||||
/// "clock_speed": 2400
|
||||
/// }
|
||||
/// ]
|
||||
|
||||
class AmdSmi {
|
||||
static List<AmdSmiItem> fromJson(String raw) {
|
||||
try {
|
||||
final jsonData = json.decode(raw);
|
||||
if (jsonData is! List) return [];
|
||||
|
||||
return jsonData
|
||||
.map((gpu) => _parseGpuItem(gpu))
|
||||
.where((item) => item != null)
|
||||
.cast<AmdSmiItem>()
|
||||
.toList();
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static AmdSmiItem? _parseGpuItem(Map<String, dynamic> gpu) {
|
||||
try {
|
||||
final name = gpu['name'] ?? gpu['card_model'] ?? gpu['device_name'] ?? 'Unknown AMD GPU';
|
||||
final deviceId = gpu['device_id']?.toString() ?? gpu['gpu_id']?.toString() ?? '0';
|
||||
|
||||
// Temperature parsing
|
||||
final tempRaw = gpu['temperature'] ?? gpu['temp'] ?? gpu['gpu_temp'];
|
||||
final temp = _parseIntValue(tempRaw);
|
||||
|
||||
// Power parsing
|
||||
final powerDraw = gpu['power_draw'] ?? gpu['current_power'];
|
||||
final powerCap = gpu['power_cap'] ?? gpu['power_limit'] ?? gpu['max_power'];
|
||||
final power = _formatPower(powerDraw, powerCap);
|
||||
|
||||
// Memory parsing
|
||||
final memory = _parseMemory(gpu['memory'] ?? gpu['vram'] ?? {});
|
||||
|
||||
// Utilization parsing
|
||||
final utilization = _parseIntValue(gpu['utilization'] ?? gpu['gpu_util'] ?? gpu['activity']);
|
||||
|
||||
// Fan speed parsing
|
||||
final fanSpeed = _parseIntValue(gpu['fan_speed'] ?? gpu['fan_rpm']);
|
||||
|
||||
// Clock speed parsing
|
||||
final clockSpeed = _parseIntValue(gpu['clock_speed'] ?? gpu['gpu_clock'] ?? gpu['sclk']);
|
||||
|
||||
return AmdSmiItem(
|
||||
deviceId: deviceId,
|
||||
name: name,
|
||||
temp: temp,
|
||||
power: power,
|
||||
memory: memory,
|
||||
utilization: utilization,
|
||||
fanSpeed: fanSpeed,
|
||||
clockSpeed: clockSpeed,
|
||||
);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static int _parseIntValue(dynamic value) {
|
||||
if (value == null) return 0;
|
||||
if (value is int) return value;
|
||||
if (value is String) {
|
||||
// Remove units and parse (e.g., "45°C" -> 45, "1200 RPM" -> 1200)
|
||||
final cleanValue = value.replaceAll(RegExp(r'[^\d]'), '');
|
||||
return int.tryParse(cleanValue) ?? 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static String _formatPower(dynamic draw, dynamic cap) {
|
||||
final drawValue = _parseIntValue(draw);
|
||||
final capValue = _parseIntValue(cap);
|
||||
|
||||
if (drawValue == 0 && capValue == 0) return 'N/A';
|
||||
if (capValue == 0) return '${drawValue}W';
|
||||
return '${drawValue}W / ${capValue}W';
|
||||
}
|
||||
|
||||
static AmdSmiMem _parseMemory(Map<String, dynamic> memData) {
|
||||
final total = _parseIntValue(memData['total'] ?? memData['total_memory']);
|
||||
final used = _parseIntValue(memData['used'] ?? memData['used_memory']);
|
||||
final unit = memData['unit']?.toString() ?? 'MB';
|
||||
|
||||
final processes = <AmdSmiMemProcess>[];
|
||||
final processesData = memData['processes'];
|
||||
if (processesData is List) {
|
||||
for (final proc in processesData) {
|
||||
if (proc is Map<String, dynamic>) {
|
||||
final process = _parseProcess(proc);
|
||||
if (process != null) processes.add(process);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AmdSmiMem(total, used, unit, processes);
|
||||
}
|
||||
|
||||
static AmdSmiMemProcess? _parseProcess(Map<String, dynamic> procData) {
|
||||
final pid = _parseIntValue(procData['pid']);
|
||||
final name = procData['name']?.toString() ?? procData['process_name']?.toString() ?? 'Unknown';
|
||||
final memory = _parseIntValue(procData['memory'] ?? procData['used_memory']);
|
||||
|
||||
if (pid == 0) return null;
|
||||
return AmdSmiMemProcess(pid, name, memory);
|
||||
}
|
||||
}
|
||||
|
||||
class AmdSmiItem {
|
||||
final String deviceId;
|
||||
final String name;
|
||||
final int temp;
|
||||
final String power;
|
||||
final AmdSmiMem memory;
|
||||
final int utilization;
|
||||
final int fanSpeed;
|
||||
final int clockSpeed;
|
||||
|
||||
const AmdSmiItem({
|
||||
required this.deviceId,
|
||||
required this.name,
|
||||
required this.temp,
|
||||
required this.power,
|
||||
required this.memory,
|
||||
required this.utilization,
|
||||
required this.fanSpeed,
|
||||
required this.clockSpeed,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AmdSmiItem{name: $name, temp: $temp, power: $power, utilization: $utilization%, memory: $memory}';
|
||||
}
|
||||
}
|
||||
|
||||
class AmdSmiMem {
|
||||
final int total;
|
||||
final int used;
|
||||
final String unit;
|
||||
final List<AmdSmiMemProcess> processes;
|
||||
|
||||
const AmdSmiMem(this.total, this.used, this.unit, this.processes);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AmdSmiMem{total: $total, used: $used, unit: $unit, processes: ${processes.length}}';
|
||||
}
|
||||
}
|
||||
|
||||
class AmdSmiMemProcess {
|
||||
final int pid;
|
||||
final String name;
|
||||
final int memory;
|
||||
|
||||
const AmdSmiMemProcess(this.pid, this.name, this.memory);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AmdSmiMemProcess{pid: $pid, name: $name, memory: $memory}';
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user