mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2026-02-15 20:55:25 +01:00
Compare commits
88 Commits
v1.0.1189
...
lollipopki
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4c0fdf6ff | ||
|
|
84921de7a7 | ||
|
|
12c8543352 | ||
|
|
92a4601335 | ||
|
|
b6ab8f1db5 | ||
|
|
ffda27d057 | ||
|
|
c548b4ef48 | ||
|
|
70040c5840 | ||
|
|
5272324be6 | ||
|
|
8cbb48ed67 | ||
|
|
03720fa322 | ||
|
|
0b51719070 | ||
|
|
a84231393d | ||
|
|
d6c2cafce7 | ||
|
|
729b76177e | ||
|
|
860c11d4a8 | ||
|
|
bd949288ed | ||
|
|
bb3e3b4848 | ||
|
|
3307fca620 | ||
|
|
da8517bcf7 | ||
|
|
f68c4a851b | ||
|
|
17db393c12 | ||
|
|
275581cfa3 | ||
|
|
d7168ea1ff | ||
|
|
fd2bf08f78 | ||
|
|
98e13c39cf | ||
|
|
e70abeef04 | ||
|
|
194774d6fb | ||
|
|
640d61bab9 | ||
|
|
7f4cf22cc9 | ||
|
|
05a927753f | ||
|
|
0c7b72fb2c | ||
|
|
a869b97502 | ||
|
|
eadd343205 | ||
|
|
1bac986fe0 | ||
|
|
a94be6c2c3 | ||
|
|
fc8e9b4bb1 | ||
|
|
ec4b633889 | ||
|
|
e51804fa70 | ||
|
|
2466341999 | ||
|
|
929061213f | ||
|
|
6b52679942 | ||
|
|
efc0315c93 | ||
|
|
8e4c2a7cde | ||
|
|
4ec7f5895e | ||
|
|
ee22cdb55f | ||
|
|
b1b0d9a18f | ||
|
|
56e67f4725 | ||
|
|
3b7fdf36fb | ||
|
|
5291d316a2 | ||
|
|
4c369546da | ||
|
|
12a243d139 | ||
|
|
a97b3cf43e | ||
|
|
53a7c0d8ff | ||
|
|
9cb705f8dd | ||
|
|
8270674b7d | ||
|
|
24fd4b782d | ||
|
|
fcb3d7e2b3 | ||
|
|
f5634d6e88 | ||
|
|
5497ad83e0 | ||
|
|
4a7827f41a | ||
|
|
60671fe461 | ||
|
|
bc1b6e5a4a | ||
|
|
1d553eccd5 | ||
|
|
68734a9e52 | ||
|
|
ed8a1d18b9 | ||
|
|
e4a9875620 | ||
|
|
6f9aa2ece9 | ||
|
|
13e28675af | ||
|
|
8c0e0f89d5 | ||
|
|
9b01da5a23 | ||
|
|
584af5423a | ||
|
|
95f8e571c1 | ||
|
|
9c9648656d | ||
|
|
6880bcc192 | ||
|
|
3a615449e3 | ||
|
|
46a12bc844 | ||
|
|
8d597294a4 | ||
|
|
682a6e4f2d | ||
|
|
8c3302cf0d | ||
|
|
ec4bf3df24 | ||
|
|
263d4eabb4 | ||
|
|
c6439673b8 | ||
|
|
a35d21981b | ||
|
|
dbc873c0c0 | ||
|
|
e69808a2f6 | ||
|
|
55b3ba63ec | ||
|
|
006e66d825 |
9
.github/workflows/analysis.yml
vendored
9
.github/workflows/analysis.yml
vendored
@@ -17,17 +17,16 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
channel: 'stable' # or: 'beta', 'dev' or 'master'
|
channel: 'stable'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
# Uncomment this step to verify the use of 'dart format' on each commit.
|
|
||||||
- name: Verify formatting
|
|
||||||
run: dart format --output=none .
|
|
||||||
|
|
||||||
# Consider passing '--fatal-infos' for slightly stricter analysis.
|
# Consider passing '--fatal-infos' for slightly stricter analysis.
|
||||||
- name: Analyze project source
|
- name: Analyze project source
|
||||||
run: dart analyze
|
run: dart analyze
|
||||||
|
|||||||
66
.github/workflows/claude.yml
vendored
Normal file
66
.github/workflows/claude.yml
vendored
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
name: Claude Code
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
pull_request_review_comment:
|
||||||
|
types: [created]
|
||||||
|
issues:
|
||||||
|
types: [opened, assigned]
|
||||||
|
pull_request_review:
|
||||||
|
types: [submitted]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
claude:
|
||||||
|
if: |
|
||||||
|
github.actor == 'lollipopkit' && (
|
||||||
|
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||||
|
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||||
|
)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
issues: read
|
||||||
|
id-token: write
|
||||||
|
actions: read # Required for Claude to read CI results on PRs
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Run Claude Code
|
||||||
|
id: claude
|
||||||
|
uses: anthropics/claude-code-action@beta
|
||||||
|
with:
|
||||||
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
|
||||||
|
# This is an optional setting that allows Claude to read CI results on PRs
|
||||||
|
additional_permissions: |
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
|
||||||
|
# model: "claude-opus-4-1-20250805"
|
||||||
|
|
||||||
|
# Optional: Customize the trigger phrase (default: @claude)
|
||||||
|
# trigger_phrase: "/claude"
|
||||||
|
|
||||||
|
# Optional: Trigger when specific user is assigned to an issue
|
||||||
|
# assignee_trigger: "claude-bot"
|
||||||
|
|
||||||
|
# Optional: Allow Claude to run specific commands
|
||||||
|
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
|
||||||
|
|
||||||
|
# Optional: Add custom instructions for Claude to customize its behavior for your project
|
||||||
|
# custom_instructions: |
|
||||||
|
# Follow our coding standards
|
||||||
|
# Ensure all new code has tests
|
||||||
|
# Use TypeScript for new files
|
||||||
|
|
||||||
|
# Optional: Custom environment variables for Claude
|
||||||
|
# claude_env: |
|
||||||
|
# NODE_ENV: test
|
||||||
|
|
||||||
26
.github/workflows/release.yml
vendored
26
.github/workflows/release.yml
vendored
@@ -9,6 +9,11 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
|
# Set by fl_build
|
||||||
|
# env:
|
||||||
|
# APP_NAME: ServerBox
|
||||||
|
# BUILD_NUMBER: ${{ github.ref_name }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
releaseAndroid:
|
releaseAndroid:
|
||||||
name: Release android
|
name: Release android
|
||||||
@@ -20,7 +25,7 @@ jobs:
|
|||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version: "3.32.2"
|
flutter-version: "3.35.3"
|
||||||
- uses: actions/setup-java@v4
|
- uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: "zulu"
|
distribution: "zulu"
|
||||||
@@ -58,24 +63,17 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
sudo apt update
|
sudo apt update
|
||||||
# Basic
|
# Basic
|
||||||
sudo apt install -y clang cmake ninja-build pkg-config libgtk-3-dev libvulkan-dev desktop-file-utils wget
|
sudo apt install -y clang cmake ninja-build pkg-config libgtk-3-dev mesa-utils libvulkan-dev desktop-file-utils wget
|
||||||
# App Specific
|
# App Specific
|
||||||
sudo apt install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libunwind-dev
|
sudo apt install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libunwind-dev libsecret-1-dev
|
||||||
# Packaging
|
|
||||||
sudo wget https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage -O /bin/appimagetool
|
|
||||||
sudo chmod +x /bin/appimagetool
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
dart run fl_build -p linux
|
dart run fl_build -p linux
|
||||||
- name: Rename artifacts
|
|
||||||
run: |
|
|
||||||
appimage_name=$(ls dist/*/*.AppImage)
|
|
||||||
mv $appimage_name ${{ env.APP_NAME }}_${{ env.BUILD_NUMBER }}_amd64.appimage
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
${{ env.APP_NAME }}_${{ env.BUILD_NUMBER }}_amd64.appimage
|
${{ env.APP_NAME }}_${{ env.BUILD_NUMBER }}_amd64.AppImage
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
@@ -105,16 +103,12 @@ jobs:
|
|||||||
# uses: actions/checkout@v4
|
# uses: actions/checkout@v4
|
||||||
# - name: Install Flutter
|
# - name: Install Flutter
|
||||||
# uses: subosito/flutter-action@v2
|
# uses: subosito/flutter-action@v2
|
||||||
# with:
|
|
||||||
# channel: 'stable'
|
|
||||||
# flutter-version: '3.32.1'
|
|
||||||
# - name: Build
|
# - name: Build
|
||||||
# run: dart run fl_build -p ios,mac
|
# run: dart run fl_build -p ios
|
||||||
# - name: Create Release
|
# - name: Create Release
|
||||||
# uses: softprops/action-gh-release@v2
|
# uses: softprops/action-gh-release@v2
|
||||||
# with:
|
# with:
|
||||||
# files: |
|
# files: |
|
||||||
# ${{ env.APP_NAME }}_universal_macos.zip
|
|
||||||
# ${{ env.APP_NAME }}_universal.ipa
|
# ${{ env.APP_NAME }}_universal.ipa
|
||||||
# env:
|
# env:
|
||||||
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
95
CLAUDE.md
Normal file
95
CLAUDE.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
- `flutter run` - Run the app in development mode
|
||||||
|
- `dart run fl_build -p PLATFORM` - Build the app for specific platform (see fl_build package)
|
||||||
|
- `dart run build_runner build --delete-conflicting-outputs` - Generate code for models with annotations (json_serializable, freezed, hive, riverpod)
|
||||||
|
- Every time you change model files, run this command to regenerate code (Hive adapters, Riverpod providers, etc.)
|
||||||
|
- Generated files include: `*.g.dart`, `*.freezed.dart` files
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- `flutter test` - Run unit tests
|
||||||
|
- `flutter test test/battery_test.dart` - Run specific test file
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
This is a Flutter application for managing Linux servers with the following key architectural components:
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
- `lib/core/` - Core utilities, extensions, and routing
|
||||||
|
- `lib/data/` - Data layer with models, providers, and storage
|
||||||
|
- `model/` - Data models organized by feature (server, container, ssh, etc.)
|
||||||
|
- `provider/` - Riverpod providers for state management
|
||||||
|
- `store/` - Local storage implementations using Hive
|
||||||
|
- `lib/view/` - UI layer with pages and widgets
|
||||||
|
- `lib/generated/` - Generated localization files
|
||||||
|
- `lib/hive/` - Hive adapters for local storage
|
||||||
|
|
||||||
|
### Key Technologies
|
||||||
|
|
||||||
|
- **State Management**: Riverpod with code generation (riverpod_annotation)
|
||||||
|
- **Local Storage**: Hive for persistent data with generated adapters
|
||||||
|
- **SSH/SFTP**: Custom dartssh2 fork for server connections
|
||||||
|
- **Terminal**: Custom xterm.dart fork for SSH terminal interface
|
||||||
|
- **Networking**: dio for HTTP requests
|
||||||
|
- **Charts**: fl_chart for server status visualization
|
||||||
|
- **Localization**: Flutter's built-in i18n with ARB files
|
||||||
|
- **Code Generation**: Uses build_runner with json_serializable, freezed, hive_generator, riverpod_generator
|
||||||
|
|
||||||
|
### Data Models
|
||||||
|
|
||||||
|
- Server management models in `lib/data/model/server/`
|
||||||
|
- Container/Docker models in `lib/data/model/container/`
|
||||||
|
- SSH and SFTP models in respective directories
|
||||||
|
- Most models use freezed for immutability and json_annotation for serialization
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Server status monitoring (CPU, memory, disk, network)
|
||||||
|
- SSH terminal with virtual keyboard
|
||||||
|
- SFTP file browser
|
||||||
|
- Docker container management
|
||||||
|
- Process and systemd service management
|
||||||
|
- Server snippets and custom commands
|
||||||
|
- Multi-language support (12+ languages)
|
||||||
|
- Cross-platform support (iOS, Android, macOS, Linux, Windows)
|
||||||
|
|
||||||
|
### State Management Pattern
|
||||||
|
|
||||||
|
- Uses Riverpod providers for dependency injection and state management
|
||||||
|
- Uses Freezed for immutable state models
|
||||||
|
- Providers are organized by feature in `lib/data/provider/`
|
||||||
|
- State is often persisted using Hive stores in `lib/data/store/`
|
||||||
|
|
||||||
|
### Build System
|
||||||
|
|
||||||
|
- Uses custom `fl_build` package for cross-platform building
|
||||||
|
- `make.dart` script handles pre/post build tasks (metadata generation)
|
||||||
|
- Supports building for multiple platforms with platform-specific configurations
|
||||||
|
- Many dependencies are custom forks hosted on GitHub (dartssh2, xterm, fl_lib, etc.)
|
||||||
|
|
||||||
|
### Important Notes
|
||||||
|
|
||||||
|
- **Never run code formatting commands** - The codebase has specific formatting that should not be changed
|
||||||
|
- **Always run code generation** after modifying models with annotations (freezed, json_serializable, hive, riverpod)
|
||||||
|
- Generated files (`*.g.dart`, `*.freezed.dart`) should not be manually edited
|
||||||
|
- AGAIN, NEVER run code formatting commands.
|
||||||
|
- USE dependency injection via GetIt for services like Stores, Services and etc.
|
||||||
|
- Generate all l10n files using `flutter gen-l10n` command after modifying ARB files.
|
||||||
|
- USE `hive_ce` not `hive` package for Hive integration.
|
||||||
|
- Which no need to config `HiveField` and `HiveType` manually.
|
||||||
|
- USE widgets and utilities from `fl_lib` package for common functionalities.
|
||||||
|
- Such as `CustomAppBar`, `context.showRoundDialog`, `Input`, `Btnx.cancelOk`, etc.
|
||||||
|
- You can use context7 MCP to search `lppcg fl_lib KEYWORD` to find relevant widgets and utilities.
|
||||||
|
- USE `libL10n` and `l10n` for localization strings.
|
||||||
|
- `libL10n` is from `fl_lib` package, and `l10n` is from this project.
|
||||||
|
- Before adding new strings, check if it already exists in `libL10n`.
|
||||||
|
- Prioritize using strings from `libL10n` to avoid duplication, even if the meaning is not 100% exact, just use the substitution of `libL10n`.
|
||||||
|
- Split UI into Widget build, Actions, Utils. use `extension on` to achieve this
|
||||||
143
LICENSE
143
LICENSE
@@ -1,5 +1,5 @@
|
|||||||
GNU GENERAL PUBLIC LICENSE
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
Version 3, 29 June 2007
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
@@ -7,17 +7,15 @@
|
|||||||
|
|
||||||
Preamble
|
Preamble
|
||||||
|
|
||||||
The GNU General Public License is a free, copyleft license for
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
software and other kinds of works.
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed
|
The licenses for most software and other practical works are designed
|
||||||
to take away your freedom to share and change the works. By contrast,
|
to take away your freedom to share and change the works. By contrast,
|
||||||
the GNU General Public License is intended to guarantee your freedom to
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
share and change all versions of a program--to make sure it remains free
|
share and change all versions of a program--to make sure it remains free
|
||||||
software for all its users. We, the Free Software Foundation, use the
|
software for all its users.
|
||||||
GNU General Public License for most of our software; it applies also to
|
|
||||||
any other work released this way by its authors. You can apply it to
|
|
||||||
your programs, too.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
When we speak of free software, we are referring to freedom, not
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
|
|||||||
want it, that you can change the software or use pieces of it in new
|
want it, that you can change the software or use pieces of it in new
|
||||||
free programs, and that you know you can do these things.
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
To protect your rights, we need to prevent others from denying you
|
Developers that use our General Public Licenses protect your rights
|
||||||
these rights or asking you to surrender the rights. Therefore, you have
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
certain responsibilities if you distribute copies of the software, or if
|
you this License which gives you legal permission to copy, distribute
|
||||||
you modify it: responsibilities to respect the freedom of others.
|
and/or modify the software.
|
||||||
|
|
||||||
For example, if you distribute copies of such a program, whether
|
A secondary benefit of defending all users' freedom is that
|
||||||
gratis or for a fee, you must pass on to the recipients the same
|
improvements made in alternate versions of the program, if they
|
||||||
freedoms that you received. You must make sure that they, too, receive
|
receive widespread use, become available for other developers to
|
||||||
or can get the source code. And you must show them these terms so they
|
incorporate. Many developers of free software are heartened and
|
||||||
know their rights.
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
Developers that use the GNU GPL protect your rights with two steps:
|
The GNU Affero General Public License is designed specifically to
|
||||||
(1) assert copyright on the software, and (2) offer you this License
|
ensure that, in such cases, the modified source code becomes available
|
||||||
giving you legal permission to copy, distribute and/or modify it.
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
For the developers' and authors' protection, the GPL clearly explains
|
An older license, called the Affero General Public License and
|
||||||
that there is no warranty for this free software. For both users' and
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
authors' sake, the GPL requires that modified versions be marked as
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
changed, so that their problems will not be attributed erroneously to
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
authors of previous versions.
|
this license.
|
||||||
|
|
||||||
Some devices are designed to deny users access to install or run
|
|
||||||
modified versions of the software inside them, although the manufacturer
|
|
||||||
can do so. This is fundamentally incompatible with the aim of
|
|
||||||
protecting users' freedom to change the software. The systematic
|
|
||||||
pattern of such abuse occurs in the area of products for individuals to
|
|
||||||
use, which is precisely where it is most unacceptable. Therefore, we
|
|
||||||
have designed this version of the GPL to prohibit the practice for those
|
|
||||||
products. If such problems arise substantially in other domains, we
|
|
||||||
stand ready to extend this provision to those domains in future versions
|
|
||||||
of the GPL, as needed to protect the freedom of users.
|
|
||||||
|
|
||||||
Finally, every program is threatened constantly by software patents.
|
|
||||||
States should not allow patents to restrict development and use of
|
|
||||||
software on general-purpose computers, but in those that do, we wish to
|
|
||||||
avoid the special danger that patents applied to a free program could
|
|
||||||
make it effectively proprietary. To prevent this, the GPL assures that
|
|
||||||
patents cannot be used to render the program non-free.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
The precise terms and conditions for copying, distribution and
|
||||||
modification follow.
|
modification follow.
|
||||||
@@ -72,7 +60,7 @@ modification follow.
|
|||||||
|
|
||||||
0. Definitions.
|
0. Definitions.
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU General Public License.
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
works, such as semiconductor masks.
|
works, such as semiconductor masks.
|
||||||
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
|
|||||||
the Program, the only way you could satisfy both those terms and this
|
the Program, the only way you could satisfy both those terms and this
|
||||||
License would be to refrain entirely from conveying the Program.
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
13. Use with the GNU Affero General Public License.
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
Notwithstanding any other provision of this License, you have
|
||||||
permission to link or combine any covered work with a work licensed
|
permission to link or combine any covered work with a work licensed
|
||||||
under version 3 of the GNU Affero General Public License into a single
|
under version 3 of the GNU General Public License into a single
|
||||||
combined work, and to convey the resulting work. The terms of this
|
combined work, and to convey the resulting work. The terms of this
|
||||||
License will continue to apply to the part which is the covered work,
|
License will continue to apply to the part which is the covered work,
|
||||||
but the special requirements of the GNU Affero General Public License,
|
but the work with which it is combined will remain governed by version
|
||||||
section 13, concerning interaction through a network will apply to the
|
3 of the GNU General Public License.
|
||||||
combination as such.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
the GNU General Public License from time to time. Such new versions will
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
be similar in spirit to the present version, but may differ in detail to
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
address new problems or concerns.
|
address new problems or concerns.
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
Each version is given a distinguishing version number. If the
|
||||||
Program specifies that a certain numbered version of the GNU General
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
Public License "or any later version" applies to it, you have the
|
Public License "or any later version" applies to it, you have the
|
||||||
option of following the terms and conditions either of that numbered
|
option of following the terms and conditions either of that numbered
|
||||||
version or of any later version published by the Free Software
|
version or of any later version published by the Free Software
|
||||||
Foundation. If the Program does not specify a version number of the
|
Foundation. If the Program does not specify a version number of the
|
||||||
GNU General Public License, you may choose any version ever published
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
by the Free Software Foundation.
|
by the Free Software Foundation.
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
If the Program specifies that a proxy can decide which future
|
||||||
versions of the GNU General Public License can be used, that proxy's
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
public statement of acceptance of a version permanently authorizes you
|
public statement of acceptance of a version permanently authorizes you
|
||||||
to choose that version for the Program.
|
to choose that version for the Program.
|
||||||
|
|
||||||
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
|
|||||||
Copyright (C) <year> <name of author>
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU General Public License as published by
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
(at your option) any later version.
|
(at your option) any later version.
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
This program is distributed in the hope that it will be useful,
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
GNU General Public License for more details.
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
You should have received a copy of the GNU Affero General Public License
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
If the program does terminal interaction, make it output a short
|
If your software can interact with users remotely through a computer
|
||||||
notice like this when it starts in an interactive mode:
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
<program> Copyright (C) <year> <name of author>
|
interface could display a "Source" link that leads users to an archive
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
of the code. There are many ways you could offer source, and different
|
||||||
This is free software, and you are welcome to redistribute it
|
solutions will be better for different programs; see section 13 for the
|
||||||
under certain conditions; type `show c' for details.
|
specific requirements.
|
||||||
|
|
||||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
|
||||||
parts of the General Public License. Of course, your program's commands
|
|
||||||
might be different; for a GUI interface, you would use an "about box".
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
For more information on this, and how to apply and follow the GNU GPL, see
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
<https://www.gnu.org/licenses/>.
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
The GNU General Public License does not permit incorporating your program
|
|
||||||
into proprietary programs. If your program is a subroutine library, you
|
|
||||||
may consider it more useful to permit linking proprietary applications with
|
|
||||||
the library. If this is what you want to do, use the GNU Lesser General
|
|
||||||
Public License instead of this License. But first, please read
|
|
||||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
|
||||||
14
README.md
14
README.md
@@ -5,12 +5,12 @@ English | [简体中文](README_zh.md)
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="https://cdn.lpkt.cn/donate"><img alt="donate" src="https://img.shields.io/badge/donate-me-pink"></a>
|
<a href="https://cdn.lpkt.cn/donate"><img alt="donate" src="https://img.shields.io/badge/donate-me-pink"></a>
|
||||||
<img alt="lang" src="https://img.shields.io/badge/lang-dart-cyan">
|
<img alt="lang" src="https://img.shields.io/badge/lang-dart-cyan">
|
||||||
<img alt="license" src="https://img.shields.io/badge/license-GPLv3-yellow">
|
<img alt="license" src="https://img.shields.io/badge/license-AGPLv3-yellow">
|
||||||
<a href="https://deepwiki.com/lollipopkit/flutter_server_box"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
|
<a href="https://deepwiki.com/lollipopkit/flutter_server_box"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p align="center">
|
<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>
|
<br>
|
||||||
Especially thanks to <a href="https://github.com/TerminalStudio/dartssh2">dartssh2</a> & <a href="https://github.com/TerminalStudio/xterm.dart">xterm.dart</a>.
|
Especially thanks to <a href="https://github.com/TerminalStudio/dartssh2">dartssh2</a> & <a href="https://github.com/TerminalStudio/xterm.dart">xterm.dart</a>.
|
||||||
</p>
|
</p>
|
||||||
@@ -26,7 +26,7 @@ Especially thanks to <a href="https://github.com/TerminalStudio/dartssh2">dartss
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
## 📥 Install
|
## 📥 Installation
|
||||||
|
|
||||||
|Platform| From|
|
|Platform| From|
|
||||||
|--|--|
|
|--|--|
|
||||||
@@ -36,7 +36,7 @@ Especially thanks to <a href="https://github.com/TerminalStudio/dartssh2">dartss
|
|||||||
|
|
||||||
Please only download pkgs from the source that **you trust**!
|
Please only download pkgs from the source that **you trust**!
|
||||||
|
|
||||||
## 🔖 Feature
|
## 🔖 Features
|
||||||
|
|
||||||
- `Status chart` (CPU, Sensors, GPU...), `SSH` Term, `SFTP`, `Docker & Process & Systemd`, `S.M.A.R.T`...
|
- `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`...
|
- Platform specific: `Bio auth`、`Msg push`、`Home widget`、`watchOS App`...
|
||||||
@@ -61,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).
|
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.
|
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
|
### Development
|
||||||
|
|
||||||
1. Setup [Flutter](https://flutter.dev/docs/get-started/install) environment.
|
1. Setup [Flutter](https://flutter.dev/docs/get-started/install) environment.
|
||||||
@@ -83,4 +85,4 @@ Any positive contribution is welcome.
|
|||||||
|
|
||||||
## 📝 License
|
## 📝 License
|
||||||
|
|
||||||
`GPL v3 lollipopkit`
|
`AGPL v3 lollipopkit & all contributors`
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="https://cdn.lpkt.cn/donate"><img alt="donate" src="https://img.shields.io/badge/捐赠-我-pink"></a>
|
<a href="https://cdn.lpkt.cn/donate"><img alt="donate" src="https://img.shields.io/badge/捐赠-我-pink"></a>
|
||||||
<img alt="语言" src="https://img.shields.io/badge/语言-dart-cyan">
|
<img alt="语言" src="https://img.shields.io/badge/语言-dart-cyan">
|
||||||
<img alt="license" src="https://img.shields.io/badge/证书-GPLv3-yellow">
|
<img alt="license" src="https://img.shields.io/badge/证书-AGPLv3-yellow">
|
||||||
<a href="https://deepwiki.com/lollipopkit/flutter_server_box"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
|
<a href="https://deepwiki.com/lollipopkit/flutter_server_box"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
使用 Flutter 开发的 <a href="https://github.com/lollipopkit/flutter_server_box/issues/43">Linux</a> 服务器工具箱,提供服务器状态图表和管理工具。
|
使用 Flutter 开发的 Linux, Unix, Windows 服务器工具箱,提供服务器状态图表和管理工具。
|
||||||
<br>
|
<br>
|
||||||
特别感谢 <a href="https://github.com/TerminalStudio/dartssh2">dartssh2</a> & <a href="https://github.com/TerminalStudio/xterm.dart">xterm.dart</a>。
|
特别感谢 <a href="https://github.com/TerminalStudio/dartssh2">dartssh2</a> & <a href="https://github.com/TerminalStudio/xterm.dart">xterm.dart</a>。
|
||||||
</p>
|
</p>
|
||||||
@@ -67,6 +67,8 @@ Linux / Windows | [GitHub](https://github.com/lollipopkit/flutter_server_box/rel
|
|||||||
|
|
||||||
任何正面的贡献都欢迎。
|
任何正面的贡献都欢迎。
|
||||||
|
|
||||||
|
如果我忘记在贡献者列表中添加你的名字,请在你打开的 issue 或 PR 中添加评论让我知道,我会尽快添加。
|
||||||
|
|
||||||
### 开发
|
### 开发
|
||||||
|
|
||||||
1. 安装 [Flutter](https://flutter.dev/docs/get-started/install)
|
1. 安装 [Flutter](https://flutter.dev/docs/get-started/install)
|
||||||
@@ -84,4 +86,4 @@ Linux / Windows | [GitHub](https://github.com/lollipopkit/flutter_server_box/rel
|
|||||||
|
|
||||||
## 📝 协议
|
## 📝 协议
|
||||||
|
|
||||||
`GPL v3 lollipopkit`
|
`AGPL v3 lollipopkit & 所有贡献者`
|
||||||
|
|||||||
@@ -92,6 +92,13 @@ android {
|
|||||||
// No applicationIdSuffix or resValue here
|
// 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 {
|
flutter {
|
||||||
@@ -106,7 +113,7 @@ android.applicationVariants.all { variant ->
|
|||||||
variant.outputs.each { output ->
|
variant.outputs.each { output ->
|
||||||
def abiVersionCode = project.ext.abiCodes.get(output.getFilter(OutputFile.ABI))
|
def abiVersionCode = project.ext.abiCodes.get(output.getFilter(OutputFile.ABI))
|
||||||
if (abiVersionCode != null) {
|
if (abiVersionCode != null) {
|
||||||
output.versionCodeOverride = variant.versionCode * 10 + abiVersionCode
|
output.versionCodeOverride = variant.versionCode * 100 + abiVersionCode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:hasFragileUserData="true"
|
android:hasFragileUserData="true"
|
||||||
android:restoreAnyVersion="true"
|
android:restoreAnyVersion="true"
|
||||||
tools:targetApi="q">
|
tools:targetApi="q">
|
||||||
@@ -45,6 +46,15 @@
|
|||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".widget.WidgetConfigureActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@android:style/Theme.Material.Light.Dialog">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widget.HomeWidget"
|
android:name=".widget.HomeWidget"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
|||||||
@@ -2,14 +2,32 @@ package tech.lolli.toolbox
|
|||||||
|
|
||||||
import android.app.*
|
import android.app.*
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.graphics.drawable.Icon
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
class ForegroundService : Service() {
|
class ForegroundService : Service() {
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
var isRunning: Boolean = false
|
||||||
|
}
|
||||||
private val chanId = "ForegroundServiceChannel"
|
private val chanId = "ForegroundServiceChannel"
|
||||||
|
private val NOTIFICATION_ID = 1000
|
||||||
|
private val ACTION_STOP_FOREGROUND = "ACTION_STOP_FOREGROUND"
|
||||||
|
private val ACTION_UPDATE_SESSIONS = "tech.lolli.toolbox.ACTION_UPDATE_SESSIONS"
|
||||||
|
private val ACTION_DISCONNECT_SESSION = "tech.lolli.toolbox.ACTION_DISCONNECT_SESSION"
|
||||||
|
|
||||||
|
private var isFgStarted = false
|
||||||
|
private val postedIds = mutableSetOf<Int>()
|
||||||
|
// Stable mapping from session-id -> notification-id to avoid hash collisions
|
||||||
|
private val notificationIdMap = mutableMapOf<String, Int>()
|
||||||
|
private val nextNotificationId = java.util.concurrent.atomic.AtomicInteger(2001)
|
||||||
|
|
||||||
private fun logError(message: String, error: Throwable? = null) {
|
private fun logError(message: String, error: Throwable? = null) {
|
||||||
Log.e("ForegroundService", message, error)
|
Log.e("ForegroundService", message, error)
|
||||||
@@ -26,48 +44,51 @@ class ForegroundService : Service() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
Log.d("ForegroundService", "Service onCreate")
|
Log.d("ForegroundService", "Service onCreate")
|
||||||
|
isRunning = true
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
try {
|
try {
|
||||||
|
// Check notification permission for Android 13+
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||||
this, android.Manifest.permission.POST_NOTIFICATIONS
|
this, android.Manifest.permission.POST_NOTIFICATIONS
|
||||||
) != android.content.pm.PackageManager.PERMISSION_GRANTED
|
) != android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
) {
|
) {
|
||||||
Log.w("ForegroundService", "Notification permission denied. Stopping service.")
|
Log.w("ForegroundService", "Notification permission denied. Stopping service gracefully.")
|
||||||
stopForegroundService()
|
// Don't call stopForegroundService() here as we haven't started foreground yet
|
||||||
|
stopSelf()
|
||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intent == null) {
|
if (intent == null) {
|
||||||
Log.w("ForegroundService", "onStartCommand called with null intent")
|
Log.w("ForegroundService", "onStartCommand called with null intent")
|
||||||
stopForegroundService()
|
// Don't call stopForegroundService() here as we haven't started foreground yet
|
||||||
|
stopSelf()
|
||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
val action = intent.action
|
val action = intent.action
|
||||||
Log.d("ForegroundService", "onStartCommand action=$action")
|
Log.d("ForegroundService", "onStartCommand action=$action")
|
||||||
|
|
||||||
// Create notification before starting foreground
|
|
||||||
val notification = createNotification()
|
|
||||||
|
|
||||||
// Use try-catch for startForeground
|
|
||||||
try {
|
|
||||||
startForeground(1, notification)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logError("Failed to start foreground", e)
|
|
||||||
stopSelf()
|
|
||||||
return START_NOT_STICKY
|
|
||||||
}
|
|
||||||
|
|
||||||
return when (action) {
|
return when (action) {
|
||||||
"ACTION_STOP_FOREGROUND" -> {
|
ACTION_STOP_FOREGROUND -> {
|
||||||
|
// Notify Flutter to stop all connections before stopping service
|
||||||
|
val stopAllIntent = Intent("tech.lolli.toolbox.STOP_ALL_CONNECTIONS")
|
||||||
|
sendBroadcast(stopAllIntent)
|
||||||
|
clearAll()
|
||||||
stopForegroundService()
|
stopForegroundService()
|
||||||
START_NOT_STICKY
|
START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
ACTION_UPDATE_SESSIONS -> {
|
||||||
|
val payload = intent.getStringExtra("payload") ?: "{}"
|
||||||
|
handleUpdateSessions(payload)
|
||||||
|
START_STICKY
|
||||||
|
}
|
||||||
else -> {
|
else -> {
|
||||||
|
// Default bring up foreground with placeholder
|
||||||
|
ensureForeground(createMergedNotification(0, emptyList(), emptyList()))
|
||||||
START_STICKY
|
START_STICKY
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,68 +106,205 @@ class ForegroundService : Service() {
|
|||||||
|
|
||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
try {
|
||||||
if (manager == null) {
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
Log.e("ForegroundService", "Failed to get NotificationManager")
|
if (manager == null) {
|
||||||
|
Log.e("ForegroundService", "Failed to get NotificationManager")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val serviceChannel = NotificationChannel(
|
||||||
|
chanId,
|
||||||
|
"ForegroundServiceChannel",
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT
|
||||||
|
).apply {
|
||||||
|
description = "For foreground service"
|
||||||
|
}
|
||||||
|
manager.createNotificationChannel(serviceChannel)
|
||||||
|
Log.d("ForegroundService", "Notification channel created successfully")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logError("Failed to create notification channel", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureForeground(notification: Notification) {
|
||||||
|
try {
|
||||||
|
// Double-check notification permission before starting foreground service
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
|
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||||
|
this, android.Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
) != android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
Log.w("ForegroundService", "Cannot start foreground service without notification permission")
|
||||||
|
stopSelf()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val serviceChannel = NotificationChannel(
|
|
||||||
chanId,
|
|
||||||
"ForegroundServiceChannel",
|
|
||||||
NotificationManager.IMPORTANCE_DEFAULT
|
|
||||||
).apply {
|
|
||||||
description = "For foreground service"
|
|
||||||
}
|
|
||||||
manager.createNotificationChannel(serviceChannel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createNotification(): Notification {
|
if (!isFgStarted) {
|
||||||
try {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
val notificationIntent = Intent(this, MainActivity::class.java)
|
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
} else {
|
||||||
this,
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
0,
|
}
|
||||||
notificationIntent,
|
isFgStarted = true
|
||||||
PendingIntent.FLAG_IMMUTABLE
|
Log.d("ForegroundService", "Foreground service started successfully")
|
||||||
)
|
|
||||||
|
|
||||||
val deleteIntent = Intent(this, ForegroundService::class.java).apply {
|
|
||||||
action = "ACTION_STOP_FOREGROUND"
|
|
||||||
}
|
|
||||||
val deletePendingIntent = PendingIntent.getService(
|
|
||||||
this,
|
|
||||||
0,
|
|
||||||
deleteIntent,
|
|
||||||
PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
|
|
||||||
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
Notification.Builder(this, chanId)
|
|
||||||
} else {
|
} else {
|
||||||
Notification.Builder(this)
|
val nm = getSystemService(NotificationManager::class.java)
|
||||||
|
if (nm != null) {
|
||||||
|
nm.notify(NOTIFICATION_ID, notification)
|
||||||
|
} else {
|
||||||
|
Log.w("ForegroundService", "NotificationManager is null, cannot update notification")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: SecurityException) {
|
||||||
return builder
|
logError("Security exception when starting foreground service (likely missing permission)", e)
|
||||||
.setContentTitle("Server Box")
|
stopSelf()
|
||||||
.setContentText("Running in background")
|
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
|
||||||
.setContentIntent(pendingIntent)
|
|
||||||
.addAction(android.R.drawable.ic_delete, "Stop", deletePendingIntent)
|
|
||||||
.build()
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logError("Error creating notification", e)
|
logError("Failed to start/update foreground", e)
|
||||||
// Return a basic notification as fallback
|
// Don't stop the service for other exceptions, just log them
|
||||||
return Notification.Builder(this)
|
|
||||||
.setContentTitle("Server Box")
|
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
|
||||||
.build()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun createMergedNotification(count: Int, lines: List<String>, sessions: List<SessionItem>): Notification {
|
||||||
|
val notificationIntent = Intent(this, MainActivity::class.java)
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val stopIntent = Intent(this, ForegroundService::class.java).apply { action = ACTION_STOP_FOREGROUND }
|
||||||
|
val stopPending = PendingIntent.getService(this, 0, stopIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
|
||||||
|
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
Notification.Builder(this, chanId)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
Notification.Builder(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the earliest session's start time for chronometer
|
||||||
|
val earliestStartTime = sessions.minOfOrNull { it.startWhen } ?: System.currentTimeMillis()
|
||||||
|
|
||||||
|
val title = when (count) {
|
||||||
|
0 -> "Server Box"
|
||||||
|
1 -> sessions.first().title
|
||||||
|
else -> "SSH sessions: $count active"
|
||||||
|
}
|
||||||
|
|
||||||
|
val contentText = when (count) {
|
||||||
|
0 -> "Ready for connections"
|
||||||
|
1 -> {
|
||||||
|
val session = sessions.first()
|
||||||
|
"${session.subtitle} · ${session.status}"
|
||||||
|
}
|
||||||
|
else -> "Multiple SSH connections active"
|
||||||
|
}
|
||||||
|
|
||||||
|
// For multiple sessions, show details in expanded view
|
||||||
|
val style = if (count > 1) {
|
||||||
|
val inbox = Notification.InboxStyle()
|
||||||
|
val maxLines = 5
|
||||||
|
val displayLines = if (lines.size > maxLines) {
|
||||||
|
lines.take(maxLines) + "...and ${lines.size - maxLines} more"
|
||||||
|
} else {
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
displayLines.forEach { inbox.addLine(it) }
|
||||||
|
inbox.setBigContentTitle(title)
|
||||||
|
inbox
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val notification = builder
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(contentText)
|
||||||
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.setWhen(earliestStartTime)
|
||||||
|
.setUsesChronometer(true)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.addAction(
|
||||||
|
Notification.Action.Builder(
|
||||||
|
Icon.createWithResource(this, android.R.drawable.ic_delete),
|
||||||
|
"Stop All",
|
||||||
|
stopPending
|
||||||
|
).build()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (style != null) {
|
||||||
|
notification.setStyle(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
return notification.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleUpdateSessions(payload: String) {
|
||||||
|
val nm = getSystemService(NotificationManager::class.java)
|
||||||
|
if (nm == null) {
|
||||||
|
logError("NotificationManager null")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val sessions = mutableListOf<SessionItem>()
|
||||||
|
try {
|
||||||
|
val obj = JSONObject(payload)
|
||||||
|
val arr: JSONArray = obj.optJSONArray("sessions") ?: JSONArray()
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val s = arr.optJSONObject(i) ?: continue
|
||||||
|
val id = s.optString("id")
|
||||||
|
val title = s.optString("title")
|
||||||
|
val sub = s.optString("subtitle")
|
||||||
|
val whenMs = s.optLong("startTimeMs", System.currentTimeMillis())
|
||||||
|
val status = s.optString("status", "connected")
|
||||||
|
if (id.isNotEmpty()) {
|
||||||
|
sessions.add(SessionItem(id, title, sub, whenMs, status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logError("Failed to parse payload", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear if empty
|
||||||
|
if (sessions.isEmpty()) {
|
||||||
|
clearAll()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel any existing individual notifications (we only show merged notification now)
|
||||||
|
val toCancel = postedIds.toSet()
|
||||||
|
toCancel.forEach { nm.cancel(it) }
|
||||||
|
postedIds.clear()
|
||||||
|
notificationIdMap.clear()
|
||||||
|
|
||||||
|
// Create merged notification content
|
||||||
|
val summaryLines = sessions.map { "${it.title}: ${it.status}" }
|
||||||
|
val mergedNotification = createMergedNotification(sessions.size, summaryLines, sessions)
|
||||||
|
ensureForeground(mergedNotification)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearAll() {
|
||||||
|
val nm = getSystemService(NotificationManager::class.java)
|
||||||
|
nm?.cancel(NOTIFICATION_ID)
|
||||||
|
postedIds.forEach { id -> nm?.cancel(id) }
|
||||||
|
postedIds.clear()
|
||||||
|
isFgStarted = false
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SessionItem(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val subtitle: String,
|
||||||
|
val startWhen: Long,
|
||||||
|
val status: String,
|
||||||
|
)
|
||||||
|
|
||||||
private fun stopForegroundService() {
|
private fun stopForegroundService() {
|
||||||
try {
|
try {
|
||||||
stopForeground(true)
|
if (isFgStarted) {
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
isFgStarted = false
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logError("Error stopping foreground", e)
|
logError("Error stopping foreground", e)
|
||||||
}
|
}
|
||||||
@@ -157,5 +315,6 @@ class ForegroundService : Service() {
|
|||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
Log.d("ForegroundService", "Service onDestroy")
|
Log.d("ForegroundService", "Service onDestroy")
|
||||||
|
isRunning = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import android.content.Intent
|
|||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.IntentFilter
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||||
@@ -13,20 +16,34 @@ import android.appwidget.AppWidgetManager
|
|||||||
import tech.lolli.toolbox.widget.HomeWidget
|
import tech.lolli.toolbox.widget.HomeWidget
|
||||||
|
|
||||||
class MainActivity: FlutterFragmentActivity() {
|
class MainActivity: FlutterFragmentActivity() {
|
||||||
|
private lateinit var channel: MethodChannel
|
||||||
|
private val ACTION_UPDATE_SESSIONS = "tech.lolli.toolbox.ACTION_UPDATE_SESSIONS"
|
||||||
|
private val ACTION_DISCONNECT_SESSION = "tech.lolli.toolbox.ACTION_DISCONNECT_SESSION"
|
||||||
|
private val ACTION_STOP_ALL_CONNECTIONS = "tech.lolli.toolbox.STOP_ALL_CONNECTIONS"
|
||||||
|
private var stopAllReceiver: BroadcastReceiver? = null
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
val binaryMessenger = flutterEngine.dartExecutor.binaryMessenger
|
val binaryMessenger = flutterEngine.dartExecutor.binaryMessenger
|
||||||
|
|
||||||
MethodChannel(binaryMessenger, "tech.lolli.toolbox/main_chan").apply {
|
channel = MethodChannel(binaryMessenger, "tech.lolli.toolbox/main_chan")
|
||||||
setMethodCallHandler { method, result ->
|
channel.setMethodCallHandler { method, result ->
|
||||||
when (method.method) {
|
when (method.method) {
|
||||||
"sendToBackground" -> {
|
"sendToBackground" -> {
|
||||||
moveTaskToBack(true)
|
moveTaskToBack(true)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"isServiceRunning" -> {
|
||||||
|
result.success(ForegroundService.isRunning)
|
||||||
|
}
|
||||||
"startService" -> {
|
"startService" -> {
|
||||||
try {
|
try {
|
||||||
reqPerm()
|
reqPerm()
|
||||||
|
if (!notificationsAllowed()) {
|
||||||
|
// Don't start foreground service without notification permission on API 33+
|
||||||
|
result.error("NOTIFICATION_PERMISSION_DENIED", "Notification permission not granted", null)
|
||||||
|
return@setMethodCallHandler
|
||||||
|
}
|
||||||
val serviceIntent = Intent(this@MainActivity, ForegroundService::class.java)
|
val serviceIntent = Intent(this@MainActivity, ForegroundService::class.java)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
startForegroundService(serviceIntent)
|
startForegroundService(serviceIntent)
|
||||||
@@ -51,31 +68,138 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
sendBroadcast(intent)
|
sendBroadcast(intent)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"updateSessions" -> {
|
||||||
|
try {
|
||||||
|
if (!notificationsAllowed()) {
|
||||||
|
// Avoid starting/continuing service updates when notifications are blocked
|
||||||
|
result.error("NOTIFICATION_PERMISSION_DENIED", "Notification permission not granted", null)
|
||||||
|
return@setMethodCallHandler
|
||||||
|
}
|
||||||
|
val serviceIntent = Intent(this@MainActivity, ForegroundService::class.java)
|
||||||
|
serviceIntent.action = ACTION_UPDATE_SESSIONS
|
||||||
|
serviceIntent.putExtra("payload", method.arguments as String)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
startForegroundService(serviceIntent)
|
||||||
|
} else {
|
||||||
|
startService(serviceIntent)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("MainActivity", "Failed to update sessions: ${e.message}")
|
||||||
|
result.error("SERVICE_ERROR", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
else -> {
|
else -> {
|
||||||
result.notImplemented()
|
result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle intent if launched via notification action
|
||||||
|
handleActionIntent(intent)
|
||||||
|
|
||||||
|
// Register broadcast receiver for stop all connections
|
||||||
|
setupStopAllReceiver()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reqPerm() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if we already have the permission to avoid unnecessary prompts
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED) {
|
||||||
|
// Check if we should show rationale
|
||||||
|
if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.POST_NOTIFICATIONS)) {
|
||||||
|
android.util.Log.i("MainActivity", "User previously denied notification permission")
|
||||||
|
}
|
||||||
|
|
||||||
|
ActivityCompat.requestPermissions(
|
||||||
|
this,
|
||||||
|
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||||
|
123,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Log error but don't crash
|
||||||
|
android.util.Log.e("MainActivity", "Failed to request permissions: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notificationsAllowed(): Boolean {
|
||||||
|
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
handleActionIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleActionIntent(intent: Intent?) {
|
||||||
|
if (intent == null) return
|
||||||
|
when (intent.action) {
|
||||||
|
ACTION_DISCONNECT_SESSION -> {
|
||||||
|
val sessionId = intent.getStringExtra("session_id")
|
||||||
|
if (sessionId != null && ::channel.isInitialized) {
|
||||||
|
try {
|
||||||
|
channel.invokeMethod("disconnectSession", mapOf("id" to sessionId))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("MainActivity", "Failed to invoke disconnect: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun reqPerm() {
|
private fun setupStopAllReceiver() {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
stopAllReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
// Check if we already have the permission to avoid unnecessary prompts
|
if (intent?.action == ACTION_STOP_ALL_CONNECTIONS && ::channel.isInitialized) {
|
||||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
try {
|
||||||
!= PackageManager.PERMISSION_GRANTED) {
|
channel.invokeMethod("stopAllConnections", null)
|
||||||
try {
|
} catch (e: Exception) {
|
||||||
ActivityCompat.requestPermissions(
|
android.util.Log.e("MainActivity", "Failed to invoke stopAllConnections: ${e.message}")
|
||||||
this,
|
}
|
||||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
}
|
||||||
123,
|
}
|
||||||
)
|
}
|
||||||
} catch (e: Exception) {
|
val filter = IntentFilter(ACTION_STOP_ALL_CONNECTIONS)
|
||||||
// Log error but don't crash
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
android.util.Log.e("MainActivity", "Failed to request permissions: ${e.message}")
|
ContextCompat.registerReceiver(this, stopAllReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||||
|
} else {
|
||||||
|
registerReceiver(stopAllReceiver, filter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRequestPermissionsResult(
|
||||||
|
requestCode: Int,
|
||||||
|
permissions: Array<out String>,
|
||||||
|
grantResults: IntArray
|
||||||
|
) {
|
||||||
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||||
|
if (requestCode == 123) {
|
||||||
|
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
android.util.Log.i("MainActivity", "Notification permission granted")
|
||||||
|
} else {
|
||||||
|
android.util.Log.w("MainActivity", "Notification permission denied")
|
||||||
|
// Optionally inform user about the limitation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
stopAllReceiver?.let {
|
||||||
|
try {
|
||||||
|
unregisterReceiver(it)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("MainActivity", "Failed to unregister receiver: ${e.message}")
|
||||||
|
}
|
||||||
|
stopAllReceiver = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,13 +13,24 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import org.json.JSONException
|
||||||
import tech.lolli.toolbox.R
|
import tech.lolli.toolbox.R
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
class HomeWidget : AppWidgetProvider() {
|
class HomeWidget : AppWidgetProvider() {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "HomeWidget"
|
||||||
|
private const val NETWORK_TIMEOUT = 10_000L // 10 seconds
|
||||||
|
private const val COROUTINE_TIMEOUT = 15_000L // 15 seconds
|
||||||
|
private val activeUpdates = ConcurrentHashMap<Int, Boolean>()
|
||||||
|
}
|
||||||
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||||
for (appWidgetId in appWidgetIds) {
|
for (appWidgetId in appWidgetIds) {
|
||||||
updateAppWidget(context, appWidgetManager, appWidgetId)
|
updateAppWidget(context, appWidgetManager, appWidgetId)
|
||||||
@@ -27,105 +38,184 @@ class HomeWidget : AppWidgetProvider() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) {
|
private fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) {
|
||||||
val views = RemoteViews(context.packageName, R.layout.home_widget)
|
// Prevent concurrent updates for the same widget
|
||||||
val sp = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)
|
if (activeUpdates.putIfAbsent(appWidgetId, true) == true) {
|
||||||
var url = sp.getString("widget_$appWidgetId", null)
|
Log.d(TAG, "Widget $appWidgetId is already updating, skipping")
|
||||||
if (url.isNullOrEmpty()) {
|
|
||||||
url = sp.getString("$appWidgetId", null)
|
|
||||||
}
|
|
||||||
if (url.isNullOrEmpty()) {
|
|
||||||
val gUrl = sp.getString("widget_*", null)
|
|
||||||
url = gUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.isNullOrEmpty()) {
|
|
||||||
Log.e("HomeWidget", "URL not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
val intentUpdate = Intent(context, HomeWidget::class.java)
|
|
||||||
intentUpdate.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
|
|
||||||
val ids = intArrayOf(appWidgetId)
|
|
||||||
intentUpdate.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
|
|
||||||
|
|
||||||
var flag = PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
if (Build.VERSION_CODES.O <= Build.VERSION.SDK_INT) {
|
|
||||||
flag = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
}
|
|
||||||
|
|
||||||
val pendingUpdate: PendingIntent = PendingIntent.getBroadcast(
|
|
||||||
context,
|
|
||||||
appWidgetId,
|
|
||||||
intentUpdate,
|
|
||||||
flag)
|
|
||||||
views.setOnClickPendingIntent(R.id.widget_container, pendingUpdate)
|
|
||||||
|
|
||||||
if (url.isNullOrEmpty()) {
|
|
||||||
views.setTextViewText(R.id.widget_name, "No URL")
|
|
||||||
// Update the widget to display a message for missing URL
|
|
||||||
views.setViewVisibility(R.id.error_message, View.VISIBLE)
|
|
||||||
views.setTextViewText(R.id.error_message, "Please configure the widget URL.")
|
|
||||||
views.setViewVisibility(R.id.widget_content, View.GONE)
|
|
||||||
views.setFloat(R.id.widget_name, "setAlpha", 1f)
|
|
||||||
views.setFloat(R.id.error_message, "setAlpha", 1f)
|
|
||||||
appWidgetManager.updateAppWidget(appWidgetId, views)
|
|
||||||
return
|
return
|
||||||
} else {
|
|
||||||
views.setViewVisibility(R.id.widget_cpu_label, View.VISIBLE)
|
|
||||||
views.setViewVisibility(R.id.widget_mem_label, View.VISIBLE)
|
|
||||||
views.setViewVisibility(R.id.widget_disk_label, View.VISIBLE)
|
|
||||||
views.setViewVisibility(R.id.widget_net_label, View.VISIBLE)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val views = RemoteViews(context.packageName, R.layout.home_widget)
|
||||||
|
val url = getWidgetUrl(context, appWidgetId)
|
||||||
|
|
||||||
|
if (url.isNullOrEmpty()) {
|
||||||
|
Log.w(TAG, "URL not found for widget $appWidgetId")
|
||||||
|
showErrorState(views, appWidgetManager, appWidgetId, "Please configure the widget URL.")
|
||||||
|
activeUpdates.remove(appWidgetId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setupClickIntent(context, views, appWidgetId)
|
||||||
|
|
||||||
|
showLoadingState(views, appWidgetManager, appWidgetId)
|
||||||
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
try {
|
withTimeoutOrNull(COROUTINE_TIMEOUT) {
|
||||||
val connection = URL(url).openConnection() as HttpURLConnection
|
try {
|
||||||
connection.requestMethod = "GET"
|
val serverData = fetchServerData(url)
|
||||||
val responseCode = connection.responseCode
|
if (serverData != null) {
|
||||||
if (responseCode == HttpURLConnection.HTTP_OK) {
|
withContext(Dispatchers.Main) {
|
||||||
val jsonStr = connection.inputStream.bufferedReader().use { it.readText() }
|
showSuccessState(views, appWidgetManager, appWidgetId, serverData)
|
||||||
val jsonObject = JSONObject(jsonStr)
|
}
|
||||||
val data = jsonObject.getJSONObject("data")
|
} else {
|
||||||
val server = data.getString("name")
|
withContext(Dispatchers.Main) {
|
||||||
val cpu = data.getString("cpu")
|
showErrorState(views, appWidgetManager, appWidgetId, "Invalid server data received.")
|
||||||
val mem = data.getString("mem")
|
|
||||||
val disk = data.getString("disk")
|
|
||||||
val net = data.getString("net")
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
if (mem.isEmpty() || disk.isEmpty()) {
|
|
||||||
Log.e("HomeWidget", "Failed to retrieve status: Memory or disk information is empty")
|
|
||||||
return@withContext
|
|
||||||
}
|
}
|
||||||
views.setTextViewText(R.id.widget_name, server)
|
|
||||||
views.setTextViewText(R.id.widget_cpu, cpu)
|
|
||||||
views.setTextViewText(R.id.widget_mem, mem)
|
|
||||||
views.setTextViewText(R.id.widget_disk, disk)
|
|
||||||
views.setTextViewText(R.id.widget_net, net)
|
|
||||||
val timeStr = android.text.format.DateFormat.format("HH:mm", java.util.Date()).toString()
|
|
||||||
views.setTextViewText(R.id.widget_time, timeStr)
|
|
||||||
views.setFloat(R.id.widget_name, "setAlpha", 1f)
|
|
||||||
views.setFloat(R.id.widget_cpu_label, "setAlpha", 1f)
|
|
||||||
views.setFloat(R.id.widget_mem_label, "setAlpha", 1f)
|
|
||||||
views.setFloat(R.id.widget_disk_label, "setAlpha", 1f)
|
|
||||||
views.setFloat(R.id.widget_net_label, "setAlpha", 1f)
|
|
||||||
views.setFloat(R.id.widget_time, "setAlpha", 1f)
|
|
||||||
appWidgetManager.updateAppWidget(appWidgetId, views)
|
|
||||||
}
|
}
|
||||||
} else {
|
} catch (e: Exception) {
|
||||||
throw FileNotFoundException("HTTP response code: $responseCode")
|
Log.e(TAG, "Error updating widget $appWidgetId: ${e.message}", e)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
val errorMessage = when (e) {
|
||||||
|
is SocketTimeoutException -> "Connection timeout. Please check your network."
|
||||||
|
is IOException -> "Network error. Please check your connection."
|
||||||
|
is JSONException -> "Invalid data format received from server."
|
||||||
|
else -> "Failed to retrieve data: ${e.message}"
|
||||||
|
}
|
||||||
|
showErrorState(views, appWidgetManager, appWidgetId, errorMessage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} ?: run {
|
||||||
Log.e("HomeWidget", "Error updating widget: ${e.localizedMessage}", e)
|
Log.w(TAG, "Widget update timed out for widget $appWidgetId")
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
views.setTextViewText(R.id.widget_name, "Error")
|
showErrorState(views, appWidgetManager, appWidgetId, "Update timed out. Please try again.")
|
||||||
// Update the widget to display a message for data retrieval failure
|
|
||||||
views.setViewVisibility(R.id.error_message, View.VISIBLE)
|
|
||||||
views.setTextViewText(R.id.error_message, "Failed to retrieve data.")
|
|
||||||
views.setViewVisibility(R.id.widget_content, View.GONE)
|
|
||||||
views.setFloat(R.id.widget_name, "setAlpha", 1f)
|
|
||||||
views.setFloat(R.id.error_message, "setAlpha", 1f)
|
|
||||||
appWidgetManager.updateAppWidget(appWidgetId, views)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
activeUpdates.remove(appWidgetId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getWidgetUrl(context: Context, appWidgetId: Int): String? {
|
||||||
|
val sp = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)
|
||||||
|
return sp.getString("widget_$appWidgetId", null)
|
||||||
|
?: sp.getString("$appWidgetId", null)
|
||||||
|
?: sp.getString("widget_*", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupClickIntent(context: Context, views: RemoteViews, appWidgetId: Int) {
|
||||||
|
val intentConfigure = Intent(context, WidgetConfigureActivity::class.java).apply {
|
||||||
|
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
||||||
|
}
|
||||||
|
|
||||||
|
val flag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
} else {
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
}
|
||||||
|
|
||||||
|
val pendingConfigure = PendingIntent.getActivity(context, appWidgetId, intentConfigure, flag)
|
||||||
|
views.setOnClickPendingIntent(R.id.widget_container, pendingConfigure)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchServerData(url: String): ServerData? = withContext(Dispatchers.IO) {
|
||||||
|
var connection: HttpURLConnection? = null
|
||||||
|
try {
|
||||||
|
connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
|
requestMethod = "GET"
|
||||||
|
connectTimeout = NETWORK_TIMEOUT.toInt()
|
||||||
|
readTimeout = NETWORK_TIMEOUT.toInt()
|
||||||
|
setRequestProperty("User-Agent", "ServerBox-Widget/1.0")
|
||||||
|
setRequestProperty("Accept", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
|
||||||
|
throw IOException("HTTP ${connection.responseCode}: ${connection.responseMessage}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val jsonStr = connection.inputStream.bufferedReader().use { it.readText() }
|
||||||
|
parseServerData(jsonStr)
|
||||||
|
} finally {
|
||||||
|
connection?.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseServerData(jsonStr: String): ServerData? {
|
||||||
|
return try {
|
||||||
|
val jsonObject = JSONObject(jsonStr)
|
||||||
|
val data = jsonObject.getJSONObject("data")
|
||||||
|
|
||||||
|
val server = data.optString("name", "Unknown Server")
|
||||||
|
val cpu = data.optString("cpu", "").takeIf { it.isNotBlank() } ?: "N/A"
|
||||||
|
val mem = data.optString("mem", "").takeIf { it.isNotBlank() } ?: "N/A"
|
||||||
|
val disk = data.optString("disk", "").takeIf { it.isNotBlank() } ?: "N/A"
|
||||||
|
val net = data.optString("net", "").takeIf { it.isNotBlank() } ?: "N/A"
|
||||||
|
|
||||||
|
// Return data even if some fields are missing, providing defaults
|
||||||
|
// Only reject if we can't parse the JSON structure properly
|
||||||
|
ServerData(server, cpu, mem, disk, net)
|
||||||
|
} catch (e: JSONException) {
|
||||||
|
Log.e(TAG, "JSON parsing error: ${e.message}", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showLoadingState(views: RemoteViews, appWidgetManager: AppWidgetManager, appWidgetId: Int) {
|
||||||
|
views.apply {
|
||||||
|
setTextViewText(R.id.widget_name, "Loading...")
|
||||||
|
setViewVisibility(R.id.error_message, View.GONE)
|
||||||
|
setViewVisibility(R.id.widget_content, View.VISIBLE)
|
||||||
|
setViewVisibility(R.id.widget_cpu_label, View.VISIBLE)
|
||||||
|
setViewVisibility(R.id.widget_mem_label, View.VISIBLE)
|
||||||
|
setViewVisibility(R.id.widget_disk_label, View.VISIBLE)
|
||||||
|
setViewVisibility(R.id.widget_net_label, View.VISIBLE)
|
||||||
|
setViewVisibility(R.id.widget_progress, View.VISIBLE)
|
||||||
|
setFloat(R.id.widget_name, "setAlpha", 0.7f)
|
||||||
|
}
|
||||||
|
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showSuccessState(views: RemoteViews, appWidgetManager: AppWidgetManager, appWidgetId: Int, data: ServerData) {
|
||||||
|
views.apply {
|
||||||
|
setTextViewText(R.id.widget_name, data.name)
|
||||||
|
setTextViewText(R.id.widget_cpu, data.cpu)
|
||||||
|
setTextViewText(R.id.widget_mem, data.mem)
|
||||||
|
setTextViewText(R.id.widget_disk, data.disk)
|
||||||
|
setTextViewText(R.id.widget_net, data.net)
|
||||||
|
|
||||||
|
val timeStr = android.text.format.DateFormat.format("HH:mm", java.util.Date()).toString()
|
||||||
|
setTextViewText(R.id.widget_time, timeStr)
|
||||||
|
|
||||||
|
setViewVisibility(R.id.error_message, View.GONE)
|
||||||
|
setViewVisibility(R.id.widget_content, View.VISIBLE)
|
||||||
|
setViewVisibility(R.id.widget_progress, View.GONE)
|
||||||
|
|
||||||
|
// Smooth fade-in animation
|
||||||
|
setFloat(R.id.widget_name, "setAlpha", 1f)
|
||||||
|
setFloat(R.id.widget_cpu_label, "setAlpha", 1f)
|
||||||
|
setFloat(R.id.widget_mem_label, "setAlpha", 1f)
|
||||||
|
setFloat(R.id.widget_disk_label, "setAlpha", 1f)
|
||||||
|
setFloat(R.id.widget_net_label, "setAlpha", 1f)
|
||||||
|
setFloat(R.id.widget_time, "setAlpha", 1f)
|
||||||
|
}
|
||||||
|
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showErrorState(views: RemoteViews, appWidgetManager: AppWidgetManager, appWidgetId: Int, errorMessage: String) {
|
||||||
|
views.apply {
|
||||||
|
setTextViewText(R.id.widget_name, "Error")
|
||||||
|
setViewVisibility(R.id.error_message, View.VISIBLE)
|
||||||
|
setTextViewText(R.id.error_message, errorMessage)
|
||||||
|
setViewVisibility(R.id.widget_content, View.GONE)
|
||||||
|
setViewVisibility(R.id.widget_progress, View.GONE)
|
||||||
|
setFloat(R.id.widget_name, "setAlpha", 1f)
|
||||||
|
setFloat(R.id.error_message, "setAlpha", 1f)
|
||||||
|
}
|
||||||
|
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ServerData(
|
||||||
|
val name: String,
|
||||||
|
val cpu: String,
|
||||||
|
val mem: String,
|
||||||
|
val disk: String,
|
||||||
|
val net: String
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package tech.lolli.toolbox.widget
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Patterns
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.EditText
|
||||||
|
import tech.lolli.toolbox.R
|
||||||
|
|
||||||
|
class WidgetConfigureActivity : Activity() {
|
||||||
|
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
|
||||||
|
private lateinit var urlEditText: EditText
|
||||||
|
private lateinit var saveButton: Button
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.widget_configure)
|
||||||
|
|
||||||
|
// 设置结果为取消,以防用户在完成配置前退出
|
||||||
|
setResult(RESULT_CANCELED)
|
||||||
|
|
||||||
|
// 获取 widget ID
|
||||||
|
val extras = intent.extras
|
||||||
|
if (extras != null) {
|
||||||
|
appWidgetId = extras.getInt(
|
||||||
|
AppWidgetManager.EXTRA_APPWIDGET_ID,
|
||||||
|
AppWidgetManager.INVALID_APPWIDGET_ID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有有效的 widget ID,完成 activity
|
||||||
|
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化 UI 元素
|
||||||
|
urlEditText = findViewById(R.id.url_edit_text)
|
||||||
|
saveButton = findViewById(R.id.save_button)
|
||||||
|
|
||||||
|
// 从 SharedPreferences 加载现有配置
|
||||||
|
val sp = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)
|
||||||
|
val existingUrl = sp.getString("widget_$appWidgetId", "")
|
||||||
|
urlEditText.setText(existingUrl)
|
||||||
|
|
||||||
|
// 设置保存按钮点击事件
|
||||||
|
saveButton.setOnClickListener {
|
||||||
|
val url = urlEditText.text.toString().trim()
|
||||||
|
if (url.isEmpty()) {
|
||||||
|
urlEditText.error = "Please enter a URL"
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 URL 格式
|
||||||
|
if (!Patterns.WEB_URL.matcher(url).matches()) {
|
||||||
|
urlEditText.error = "Please enter a valid URL"
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存 URL 到 SharedPreferences
|
||||||
|
val editor = sp.edit()
|
||||||
|
editor.putString("widget_$appWidgetId", url)
|
||||||
|
editor.apply()
|
||||||
|
|
||||||
|
// 更新 widget 使用 AppWidgetManager
|
||||||
|
val appWidgetManager = AppWidgetManager.getInstance(this)
|
||||||
|
val updateIntent = Intent(this, HomeWidget::class.java).apply {
|
||||||
|
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
|
||||||
|
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(appWidgetId))
|
||||||
|
}
|
||||||
|
sendBroadcast(updateIntent)
|
||||||
|
|
||||||
|
// 设置结果并结束 activity
|
||||||
|
val resultValue = Intent()
|
||||||
|
resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
||||||
|
setResult(RESULT_OK, resultValue)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,14 +10,17 @@
|
|||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_name"
|
android:id="@+id/widget_name"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textColor="@color/widgetText"
|
android:textColor="@color/widgetText"
|
||||||
android:textSize="23sp"
|
android:textSize="20sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end"
|
||||||
android:alpha="0"
|
android:alpha="0"
|
||||||
android:animateLayoutChanges="true"
|
android:animateLayoutChanges="true"
|
||||||
|
android:fadingEdge="horizontal"
|
||||||
|
android:singleLine="true"
|
||||||
tools:text="Server Name" />
|
tools:text="Server Name" />
|
||||||
|
|
||||||
<!-- Wrap the content in a LinearLayout for easy visibility management -->
|
<!-- Wrap the content in a LinearLayout for easy visibility management -->
|
||||||
@@ -27,121 +30,138 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:layout_below="@id/widget_name"
|
android:layout_below="@id/widget_name"
|
||||||
android:paddingTop="13dp">
|
android:layout_marginTop="8dp">
|
||||||
|
|
||||||
<RelativeLayout
|
<RelativeLayout
|
||||||
android:id="@+id/widget_container_inner"
|
android:id="@+id/widget_container_inner"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="wrap_content"
|
||||||
android:gravity="center_vertical"
|
|
||||||
android:paddingTop="13dp"
|
|
||||||
android:animateLayoutChanges="true">
|
android:animateLayoutChanges="true">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/widget_cpu_label"
|
android:id="@+id/widget_cpu_label"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:paddingBottom="2.7dp"
|
android:layout_marginBottom="4dp"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:orientation="horizontal">
|
android:orientation="horizontal"
|
||||||
|
android:alpha="0"
|
||||||
|
android:animateLayoutChanges="true">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="17dp"
|
android:layout_width="16dp"
|
||||||
android:layout_height="17dp"
|
android:layout_height="16dp"
|
||||||
android:src="@drawable/speed_24">
|
android:src="@drawable/speed_24"
|
||||||
</ImageView>
|
android:layout_gravity="center_vertical"
|
||||||
|
android:contentDescription="CPU usage" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_cpu"
|
android:id="@+id/widget_cpu"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="11dp"
|
android:layout_weight="1"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
android:singleLine="true"
|
android:singleLine="true"
|
||||||
android:ellipsize = "marquee"
|
android:ellipsize="end"
|
||||||
android:textColor="@color/widgetSummaryText"
|
android:textColor="@color/widgetSummaryText"
|
||||||
android:textSize="12.7sp"
|
android:textSize="12sp"
|
||||||
tools:text="CPU" />
|
tools:text="CPU: 25.6%" />
|
||||||
|
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/widget_mem_label"
|
android:id="@+id/widget_mem_label"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:paddingBottom="2.7dp"
|
android:layout_marginBottom="4dp"
|
||||||
android:layout_below="@id/widget_cpu_label"
|
android:layout_below="@id/widget_cpu_label"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:orientation="horizontal">
|
android:orientation="horizontal"
|
||||||
|
android:alpha="0"
|
||||||
|
android:animateLayoutChanges="true">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="17dp"
|
android:layout_width="16dp"
|
||||||
android:layout_height="17dp"
|
android:layout_height="16dp"
|
||||||
android:src="@drawable/memory_24">
|
android:src="@drawable/memory_24"
|
||||||
</ImageView>
|
android:layout_gravity="center_vertical"
|
||||||
|
android:contentDescription="Memory usage" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_mem"
|
android:id="@+id/widget_mem"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="11dp"
|
android:layout_weight="1"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end"
|
||||||
android:textColor="@color/widgetSummaryText"
|
android:textColor="@color/widgetSummaryText"
|
||||||
android:textSize="12.7sp"
|
android:textSize="12sp"
|
||||||
tools:text="Mem" />
|
tools:text="Memory: 4.2GB / 8GB" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/widget_disk_label"
|
android:id="@+id/widget_disk_label"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:paddingBottom="2.7dp"
|
android:layout_marginBottom="4dp"
|
||||||
android:layout_below="@id/widget_mem_label"
|
android:layout_below="@id/widget_mem_label"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:orientation="horizontal">
|
android:orientation="horizontal"
|
||||||
|
android:alpha="0"
|
||||||
|
android:animateLayoutChanges="true">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="17dp"
|
android:layout_width="16dp"
|
||||||
android:layout_height="17dp"
|
android:layout_height="16dp"
|
||||||
android:src="@drawable/storage_24">
|
android:src="@drawable/storage_24"
|
||||||
</ImageView>
|
android:layout_gravity="center_vertical"
|
||||||
|
android:contentDescription="Disk usage" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_disk"
|
android:id="@+id/widget_disk"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="11dp"
|
android:layout_weight="1"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end"
|
||||||
android:textColor="@color/widgetSummaryText"
|
android:textColor="@color/widgetSummaryText"
|
||||||
android:textSize="12.7sp"
|
android:textSize="12sp"
|
||||||
tools:text="Disk" />
|
tools:text="Disk: 125GB / 250GB" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/widget_net_label"
|
android:id="@+id/widget_net_label"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_below="@id/widget_disk_label"
|
android:layout_below="@id/widget_disk_label"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:orientation="horizontal">
|
android:orientation="horizontal"
|
||||||
|
android:alpha="0"
|
||||||
|
android:animateLayoutChanges="true">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="17dp"
|
android:layout_width="16dp"
|
||||||
android:layout_height="17dp"
|
android:layout_height="16dp"
|
||||||
android:src="@drawable/net_24">
|
android:src="@drawable/net_24"
|
||||||
</ImageView>
|
android:layout_gravity="center_vertical"
|
||||||
|
android:contentDescription="Network usage" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_net"
|
android:id="@+id/widget_net"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="11dp"
|
android:layout_weight="1"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end"
|
||||||
android:textColor="@color/widgetSummaryText"
|
android:textColor="@color/widgetSummaryText"
|
||||||
android:textSize="12.7sp"
|
android:textSize="12sp"
|
||||||
tools:text="Net" />
|
tools:text="Network: 15MB/s ↓ 8MB/s ↑" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
@@ -149,29 +169,45 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Add a TextView for error messages -->
|
<!-- Error message display -->
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/error_message"
|
android:id="@+id/error_message"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_below="@id/widget_name"
|
android:layout_below="@id/widget_name"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
android:textColor="@color/widgetSummaryText"
|
android:textColor="@color/widgetSummaryText"
|
||||||
android:textSize="12sp"
|
android:textSize="11sp"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
android:alpha="0"
|
android:alpha="0"
|
||||||
android:animateLayoutChanges="true"
|
android:animateLayoutChanges="true"
|
||||||
tools:text="Error message" />
|
android:lineSpacingMultiplier="1.2"
|
||||||
|
android:maxLines="3"
|
||||||
|
android:ellipsize="end"
|
||||||
|
tools:text="Error message text that might be longer than usual" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_time"
|
android:id="@+id/widget_time"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_alignParentBottom="true"
|
android:layout_alignParentBottom="true"
|
||||||
android:maxLines="2"
|
android:layout_alignParentEnd="true"
|
||||||
|
android:maxLines="1"
|
||||||
android:textColor="@color/widgetSummaryText"
|
android:textColor="@color/widgetSummaryText"
|
||||||
android:textSize="11sp"
|
android:textSize="10sp"
|
||||||
android:alpha="0"
|
android:alpha="0"
|
||||||
android:animateLayoutChanges="true"
|
android:animateLayoutChanges="true"
|
||||||
tools:text="UpdateTime" />
|
android:fontFamily="monospace"
|
||||||
|
tools:text="12:34" />
|
||||||
|
|
||||||
|
<!-- Progress indicator for loading state -->
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/widget_progress"
|
||||||
|
style="?android:attr/progressBarStyleLarge"
|
||||||
|
android:layout_width="32dp"
|
||||||
|
android:layout_height="32dp"
|
||||||
|
android:layout_centerInParent="true"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:indeterminate="true" />
|
||||||
|
|
||||||
</RelativeLayout>
|
</RelativeLayout>
|
||||||
38
android/app/src/main/res/layout/widget_configure.xml
Normal file
38
android/app/src/main/res/layout/widget_configure.xml
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Widget URL"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
android:textColor="@android:color/black" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/url_edit_text"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="https://server/status"
|
||||||
|
android:inputType="textUri"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
android:background="@android:drawable/edit_text"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:textColor="@android:color/black"
|
||||||
|
android:textColorHint="@android:color/darker_gray" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/save_button"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Save"
|
||||||
|
android:background="#8b2252"
|
||||||
|
android:textColor="@android:color/white"
|
||||||
|
android:padding="12dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
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:minHeight="110dp"
|
||||||
android:updatePeriodMillis="1800001"
|
android:updatePeriodMillis="1800001"
|
||||||
android:initialLayout="@layout/home_widget"
|
android:initialLayout="@layout/home_widget"
|
||||||
|
android:configure="tech.lolli.toolbox.widget.WidgetConfigureActivity"
|
||||||
android:resizeMode="none"
|
android:resizeMode="none"
|
||||||
android:widgetCategory="home_screen">
|
android:widgetCategory="home_screen">
|
||||||
</appwidget-provider>
|
</appwidget-provider>
|
||||||
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:
|
extensions:
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
variables:
|
|
||||||
output: dist/
|
|
||||||
releases:
|
|
||||||
- name: linux
|
|
||||||
jobs:
|
|
||||||
- name: release-linux-deb
|
|
||||||
package:
|
|
||||||
platform: linux
|
|
||||||
target: deb
|
|
||||||
- name: release-linux-rpm
|
|
||||||
package:
|
|
||||||
platform: linux
|
|
||||||
target: rpm
|
|
||||||
7
fastlane/metadata/android/ru/full_description.txt
Normal file
7
fastlane/metadata/android/ru/full_description.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Проект на базе Flutter, предоставляющий диаграммы состояний серверов под Linux, Unix и Windows и инструменты для управления ими.
|
||||||
|
|
||||||
|
Особая благодарность dartssh2 и xterm.dart.
|
||||||
|
|
||||||
|
* Диаграмма состояния (ЦП, датчики, видеокарта…), SSH Term, SFTP, Docker, пакеты, процессы…
|
||||||
|
* Платформозависимые: биометрическая аутентификация, push-уведомления, виджет, приложение для watchOS…
|
||||||
|
* Многоязычная поддержка: English, 简体中文; Deutsch, 繁體中文, Indonesian, Français, Dutch; Español, Русский язык, Português, 日本語
|
||||||
1
fastlane/metadata/android/ru/short_description.txt
Normal file
1
fastlane/metadata/android/ru/short_description.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Приложение для мониторинга серверов и набор инструментов управления ими
|
||||||
@@ -21,6 +21,6 @@
|
|||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<key>MinimumOSVersion</key>
|
<key>MinimumOSVersion</key>
|
||||||
<string>12.0</string>
|
<string>13.0</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Uncomment this line to define a global platform for your project
|
# Uncomment this line to define a global platform for your project
|
||||||
# platform :ios, '12.0'
|
# platform :ios, '13.0'
|
||||||
|
|
||||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
PODS:
|
PODS:
|
||||||
- app_links (0.0.2):
|
- app_links (6.4.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- camera_avfoundation (0.0.1):
|
- camera_avfoundation (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
@@ -8,6 +8,8 @@ PODS:
|
|||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
- flutter_native_splash (2.4.3):
|
- flutter_native_splash (2.4.3):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- flutter_secure_storage (6.0.0):
|
||||||
|
- Flutter
|
||||||
- icloud_storage (0.0.1):
|
- icloud_storage (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- local_auth_darwin (0.0.1):
|
- local_auth_darwin (0.0.1):
|
||||||
@@ -38,6 +40,7 @@ DEPENDENCIES:
|
|||||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
- 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`)
|
- icloud_storage (from `.symlinks/plugins/icloud_storage/ios`)
|
||||||
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
|
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
|
||||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||||
@@ -60,6 +63,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter
|
:path: Flutter
|
||||||
flutter_native_splash:
|
flutter_native_splash:
|
||||||
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
||||||
|
flutter_secure_storage:
|
||||||
|
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
||||||
icloud_storage:
|
icloud_storage:
|
||||||
:path: ".symlinks/plugins/icloud_storage/ios"
|
:path: ".symlinks/plugins/icloud_storage/ios"
|
||||||
local_auth_darwin:
|
local_auth_darwin:
|
||||||
@@ -82,13 +87,14 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/watch_connectivity/ios"
|
:path: ".symlinks/plugins/watch_connectivity/ios"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7
|
app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a
|
||||||
camera_avfoundation: be3be85408cd4126f250386828e9b1dfa40ab436
|
camera_avfoundation: be3be85408cd4126f250386828e9b1dfa40ab436
|
||||||
file_picker: fb04e739ae6239a76ce1f571863a196a922c87d4
|
file_picker: fb04e739ae6239a76ce1f571863a196a922c87d4
|
||||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
||||||
|
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
||||||
icloud_storage: e55639f0c0d7cb2b0ba9c0b3d5968ccca9cd9aa2
|
icloud_storage: e55639f0c0d7cb2b0ba9c0b3d5968ccca9cd9aa2
|
||||||
local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391
|
local_auth_darwin: d2e8c53ef0c4f43c646462e3415432c4dab3ae19
|
||||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||||
plain_notification_token: 047876b9d80a5b93565ddcc13a487a7e7b906f7d
|
plain_notification_token: 047876b9d80a5b93565ddcc13a487a7e7b906f7d
|
||||||
@@ -98,6 +104,6 @@ SPEC CHECKSUMS:
|
|||||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||||
watch_connectivity: 88e5bea25b473e66ef8d3f960954d154ed0356d6
|
watch_connectivity: 88e5bea25b473e66ef8d3f960954d154ed0356d6
|
||||||
|
|
||||||
PODFILE CHECKSUM: ec6ef69056f066e8b21a3391082f23b5ad2d37f8
|
PODFILE CHECKSUM: 5a0fb6438066e44ab2c77bd223668d351b8d8461
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
|
4A2DCD6B2E4B127100CF68B7 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */; };
|
||||||
|
4A2DCD6C2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */; };
|
||||||
|
4A2DCD6F2E4B128100CF68B7 /* TerminalLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD6D2E4B128100CF68B7 /* TerminalLiveActivity.swift */; };
|
||||||
|
4A2DCD702E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD6E2E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift */; };
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
7538AEC32BB83FAB002AB82A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7538AEC22BB83FAB002AB82A /* PrivacyInfo.xcprivacy */; };
|
7538AEC32BB83FAB002AB82A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7538AEC22BB83FAB002AB82A /* PrivacyInfo.xcprivacy */; };
|
||||||
7538AEC52BB83FC8002AB82A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7538AEC42BB83FC8002AB82A /* PrivacyInfo.xcprivacy */; };
|
7538AEC52BB83FC8002AB82A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7538AEC42BB83FC8002AB82A /* PrivacyInfo.xcprivacy */; };
|
||||||
@@ -36,6 +40,8 @@
|
|||||||
E3AE8AEB2AB601DB000A6459 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AE8AE92AB601DB000A6459 /* Utils.swift */; };
|
E3AE8AEB2AB601DB000A6459 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AE8AE92AB601DB000A6459 /* Utils.swift */; };
|
||||||
E3AE8AEC2AB601DB000A6459 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AE8AE92AB601DB000A6459 /* Utils.swift */; };
|
E3AE8AEC2AB601DB000A6459 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AE8AE92AB601DB000A6459 /* Utils.swift */; };
|
||||||
E3DB67ED2A31FE200027B8CB /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E3DB67EB2A31FE200027B8CB /* LaunchScreen.storyboard */; };
|
E3DB67ED2A31FE200027B8CB /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E3DB67EB2A31FE200027B8CB /* LaunchScreen.storyboard */; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F0005 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = F0A1B2C31A2B3C4D5E6F0001 /* Localizable.strings */; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F1005 /* Localizable.strings (StatusWidget) in Resources */ = {isa = PBXBuildFile; fileRef = F0A1B2C31A2B3C4D5E6F1001 /* Localizable.strings */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -95,6 +101,10 @@
|
|||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||||
278C1EB3935F9285537B0516 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
278C1EB3935F9285537B0516 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
|
4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; };
|
||||||
|
4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLiveActivityAttributes.swift; sourceTree = "<group>"; };
|
||||||
|
4A2DCD6D2E4B128100CF68B7 /* TerminalLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLiveActivity.swift; sourceTree = "<group>"; };
|
||||||
|
4A2DCD6E2E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLiveActivityAttributes.swift; sourceTree = "<group>"; };
|
||||||
5A4B3EB10512B2EB8E10213B /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
5A4B3EB10512B2EB8E10213B /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
@@ -156,6 +166,26 @@
|
|||||||
E3D26BD22B9966EC00D83425 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Main.strings; sourceTree = "<group>"; };
|
E3D26BD22B9966EC00D83425 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Main.strings; sourceTree = "<group>"; };
|
||||||
E3D26BD32B9966EC00D83425 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/LaunchScreen.strings; sourceTree = "<group>"; };
|
E3D26BD32B9966EC00D83425 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/LaunchScreen.strings; sourceTree = "<group>"; };
|
||||||
E3DB67EC2A31FE200027B8CB /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
E3DB67EC2A31FE200027B8CB /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F0002 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F0003 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F0004 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F0006 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F0007 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F0008 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F0009 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F000A /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F000B /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F000C /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F1002 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F1003 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F1004 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F1006 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F1007 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F1008 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F1009 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F100A /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F100B /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F100C /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -233,6 +263,7 @@
|
|||||||
97C146F01CF9000F007C117D /* Runner */ = {
|
97C146F01CF9000F007C117D /* Runner */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
F0A1B2C31A2B3C4D5E6F0001 /* Localizable.strings */,
|
||||||
7538AEC22BB83FAB002AB82A /* PrivacyInfo.xcprivacy */,
|
7538AEC22BB83FAB002AB82A /* PrivacyInfo.xcprivacy */,
|
||||||
E398BF6A29BDB34500FE4FD5 /* Runner.entitlements */,
|
E398BF6A29BDB34500FE4FD5 /* Runner.entitlements */,
|
||||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||||
@@ -242,6 +273,8 @@
|
|||||||
E39A76AD2AB9A2F70067C641 /* Info-Profile.plist */,
|
E39A76AD2AB9A2F70067C641 /* Info-Profile.plist */,
|
||||||
E39A76AC2AB9A2F70067C641 /* Info-Release.plist */,
|
E39A76AC2AB9A2F70067C641 /* Info-Release.plist */,
|
||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||||
|
4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */,
|
||||||
|
4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */,
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||||
E3AE8AE92AB601DB000A6459 /* Utils.swift */,
|
E3AE8AE92AB601DB000A6459 /* Utils.swift */,
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||||
@@ -263,8 +296,11 @@
|
|||||||
E33A3E3A2A626DCE009744AB /* StatusWidget */ = {
|
E33A3E3A2A626DCE009744AB /* StatusWidget */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
F0A1B2C31A2B3C4D5E6F1001 /* Localizable.strings */,
|
||||||
7538AEC42BB83FC8002AB82A /* PrivacyInfo.xcprivacy */,
|
7538AEC42BB83FC8002AB82A /* PrivacyInfo.xcprivacy */,
|
||||||
E33A3E3B2A626DCE009744AB /* StatusWidgetBundle.swift */,
|
E33A3E3B2A626DCE009744AB /* StatusWidgetBundle.swift */,
|
||||||
|
4A2DCD6D2E4B128100CF68B7 /* TerminalLiveActivity.swift */,
|
||||||
|
4A2DCD6E2E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift */,
|
||||||
E33A3E3F2A626DCE009744AB /* StatusWidget.swift */,
|
E33A3E3F2A626DCE009744AB /* StatusWidget.swift */,
|
||||||
E37C48ED2B9C30EE00E542D2 /* StatusWidget.intentdefinition */,
|
E37C48ED2B9C30EE00E542D2 /* StatusWidget.intentdefinition */,
|
||||||
E33A3E442A626DD0009744AB /* Info.plist */,
|
E33A3E442A626DD0009744AB /* Info.plist */,
|
||||||
@@ -412,6 +448,7 @@
|
|||||||
E39A76B02AB9A2F70067C641 /* Info-Profile.plist in Resources */,
|
E39A76B02AB9A2F70067C641 /* Info-Profile.plist in Resources */,
|
||||||
7538AEC32BB83FAB002AB82A /* PrivacyInfo.xcprivacy in Resources */,
|
7538AEC32BB83FAB002AB82A /* PrivacyInfo.xcprivacy in Resources */,
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F0005 /* Localizable.strings in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -420,6 +457,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
7538AEC52BB83FC8002AB82A /* PrivacyInfo.xcprivacy in Resources */,
|
7538AEC52BB83FC8002AB82A /* PrivacyInfo.xcprivacy in Resources */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F1005 /* Localizable.strings (StatusWidget) in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -516,6 +554,8 @@
|
|||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
E37C48EA2B9C30EE00E542D2 /* StatusWidget.intentdefinition in Sources */,
|
E37C48EA2B9C30EE00E542D2 /* StatusWidget.intentdefinition in Sources */,
|
||||||
E3AE8AEA2AB601DB000A6459 /* Utils.swift in Sources */,
|
E3AE8AEA2AB601DB000A6459 /* Utils.swift in Sources */,
|
||||||
|
4A2DCD6B2E4B127100CF68B7 /* LiveActivityManager.swift in Sources */,
|
||||||
|
4A2DCD6C2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -525,6 +565,8 @@
|
|||||||
files = (
|
files = (
|
||||||
E33A3E402A626DCE009744AB /* StatusWidget.swift in Sources */,
|
E33A3E402A626DCE009744AB /* StatusWidget.swift in Sources */,
|
||||||
E37C48EB2B9C30EE00E542D2 /* StatusWidget.intentdefinition in Sources */,
|
E37C48EB2B9C30EE00E542D2 /* StatusWidget.intentdefinition in Sources */,
|
||||||
|
4A2DCD6F2E4B128100CF68B7 /* TerminalLiveActivity.swift in Sources */,
|
||||||
|
4A2DCD702E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */,
|
||||||
E33A3E3C2A626DCE009744AB /* StatusWidgetBundle.swift in Sources */,
|
E33A3E3C2A626DCE009744AB /* StatusWidgetBundle.swift in Sources */,
|
||||||
E3AE8AEB2AB601DB000A6459 /* Utils.swift in Sources */,
|
E3AE8AEB2AB601DB000A6459 /* Utils.swift in Sources */,
|
||||||
);
|
);
|
||||||
@@ -610,6 +652,40 @@
|
|||||||
name = LaunchScreen.storyboard;
|
name = LaunchScreen.storyboard;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
F0A1B2C31A2B3C4D5E6F0001 /* Localizable.strings */ = {
|
||||||
|
isa = PBXVariantGroup;
|
||||||
|
children = (
|
||||||
|
F0A1B2C31A2B3C4D5E6F0002 /* en */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F0003 /* zh-Hans */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F0004 /* zh-Hant */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F0006 /* fr */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F0007 /* ru */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F0008 /* es */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F0009 /* de */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F000A /* pt-BR */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F000B /* id */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F000C /* ja */,
|
||||||
|
);
|
||||||
|
name = Localizable.strings;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
F0A1B2C31A2B3C4D5E6F1001 /* Localizable.strings */ = {
|
||||||
|
isa = PBXVariantGroup;
|
||||||
|
children = (
|
||||||
|
F0A1B2C31A2B3C4D5E6F1002 /* en */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F1003 /* zh-Hans */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F1004 /* zh-Hant */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F1006 /* fr */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F1007 /* ru */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F1008 /* es */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F1009 /* de */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F100A /* pt-BR */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F100B /* id */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F100C /* ja */,
|
||||||
|
);
|
||||||
|
name = Localizable.strings;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXVariantGroup section */
|
/* End PBXVariantGroup section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
@@ -655,7 +731,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
@@ -672,17 +748,17 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 1189;
|
CURRENT_PROJECT_VERSION = 1270;
|
||||||
DEVELOPMENT_TEAM = BA88US33G6;
|
DEVELOPMENT_TEAM = BA88US33G6;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
|
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1189;
|
MARKETING_VERSION = 1.0.1270;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
|
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
@@ -739,7 +815,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -789,7 +865,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
@@ -808,17 +884,17 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 1189;
|
CURRENT_PROJECT_VERSION = 1270;
|
||||||
DEVELOPMENT_TEAM = BA88US33G6;
|
DEVELOPMENT_TEAM = BA88US33G6;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
|
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1189;
|
MARKETING_VERSION = 1.0.1270;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
|
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
@@ -836,17 +912,17 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 1189;
|
CURRENT_PROJECT_VERSION = 1270;
|
||||||
DEVELOPMENT_TEAM = BA88US33G6;
|
DEVELOPMENT_TEAM = BA88US33G6;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
|
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1189;
|
MARKETING_VERSION = 1.0.1270;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
|
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
@@ -867,7 +943,7 @@
|
|||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1189;
|
CURRENT_PROJECT_VERSION = 1270;
|
||||||
DEVELOPMENT_TEAM = BA88US33G6;
|
DEVELOPMENT_TEAM = BA88US33G6;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -880,7 +956,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1189;
|
MARKETING_VERSION = 1.0.1270;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
|
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
|
||||||
@@ -906,7 +982,7 @@
|
|||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1189;
|
CURRENT_PROJECT_VERSION = 1270;
|
||||||
DEVELOPMENT_TEAM = BA88US33G6;
|
DEVELOPMENT_TEAM = BA88US33G6;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -919,7 +995,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1189;
|
MARKETING_VERSION = 1.0.1270;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
|
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -942,7 +1018,7 @@
|
|||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1189;
|
CURRENT_PROJECT_VERSION = 1270;
|
||||||
DEVELOPMENT_TEAM = BA88US33G6;
|
DEVELOPMENT_TEAM = BA88US33G6;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -955,7 +1031,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1189;
|
MARKETING_VERSION = 1.0.1270;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
|
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -978,7 +1054,7 @@
|
|||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1189;
|
CURRENT_PROJECT_VERSION = 1270;
|
||||||
DEVELOPMENT_ASSET_PATHS = "";
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
DEVELOPMENT_TEAM = BA88US33G6;
|
DEVELOPMENT_TEAM = BA88US33G6;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -990,7 +1066,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1189;
|
MARKETING_VERSION = 1.0.1270;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
|
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
|
||||||
@@ -1019,7 +1095,7 @@
|
|||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1189;
|
CURRENT_PROJECT_VERSION = 1270;
|
||||||
DEVELOPMENT_ASSET_PATHS = "";
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
DEVELOPMENT_TEAM = BA88US33G6;
|
DEVELOPMENT_TEAM = BA88US33G6;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -1031,7 +1107,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1189;
|
MARKETING_VERSION = 1.0.1270;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
|
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
|
||||||
PRODUCT_NAME = ServerBox;
|
PRODUCT_NAME = ServerBox;
|
||||||
@@ -1057,7 +1133,7 @@
|
|||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1189;
|
CURRENT_PROJECT_VERSION = 1270;
|
||||||
DEVELOPMENT_ASSET_PATHS = "";
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
DEVELOPMENT_TEAM = BA88US33G6;
|
DEVELOPMENT_TEAM = BA88US33G6;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -1069,7 +1145,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1189;
|
MARKETING_VERSION = 1.0.1270;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
|
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
|
||||||
PRODUCT_NAME = ServerBox;
|
PRODUCT_NAME = ServerBox;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
import Flutter
|
import Flutter
|
||||||
|
import ActivityKit
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
@@ -11,14 +12,48 @@ import Flutter
|
|||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
|
|
||||||
let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
|
let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
|
||||||
let methodChannel = FlutterMethodChannel(name: "tech.lolli.toolbox/home_widget", binaryMessenger: controller.binaryMessenger)
|
// Home widget channel (legacy)
|
||||||
methodChannel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
|
let homeWidgetChannel = FlutterMethodChannel(name: "tech.lolli.toolbox/home_widget", binaryMessenger: controller.binaryMessenger)
|
||||||
|
homeWidgetChannel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
|
||||||
if call.method == "update" {
|
if call.method == "update" {
|
||||||
if #available(iOS 14.0, *) {
|
if #available(iOS 14.0, *) {
|
||||||
WidgetCenter.shared.reloadTimelines(ofKind: "StatusWidget")
|
WidgetCenter.shared.reloadTimelines(ofKind: "StatusWidget")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Main channel for cross-platform calls (incl. Live Activities)
|
||||||
|
let mainChannel = FlutterMethodChannel(name: "tech.lolli.toolbox/main_chan", binaryMessenger: controller.binaryMessenger)
|
||||||
|
mainChannel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
|
||||||
|
switch call.method {
|
||||||
|
case "updateHomeWidget":
|
||||||
|
if #available(iOS 14.0, *) {
|
||||||
|
WidgetCenter.shared.reloadTimelines(ofKind: "StatusWidget")
|
||||||
|
}
|
||||||
|
result(nil)
|
||||||
|
case "startLiveActivity":
|
||||||
|
if #available(iOS 16.2, *) {
|
||||||
|
if let payload = call.arguments as? String {
|
||||||
|
LiveActivityManager.start(json: payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result(nil)
|
||||||
|
case "updateLiveActivity":
|
||||||
|
if #available(iOS 16.2, *) {
|
||||||
|
if let payload = call.arguments as? String {
|
||||||
|
LiveActivityManager.update(json: payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result(nil)
|
||||||
|
case "stopLiveActivity":
|
||||||
|
if #available(iOS 16.2, *) {
|
||||||
|
LiveActivityManager.stop()
|
||||||
|
}
|
||||||
|
result(nil)
|
||||||
|
default:
|
||||||
|
result(FlutterMethodNotImplemented)
|
||||||
|
}
|
||||||
|
})
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,4 +65,11 @@ import Flutter
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func applicationWillTerminate(_ application: UIApplication) {
|
||||||
|
// Stop Live Activity when app is about to terminate
|
||||||
|
if #available(iOS 16.2, *) {
|
||||||
|
LiveActivityManager.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,8 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>ConfigurationIntent</string>
|
<string>ConfigurationIntent</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>NSSupportsLiveActivities</key>
|
||||||
|
<true/>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true />
|
<true />
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
@@ -78,4 +80,4 @@
|
|||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>Get QR code and etc.</string>
|
<string>Get QR code and etc.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
<string>en</string>
|
<string>en</string>
|
||||||
<string>zh</string>
|
<string>zh</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>NSSupportsLiveActivities</key>
|
||||||
|
<true/>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>ServerBox</string>
|
<string>ServerBox</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
<string>en</string>
|
<string>en</string>
|
||||||
<string>zh</string>
|
<string>zh</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>NSSupportsLiveActivities</key>
|
||||||
|
<true/>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>ServerBox</string>
|
<string>ServerBox</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
@@ -68,4 +70,4 @@
|
|||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>Get QR code and etc.</string>
|
<string>Get QR code and etc.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
95
ios/Runner/LiveActivityManager.swift
Normal file
95
ios/Runner/LiveActivityManager.swift
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
//
|
||||||
|
// LiveActivityManager.swift
|
||||||
|
// Runner
|
||||||
|
//
|
||||||
|
// Handles starting/updating/stopping Terminal Live Activities from Flutter via MethodChannel.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import ActivityKit
|
||||||
|
|
||||||
|
@available(iOS 16.2, *)
|
||||||
|
class LiveActivityManager {
|
||||||
|
static var current: Activity<TerminalAttributes>?
|
||||||
|
|
||||||
|
struct Payload: Decodable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
let startTimeMs: Int
|
||||||
|
let status: String
|
||||||
|
let hasTerminal: Bool?
|
||||||
|
let connectionCount: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parse(_ json: String) -> Payload? {
|
||||||
|
guard let data = json.data(using: .utf8) else { return nil }
|
||||||
|
return try? JSONDecoder().decode(Payload.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func start(json: String) {
|
||||||
|
guard #available(iOS 16.2, *) else { return }
|
||||||
|
guard let p = parse(json) else { return }
|
||||||
|
let attributes = TerminalAttributes(id: p.id)
|
||||||
|
let date = Date(timeIntervalSince1970: TimeInterval(p.startTimeMs) / 1000.0)
|
||||||
|
// Localize multi-connection title/subtitle on iOS side
|
||||||
|
let isMulti = (p.id == "multi_connections")
|
||||||
|
let title = isMulti
|
||||||
|
? String(format: NSLocalizedString("%d connections", comment: "Title for multiple connections"), p.connectionCount ?? 1)
|
||||||
|
: p.title
|
||||||
|
let subtitle = isMulti
|
||||||
|
? NSLocalizedString("Multiple SSH sessions active", comment: "Subtitle for multiple connections")
|
||||||
|
: p.subtitle
|
||||||
|
let state = TerminalAttributes.ContentState(
|
||||||
|
id: p.id,
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitle,
|
||||||
|
status: p.status,
|
||||||
|
startTime: date,
|
||||||
|
hasTerminal: p.hasTerminal ?? true,
|
||||||
|
connectionCount: p.connectionCount ?? 1
|
||||||
|
)
|
||||||
|
let content = ActivityContent(state: state, staleDate: nil)
|
||||||
|
do {
|
||||||
|
current = try Activity<TerminalAttributes>.request(attributes: attributes, content: content, pushType: nil)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func update(json: String) {
|
||||||
|
guard #available(iOS 16.2, *) else { return }
|
||||||
|
guard let p = parse(json) else { return }
|
||||||
|
let date = Date(timeIntervalSince1970: TimeInterval(p.startTimeMs) / 1000.0)
|
||||||
|
// Localize multi-connection title/subtitle on iOS side
|
||||||
|
let isMulti = (p.id == "multi_connections")
|
||||||
|
let title = isMulti
|
||||||
|
? String(format: NSLocalizedString("%d connections", comment: "Title for multiple connections"), p.connectionCount ?? 1)
|
||||||
|
: p.title
|
||||||
|
let subtitle = isMulti
|
||||||
|
? NSLocalizedString("Multiple SSH sessions active", comment: "Subtitle for multiple connections")
|
||||||
|
: p.subtitle
|
||||||
|
let state = TerminalAttributes.ContentState(
|
||||||
|
id: p.id,
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitle,
|
||||||
|
status: p.status,
|
||||||
|
startTime: date,
|
||||||
|
hasTerminal: p.hasTerminal ?? true,
|
||||||
|
connectionCount: p.connectionCount ?? 1
|
||||||
|
)
|
||||||
|
if let activity = current {
|
||||||
|
Task { await activity.update(ActivityContent(state: state, staleDate: nil)) }
|
||||||
|
} else {
|
||||||
|
start(json: json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func stop() {
|
||||||
|
guard #available(iOS 16.2, *) else { return }
|
||||||
|
if let activity = current {
|
||||||
|
Task { await activity.end(dismissalPolicy: .immediate) }
|
||||||
|
current = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
ios/Runner/TerminalLiveActivityAttributes.swift
Normal file
39
ios/Runner/TerminalLiveActivityAttributes.swift
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
//
|
||||||
|
// TerminalLiveActivityAttributes.swift
|
||||||
|
// Runner
|
||||||
|
//
|
||||||
|
// Mirror of the ActivityKit attributes used in the extension.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import ActivityKit
|
||||||
|
|
||||||
|
@available(iOS 16.1, *)
|
||||||
|
public struct TerminalAttributes: ActivityAttributes {
|
||||||
|
public struct ContentState: Codable, Hashable {
|
||||||
|
public var id: String
|
||||||
|
public var title: String
|
||||||
|
public var subtitle: String
|
||||||
|
public var status: String
|
||||||
|
public var startTime: Date
|
||||||
|
public var hasTerminal: Bool
|
||||||
|
public var connectionCount: Int
|
||||||
|
|
||||||
|
public init(id: String, title: String, subtitle: String, status: String, startTime: Date, hasTerminal: Bool, connectionCount: Int = 1) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.status = status
|
||||||
|
self.startTime = startTime
|
||||||
|
self.hasTerminal = hasTerminal
|
||||||
|
self.connectionCount = connectionCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var id: String
|
||||||
|
|
||||||
|
public init(id: String) {
|
||||||
|
self.id = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
8
ios/Runner/de.lproj/Localizable.strings
Normal file
8
ios/Runner/de.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Terminal";
|
||||||
|
"Connected" = "Verbunden";
|
||||||
|
"Connecting" = "Verbindung wird hergestellt";
|
||||||
|
"Disconnected" = "Getrennt";
|
||||||
|
"Multiple SSH sessions active" = "Mehrere aktive SSH-Sitzungen";
|
||||||
|
"1 connection" = "1 Verbindung";
|
||||||
|
"%d connections" = "%d Verbindungen";
|
||||||
|
|
||||||
8
ios/Runner/en.lproj/Localizable.strings
Normal file
8
ios/Runner/en.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Terminal";
|
||||||
|
"Connected" = "Connected";
|
||||||
|
"Connecting" = "Connecting";
|
||||||
|
"Disconnected" = "Disconnected";
|
||||||
|
"Multiple SSH sessions active" = "Multiple SSH sessions active";
|
||||||
|
"1 connection" = "1 connection";
|
||||||
|
"%d connections" = "%d connections";
|
||||||
|
|
||||||
8
ios/Runner/es.lproj/Localizable.strings
Normal file
8
ios/Runner/es.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Terminal";
|
||||||
|
"Connected" = "Conectado";
|
||||||
|
"Connecting" = "Conectando";
|
||||||
|
"Disconnected" = "Desconectado";
|
||||||
|
"Multiple SSH sessions active" = "Varias sesiones SSH activas";
|
||||||
|
"1 connection" = "1 conexión";
|
||||||
|
"%d connections" = "%d conexiones";
|
||||||
|
|
||||||
8
ios/Runner/fr.lproj/Localizable.strings
Normal file
8
ios/Runner/fr.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Terminal";
|
||||||
|
"Connected" = "Connecté";
|
||||||
|
"Connecting" = "Connexion en cours";
|
||||||
|
"Disconnected" = "Déconnecté";
|
||||||
|
"Multiple SSH sessions active" = "Plusieurs sessions SSH actives";
|
||||||
|
"1 connection" = "1 connexion";
|
||||||
|
"%d connections" = "%d connexions";
|
||||||
|
|
||||||
8
ios/Runner/id.lproj/Localizable.strings
Normal file
8
ios/Runner/id.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Terminal";
|
||||||
|
"Connected" = "Terhubung";
|
||||||
|
"Connecting" = "Menghubungkan";
|
||||||
|
"Disconnected" = "Terputus";
|
||||||
|
"Multiple SSH sessions active" = "Beberapa sesi SSH aktif";
|
||||||
|
"1 connection" = "1 koneksi";
|
||||||
|
"%d connections" = "%d koneksi";
|
||||||
|
|
||||||
8
ios/Runner/ja.lproj/Localizable.strings
Normal file
8
ios/Runner/ja.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "ターミナル";
|
||||||
|
"Connected" = "接続済み";
|
||||||
|
"Connecting" = "接続中";
|
||||||
|
"Disconnected" = "切断";
|
||||||
|
"Multiple SSH sessions active" = "複数の SSH セッションがアクティブ";
|
||||||
|
"1 connection" = "1 件の接続";
|
||||||
|
"%d connections" = "%d 件の接続";
|
||||||
|
|
||||||
8
ios/Runner/pt-BR.lproj/Localizable.strings
Normal file
8
ios/Runner/pt-BR.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Terminal";
|
||||||
|
"Connected" = "Conectado";
|
||||||
|
"Connecting" = "Conectando";
|
||||||
|
"Disconnected" = "Desconectado";
|
||||||
|
"Multiple SSH sessions active" = "Várias sessões SSH ativas";
|
||||||
|
"1 connection" = "1 conexão";
|
||||||
|
"%d connections" = "%d conexões";
|
||||||
|
|
||||||
8
ios/Runner/ru.lproj/Localizable.strings
Normal file
8
ios/Runner/ru.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Терминал";
|
||||||
|
"Connected" = "Подключено";
|
||||||
|
"Connecting" = "Подключение";
|
||||||
|
"Disconnected" = "Отключено";
|
||||||
|
"Multiple SSH sessions active" = "Несколько активных сеансов SSH";
|
||||||
|
"1 connection" = "1 подключение";
|
||||||
|
"%d connections" = "%d подключений";
|
||||||
|
|
||||||
8
ios/Runner/zh-Hans.lproj/Localizable.strings
Normal file
8
ios/Runner/zh-Hans.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "终端";
|
||||||
|
"Connected" = "已连接";
|
||||||
|
"Connecting" = "连接中";
|
||||||
|
"Disconnected" = "已断开连接";
|
||||||
|
"Multiple SSH sessions active" = "多个 SSH 会话正在活动";
|
||||||
|
"1 connection" = "1 个连接";
|
||||||
|
"%d connections" = "%d 个连接";
|
||||||
|
|
||||||
8
ios/Runner/zh-Hant.lproj/Localizable.strings
Normal file
8
ios/Runner/zh-Hant.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "終端機";
|
||||||
|
"Connected" = "已連線";
|
||||||
|
"Connecting" = "連線中";
|
||||||
|
"Disconnected" = "已中斷連線";
|
||||||
|
"Multiple SSH sessions active" = "多個 SSH 連線運行中";
|
||||||
|
"1 connection" = "1 個連線";
|
||||||
|
"%d connections" = "%d 個連線";
|
||||||
|
|
||||||
@@ -4,6 +4,15 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>NSExtension</key>
|
<key>NSExtension</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>NSExtensionAttributes</key>
|
||||||
|
<dict>
|
||||||
|
<key>IntentsSupportedIntents</key>
|
||||||
|
<array>
|
||||||
|
<string>ConfigurationIntent</string>
|
||||||
|
</array>
|
||||||
|
<key>NSSupportsLiveActivities</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
<key>NSExtensionPointIdentifier</key>
|
<key>NSExtensionPointIdentifier</key>
|
||||||
<string>com.apple.widgetkit-extension</string>
|
<string>com.apple.widgetkit-extension</string>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -15,6 +15,142 @@ let demoStatus = Status(name: "Server", cpu: "31.7%", mem: "1.3g / 1.9g", disk:
|
|||||||
let domain = "com.lollipopkit.toolbox"
|
let domain = "com.lollipopkit.toolbox"
|
||||||
let bgColor = DynamicColor(dark: UIColor.black, light: UIColor.white)
|
let bgColor = DynamicColor(dark: UIColor.black, light: UIColor.white)
|
||||||
|
|
||||||
|
// Widget-specific constants
|
||||||
|
enum WidgetConstants {
|
||||||
|
enum Dimensions {
|
||||||
|
static let smallGauge: CGFloat = 56
|
||||||
|
static let mediumGauge: CGFloat = 64
|
||||||
|
static let largeGauge: CGFloat = 76
|
||||||
|
static let refreshIconSmall: CGFloat = 12
|
||||||
|
static let refreshIconLarge: CGFloat = 14
|
||||||
|
static let cornerRadius: CGFloat = 12
|
||||||
|
static let shadowRadius: CGFloat = 2
|
||||||
|
}
|
||||||
|
enum Thresholds {
|
||||||
|
static let warningThreshold: Double = 0.6
|
||||||
|
static let criticalThreshold: Double = 0.85
|
||||||
|
}
|
||||||
|
enum Spacing {
|
||||||
|
static let tight: CGFloat = 4
|
||||||
|
static let normal: CGFloat = 8
|
||||||
|
static let loose: CGFloat = 12
|
||||||
|
static let extraLoose: CGFloat = 16
|
||||||
|
}
|
||||||
|
enum Colors {
|
||||||
|
static let cardBackground = Color(.systemBackground)
|
||||||
|
static let secondaryText = Color(.secondaryLabel)
|
||||||
|
static let success = Color(.systemGreen)
|
||||||
|
static let warning = Color(.systemOrange)
|
||||||
|
static let critical = Color(.systemRed)
|
||||||
|
static let accent = Color(.systemBlue)
|
||||||
|
}
|
||||||
|
static let appGroupId = "group.com.lollipopkit.toolbox"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Performance optimization: cache parsed values
|
||||||
|
struct ParseCache {
|
||||||
|
private static var percentCache: [String: Double] = [:]
|
||||||
|
private static var usagePercentCache: [String: Double] = [:]
|
||||||
|
|
||||||
|
static func parsePercent(_ text: String) -> Double {
|
||||||
|
if let cached = percentCache[text] { return cached }
|
||||||
|
let trimmed = text.trimmingCharacters(in: CharacterSet(charactersIn: "% "))
|
||||||
|
let result = Double(trimmed).map { max(0, min(1, $0 / 100.0)) } ?? 0
|
||||||
|
percentCache[text] = result
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parseUsagePercent(_ text: String) -> Double {
|
||||||
|
if let cached = usagePercentCache[text] { return cached }
|
||||||
|
let parts = text.split(separator: "/").map { String($0).trimmingCharacters(in: .whitespaces) }
|
||||||
|
guard parts.count == 2 else { return 0 }
|
||||||
|
let used = PerformanceUtils.parseSizeToBytes(parts[0])
|
||||||
|
let total = PerformanceUtils.parseSizeToBytes(parts[1])
|
||||||
|
let result = total <= 0 ? 0 : max(0, min(1, used / total))
|
||||||
|
usagePercentCache[text] = result
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parseNetworkTotal(_ text: String) -> (totalBytes: Double, displayText: String) {
|
||||||
|
let parts = text.split(separator: "/").map { String($0).trimmingCharacters(in: .whitespaces) }
|
||||||
|
guard parts.count == 2 else { return (0, "0 B") }
|
||||||
|
let upload = PerformanceUtils.parseSizeToBytes(parts[0])
|
||||||
|
let download = PerformanceUtils.parseSizeToBytes(parts[1])
|
||||||
|
let total = upload + download
|
||||||
|
let displayText = PerformanceUtils.formatSize(total)
|
||||||
|
return (total, displayText)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parseNetworkPercent(_ text: String) -> Double {
|
||||||
|
let parts = text.split(separator: "/").map { String($0).trimmingCharacters(in: .whitespaces) }
|
||||||
|
guard parts.count == 2 else { return 0 }
|
||||||
|
let upload = PerformanceUtils.parseSizeToBytes(parts[0])
|
||||||
|
let download = PerformanceUtils.parseSizeToBytes(parts[1])
|
||||||
|
let total = upload + download
|
||||||
|
// Return upload percentage of total traffic
|
||||||
|
return total <= 0 ? 0 : max(0, min(1, upload / total))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PerformanceUtils {
|
||||||
|
// Precomputed multipliers for performance
|
||||||
|
private static let sizeMultipliers: [Character: Double] = [
|
||||||
|
"k": 1024,
|
||||||
|
"m": pow(1024, 2),
|
||||||
|
"g": pow(1024, 3),
|
||||||
|
"t": pow(1024, 4),
|
||||||
|
"p": pow(1024, 5)
|
||||||
|
]
|
||||||
|
|
||||||
|
static func parseSizeToBytes(_ text: String) -> Double {
|
||||||
|
let lower = text.lowercased().replacingOccurrences(of: "b", with: "")
|
||||||
|
let unitChar = lower.trimmingCharacters(in: .whitespaces).last
|
||||||
|
let numberPart: String
|
||||||
|
let multiplier: Double
|
||||||
|
|
||||||
|
if let u = unitChar, let mult = sizeMultipliers[u] {
|
||||||
|
multiplier = mult
|
||||||
|
numberPart = String(lower.dropLast())
|
||||||
|
} else {
|
||||||
|
multiplier = 1.0
|
||||||
|
numberPart = lower
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = Double(numberPart.trimmingCharacters(in: .whitespaces)) ?? 0
|
||||||
|
return value * multiplier
|
||||||
|
}
|
||||||
|
|
||||||
|
static func percentStr(_ value: Double) -> String {
|
||||||
|
let pct = max(0, min(1, value)) * 100
|
||||||
|
let rounded = (pct * 10).rounded() / 10
|
||||||
|
return rounded.truncatingRemainder(dividingBy: 1) == 0
|
||||||
|
? String(format: "%.0f%%", rounded)
|
||||||
|
: String(format: "%.1f%%", rounded)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func thresholdColor(_ value: Double) -> Color {
|
||||||
|
let v = max(0, min(1, value))
|
||||||
|
switch v {
|
||||||
|
case ..<WidgetConstants.Thresholds.warningThreshold: return WidgetConstants.Colors.success
|
||||||
|
case ..<WidgetConstants.Thresholds.criticalThreshold: return WidgetConstants.Colors.warning
|
||||||
|
default: return WidgetConstants.Colors.critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func formatSize(_ bytes: Double) -> String {
|
||||||
|
let units = ["B", "KB", "MB", "GB", "TB"]
|
||||||
|
var size = bytes
|
||||||
|
var unitIndex = 0
|
||||||
|
|
||||||
|
while size >= 1024 && unitIndex < units.count - 1 {
|
||||||
|
size /= 1024
|
||||||
|
unitIndex += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(format: "%.1f %@", size, units[unitIndex])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct Provider: IntentTimelineProvider {
|
struct Provider: IntentTimelineProvider {
|
||||||
func placeholder(in context: Context) -> SimpleEntry {
|
func placeholder(in context: Context) -> SimpleEntry {
|
||||||
SimpleEntry(date: Date(), configuration: ConfigurationIntent(), state: .normal(demoStatus))
|
SimpleEntry(date: Date(), configuration: ConfigurationIntent(), state: .normal(demoStatus))
|
||||||
@@ -29,11 +165,13 @@ struct Provider: IntentTimelineProvider {
|
|||||||
var url = configuration.url
|
var url = configuration.url
|
||||||
|
|
||||||
let family = context.family
|
let family = context.family
|
||||||
|
#if os(iOS)
|
||||||
if #available(iOSApplicationExtension 16.0, *) {
|
if #available(iOSApplicationExtension 16.0, *) {
|
||||||
if family == .accessoryInline || family == .accessoryRectangular {
|
if family == .accessoryInline || family == .accessoryRectangular {
|
||||||
url = UserDefaults.standard.string(forKey: accessoryKey)
|
url = UserDefaults(suiteName: WidgetConstants.appGroupId)?.string(forKey: "accessory_widget_url")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
let currentDate = Date()
|
let currentDate = Date()
|
||||||
let refreshDate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)!
|
let refreshDate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)!
|
||||||
@@ -111,7 +249,7 @@ struct StatusWidgetEntryView : View {
|
|||||||
Button(intent: RefreshIntent()) {
|
Button(intent: RefreshIntent()) {
|
||||||
Image(systemName: "arrow.clockwise")
|
Image(systemName: "arrow.clockwise")
|
||||||
.resizable()
|
.resizable()
|
||||||
.frame(width: 10, height: 12.7)
|
.frame(width: WidgetConstants.Dimensions.refreshIconSmall, height: WidgetConstants.Dimensions.refreshIconSmall * 1.27)
|
||||||
}.tint(.gray)
|
}.tint(.gray)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,6 +261,37 @@ struct StatusWidgetEntryView : View {
|
|||||||
case .normal(let data):
|
case .normal(let data):
|
||||||
let sumColor: Color = .primary.opacity(0.7)
|
let sumColor: Color = .primary.opacity(0.7)
|
||||||
switch family {
|
switch family {
|
||||||
|
case .systemMedium:
|
||||||
|
VStack(alignment: .leading, spacing: WidgetConstants.Spacing.normal) {
|
||||||
|
// Title + refresh
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
|
HStack {
|
||||||
|
Text(data.name).font(.system(.title3, design: .monospaced))
|
||||||
|
Spacer()
|
||||||
|
Button(intent: RefreshIntent()) {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: WidgetConstants.Dimensions.refreshIconSmall, height: WidgetConstants.Dimensions.refreshIconSmall * 1.27)
|
||||||
|
}.tint(.gray)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(data.name).font(.system(.title3, design: .monospaced))
|
||||||
|
}
|
||||||
|
Spacer(minLength: WidgetConstants.Spacing.normal)
|
||||||
|
// Gauges row
|
||||||
|
HStack(spacing: WidgetConstants.Spacing.tight) {
|
||||||
|
GaugeTile(label: "CPU", value: ParseCache.parsePercent(data.cpu), display: data.cpu, diameter: WidgetConstants.Dimensions.smallGauge)
|
||||||
|
GaugeTile(label: "MEM", value: ParseCache.parseUsagePercent(data.mem), display: PerformanceUtils.percentStr(ParseCache.parseUsagePercent(data.mem)), diameter: WidgetConstants.Dimensions.smallGauge)
|
||||||
|
GaugeTile(label: "DISK", value: ParseCache.parseUsagePercent(data.disk), display: PerformanceUtils.percentStr(ParseCache.parseUsagePercent(data.disk)), diameter: WidgetConstants.Dimensions.smallGauge)
|
||||||
|
GaugeTile(label: "NET", value: ParseCache.parseNetworkPercent(data.net), display: ParseCache.parseNetworkTotal(data.net).displayText, diameter: WidgetConstants.Dimensions.smallGauge)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.bottom, 3)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.autoPadding()
|
||||||
|
.widgetBackground()
|
||||||
|
#if os(iOS)
|
||||||
case .accessoryRectangular:
|
case .accessoryRectangular:
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -142,6 +311,7 @@ struct StatusWidgetEntryView : View {
|
|||||||
.widgetBackground()
|
.widgetBackground()
|
||||||
case .accessoryInline:
|
case .accessoryInline:
|
||||||
Text("\(data.name) \(data.cpu)").widgetBackground()
|
Text("\(data.name) \(data.cpu)").widgetBackground()
|
||||||
|
#endif
|
||||||
default:
|
default:
|
||||||
VStack(alignment: .leading, spacing: 3.7) {
|
VStack(alignment: .leading, spacing: 3.7) {
|
||||||
if #available(iOS 17.0, *) {
|
if #available(iOS 17.0, *) {
|
||||||
@@ -151,7 +321,7 @@ struct StatusWidgetEntryView : View {
|
|||||||
Button(intent: RefreshIntent()) {
|
Button(intent: RefreshIntent()) {
|
||||||
Image(systemName: "arrow.clockwise")
|
Image(systemName: "arrow.clockwise")
|
||||||
.resizable()
|
.resizable()
|
||||||
.frame(width: 10, height: 12.7)
|
.frame(width: WidgetConstants.Dimensions.refreshIconSmall, height: WidgetConstants.Dimensions.refreshIconSmall * 1.27)
|
||||||
}.tint(.gray)
|
}.tint(.gray)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -162,9 +332,6 @@ struct StatusWidgetEntryView : View {
|
|||||||
DetailItem(icon: "memorychip", text: data.mem, color: sumColor)
|
DetailItem(icon: "memorychip", text: data.mem, color: sumColor)
|
||||||
DetailItem(icon: "externaldrive", text: data.disk, color: sumColor)
|
DetailItem(icon: "externaldrive", text: data.disk, color: sumColor)
|
||||||
DetailItem(icon: "network", text: data.net, color: sumColor)
|
DetailItem(icon: "network", text: data.net, color: sumColor)
|
||||||
Spacer()
|
|
||||||
DetailItem(icon: "clock", text: entry.date.toStr(), color: sumColor)
|
|
||||||
.padding(.bottom, 3)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.autoPadding()
|
.autoPadding()
|
||||||
@@ -177,8 +344,16 @@ struct StatusWidgetEntryView : View {
|
|||||||
extension View {
|
extension View {
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func widgetBackground() -> some View {
|
func widgetBackground() -> some View {
|
||||||
// Set bg to black in Night, white in Day
|
// Modern card-style background with subtle effects
|
||||||
let backgroundView = Color(bgColor.resolve())
|
let backgroundView = LinearGradient(
|
||||||
|
gradient: Gradient(colors: [
|
||||||
|
Color(bgColor.resolve()),
|
||||||
|
Color(bgColor.resolve()).opacity(0.95)
|
||||||
|
]),
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
|
||||||
if #available(iOS 17.0, *) {
|
if #available(iOS 17.0, *) {
|
||||||
containerBackground(for: .widget) {
|
containerBackground(for: .widget) {
|
||||||
backgroundView
|
backgroundView
|
||||||
@@ -188,14 +363,29 @@ extension View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// iOS 17 will auto add a SafeArea, so when iOS < 17, add .padding(.all, 17)
|
// Enhanced padding with improved spacing
|
||||||
func autoPadding() -> some View {
|
func autoPadding() -> some View {
|
||||||
if #available(iOS 17.0, *) {
|
if #available(iOS 17.0, *) {
|
||||||
return self
|
return self.padding(.all, WidgetConstants.Spacing.tight)
|
||||||
} else {
|
} else {
|
||||||
return self.padding(.all, 17)
|
return self.padding(.all, WidgetConstants.Spacing.extraLoose + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Modern card container with shadow and rounded corners
|
||||||
|
func modernCard(cornerRadius: CGFloat = WidgetConstants.Dimensions.cornerRadius) -> some View {
|
||||||
|
self
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: cornerRadius)
|
||||||
|
.fill(WidgetConstants.Colors.cardBackground)
|
||||||
|
.shadow(
|
||||||
|
color: .black.opacity(0.08),
|
||||||
|
radius: WidgetConstants.Dimensions.shadowRadius,
|
||||||
|
x: 0,
|
||||||
|
y: 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StatusWidget: Widget {
|
struct StatusWidget: Widget {
|
||||||
@@ -207,11 +397,15 @@ struct StatusWidget: Widget {
|
|||||||
}
|
}
|
||||||
.configurationDisplayName("Status")
|
.configurationDisplayName("Status")
|
||||||
.description("Status of your servers.")
|
.description("Status of your servers.")
|
||||||
if #available(iOSApplicationExtension 16.0, *) {
|
#if os(iOS)
|
||||||
return cfg.supportedFamilies([.systemSmall, .accessoryRectangular, .accessoryInline])
|
if #available(iOSApplicationExtension 16.0, *) {
|
||||||
|
return cfg.supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular, .accessoryInline])
|
||||||
} else {
|
} else {
|
||||||
return cfg.supportedFamilies([.systemSmall])
|
return cfg.supportedFamilies([.systemSmall, .systemMedium])
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
return cfg.supportedFamilies([.systemSmall, .systemMedium])
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,31 +422,176 @@ struct DetailItem: View {
|
|||||||
let color: Color
|
let color: Color
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 6.7) {
|
HStack(spacing: WidgetConstants.Spacing.normal) {
|
||||||
Image(systemName: icon).resizable().foregroundColor(color).frame(width: 11, height: 11, alignment: .center)
|
Image(systemName: icon)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.foregroundColor(color.opacity(0.8))
|
||||||
|
.frame(width: 12, height: 12)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(color.opacity(0.1))
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
)
|
||||||
|
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.system(size: 12, weight: .medium, design: .rounded))
|
||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, WidgetConstants.Spacing.tight)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced circular progress indicator
|
||||||
|
struct CirclePercent: View {
|
||||||
|
// eg: 31.7%
|
||||||
|
let percent: String
|
||||||
|
@State private var animatedProgress: Double = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let percentD = Double(percent.trimmingCharacters(in: .init(charactersIn: "%")))
|
||||||
|
let progress = (percentD ?? 0) / 100
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
// Background circle
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.primary.opacity(0.15), lineWidth: 2.5)
|
||||||
|
|
||||||
|
// Progress circle with gradient
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: CGFloat(max(0, min(1, animatedProgress))))
|
||||||
|
.stroke(
|
||||||
|
AngularGradient(
|
||||||
|
gradient: Gradient(colors: [
|
||||||
|
PerformanceUtils.thresholdColor(progress).opacity(0.7),
|
||||||
|
PerformanceUtils.thresholdColor(progress)
|
||||||
|
]),
|
||||||
|
center: .center
|
||||||
|
),
|
||||||
|
style: StrokeStyle(lineWidth: 3, lineCap: .round)
|
||||||
|
)
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
|
||||||
|
// Percentage text
|
||||||
|
Text(percent)
|
||||||
|
.font(.system(size: 8, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(.primary.opacity(0.8))
|
||||||
|
}
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.easeOut(duration: 0.8).delay(0.2)) {
|
||||||
|
animatedProgress = progress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: progress) { newProgress in
|
||||||
|
withAnimation(.easeInOut(duration: 0.5)) {
|
||||||
|
animatedProgress = newProgress
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 空心圆,显示百分比
|
// Modern gauge tile with enhanced visual design
|
||||||
struct CirclePercent: View {
|
struct GaugeTile: View {
|
||||||
// eg: 31.7%
|
let label: String
|
||||||
let percent: String
|
// 0..1
|
||||||
|
let value: Double
|
||||||
|
// eg: "31.7%"
|
||||||
|
let display: String
|
||||||
|
let diameter: CGFloat
|
||||||
|
|
||||||
|
@State private var animatedValue: Double = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
// 31.7% -> 0.317
|
VStack(spacing: WidgetConstants.Spacing.normal) {
|
||||||
let percentD = Double(percent.trimmingCharacters(in: .init(charactersIn: "%")))
|
ZStack {
|
||||||
let double = (percentD ?? 0) / 100
|
// Background circle with subtle shadow effect
|
||||||
Circle()
|
Circle()
|
||||||
.trim(from: 0, to: CGFloat(double))
|
.stroke(Color.primary.opacity(0.1), lineWidth: 4)
|
||||||
.stroke(Color.primary, lineWidth: 3)
|
.background(
|
||||||
.animation(.easeInOut(duration: 0.5))
|
Circle()
|
||||||
|
.fill(WidgetConstants.Colors.cardBackground)
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: WidgetConstants.Dimensions.shadowRadius, x: 0, y: 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Progress arc with gradient effect
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: CGFloat(max(0, min(1, animatedValue))))
|
||||||
|
.stroke(
|
||||||
|
AngularGradient(
|
||||||
|
gradient: Gradient(colors: [
|
||||||
|
PerformanceUtils.thresholdColor(value).opacity(0.8),
|
||||||
|
PerformanceUtils.thresholdColor(value)
|
||||||
|
]),
|
||||||
|
center: .center,
|
||||||
|
startAngle: .degrees(-90),
|
||||||
|
endAngle: .degrees(270)
|
||||||
|
),
|
||||||
|
style: StrokeStyle(lineWidth: 5, lineCap: .round)
|
||||||
|
)
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
|
||||||
|
// Center value text with improved typography
|
||||||
|
Text(display)
|
||||||
|
.font(.system(size: diameter < 60 ? 11 : 13, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.frame(width: diameter, height: diameter)
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.easeOut(duration: 0.8).delay(0.1)) {
|
||||||
|
animatedValue = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: value) { newValue in
|
||||||
|
withAnimation(.easeInOut(duration: 0.6)) {
|
||||||
|
animatedValue = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label with enhanced styling
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 11, weight: .medium, design: .rounded))
|
||||||
|
.foregroundColor(WidgetConstants.Colors.secondaryText)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
.tracking(0.5)
|
||||||
|
} else {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 11, weight: .medium, design: .rounded))
|
||||||
|
.foregroundColor(WidgetConstants.Colors.secondaryText)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legacy functions maintained for compatibility - now delegate to optimized versions
|
||||||
|
func parsePercent(_ text: String) -> Double {
|
||||||
|
return ParseCache.parsePercent(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUsagePercent(_ text: String) -> Double {
|
||||||
|
return ParseCache.parseUsagePercent(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSizeToBytes(_ text: String) -> Double {
|
||||||
|
return PerformanceUtils.parseSizeToBytes(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func percentStr(_ value: Double) -> String {
|
||||||
|
return PerformanceUtils.percentStr(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func thresholdColor(_ value: Double) -> Color {
|
||||||
|
return PerformanceUtils.thresholdColor(value)
|
||||||
|
}
|
||||||
|
|
||||||
struct DynamicColor {
|
struct DynamicColor {
|
||||||
let dark: UIColor
|
let dark: UIColor
|
||||||
let light: UIColor
|
let light: UIColor
|
||||||
|
|||||||
@@ -12,5 +12,8 @@ import SwiftUI
|
|||||||
struct StatusWidgetBundle: WidgetBundle {
|
struct StatusWidgetBundle: WidgetBundle {
|
||||||
var body: some Widget {
|
var body: some Widget {
|
||||||
StatusWidget()
|
StatusWidget()
|
||||||
|
if #available(iOSApplicationExtension 16.1, *) {
|
||||||
|
TerminalLiveActivity()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
185
ios/StatusWidget/TerminalLiveActivity.swift
Normal file
185
ios/StatusWidget/TerminalLiveActivity.swift
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
//
|
||||||
|
// TerminalLiveActivity.swift
|
||||||
|
// StatusWidget
|
||||||
|
//
|
||||||
|
// Renders the Live Activity UI for SSH/Terminal sessions.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
import ActivityKit
|
||||||
|
|
||||||
|
// Helper to map status strings to a color dot (case-insensitive).
|
||||||
|
@inline(__always)
|
||||||
|
private func getStatusDotColor(_ status: String) -> Color {
|
||||||
|
switch status.lowercased() {
|
||||||
|
case "connected":
|
||||||
|
return .green
|
||||||
|
case "connecting":
|
||||||
|
return .yellow
|
||||||
|
case "disconnected":
|
||||||
|
return .red
|
||||||
|
default:
|
||||||
|
return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize status for display: capitalize first letter only.
|
||||||
|
@inline(__always)
|
||||||
|
private func formatStatus(_ status: String) -> String {
|
||||||
|
let trimmed = status.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard let first = trimmed.first else { return status }
|
||||||
|
let head = String(first).uppercased()
|
||||||
|
let tail = String(trimmed.dropFirst()).lowercased()
|
||||||
|
return head + tail
|
||||||
|
}
|
||||||
|
|
||||||
|
// Localize known statuses; fall back to formatted original.
|
||||||
|
@inline(__always)
|
||||||
|
private func localizedStatus(_ status: String) -> String {
|
||||||
|
switch status.lowercased() {
|
||||||
|
case "connected":
|
||||||
|
return NSLocalizedString("Connected", comment: "Session connected status")
|
||||||
|
case "connecting":
|
||||||
|
return NSLocalizedString("Connecting", comment: "Session connecting status")
|
||||||
|
case "disconnected":
|
||||||
|
return NSLocalizedString("Disconnected", comment: "Session disconnected status")
|
||||||
|
default:
|
||||||
|
return formatStatus(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.1, *)
|
||||||
|
struct TerminalLiveActivity: Widget {
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
ActivityConfiguration(for: TerminalAttributes.self) { context in
|
||||||
|
let state = context.state
|
||||||
|
|
||||||
|
HStack(alignment: .center, spacing: 12) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(state.hasTerminal ? NSLocalizedString("Terminal", comment: "Terminal label") : "SSH")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if state.connectionCount > 1 {
|
||||||
|
Text("(\(state.connectionCount))")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(state.title)
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
Text(state.subtitle)
|
||||||
|
.font(.subheadline)
|
||||||
|
.lineLimit(1)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.fill(getStatusDotColor(state.status))
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
Text(localizedStatus(state.status))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Image(systemName: state.hasTerminal ? "terminal" : "bolt.horizontal.circle")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
} dynamicIsland: { context in
|
||||||
|
DynamicIsland {
|
||||||
|
DynamicIslandExpandedRegion(.leading) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(context.state.hasTerminal ? NSLocalizedString("Terminal", comment: "Terminal label") : "SSH")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if context.state.connectionCount > 1 {
|
||||||
|
Text("(\(context.state.connectionCount))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(context.state.title)
|
||||||
|
.font(.subheadline)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
}
|
||||||
|
DynamicIslandExpandedRegion(.trailing) {
|
||||||
|
VStack(alignment: .trailing, spacing: 6) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Circle()
|
||||||
|
.fill(getStatusDotColor(context.state.status))
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
Text(localizedStatus(context.state.status))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
}
|
||||||
|
DynamicIslandExpandedRegion(.bottom) {
|
||||||
|
Text(context.state.subtitle)
|
||||||
|
.font(.caption)
|
||||||
|
.lineLimit(1)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} compactLeading: {
|
||||||
|
Image(systemName: context.state.hasTerminal ? "terminal" : "bolt.horizontal.circle")
|
||||||
|
} compactTrailing: {
|
||||||
|
EmptyView()
|
||||||
|
} minimal: {
|
||||||
|
Image(systemName: context.state.hasTerminal ? "terminal" : "bolt.horizontal.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
@available(iOS 16.2, *)
|
||||||
|
struct TerminalLiveActivity_Previews: PreviewProvider {
|
||||||
|
static let attributes = TerminalAttributes(id: "preview")
|
||||||
|
static let contentState = TerminalAttributes.ContentState(
|
||||||
|
id: "preview",
|
||||||
|
title: "root@server-01",
|
||||||
|
subtitle: "CPU 37% • Mem 1.3G/2.0G",
|
||||||
|
status: "Connected",
|
||||||
|
startTime: Date().addingTimeInterval(-1234),
|
||||||
|
hasTerminal: true,
|
||||||
|
connectionCount: 2
|
||||||
|
)
|
||||||
|
|
||||||
|
static var previews: some View {
|
||||||
|
Group {
|
||||||
|
// 锁屏 / 通知样式预览
|
||||||
|
attributes
|
||||||
|
.previewContext(contentState, viewKind: .content)
|
||||||
|
.previewDisplayName("Lock Screen")
|
||||||
|
|
||||||
|
// 岛屿展开态预览
|
||||||
|
attributes
|
||||||
|
.previewContext(contentState, viewKind: .dynamicIsland(.expanded))
|
||||||
|
.previewDisplayName("Dynamic Island • Expanded")
|
||||||
|
|
||||||
|
// 岛屿紧凑态预览
|
||||||
|
attributes
|
||||||
|
.previewContext(contentState, viewKind: .dynamicIsland(.compact))
|
||||||
|
.previewDisplayName("Dynamic Island • Compact")
|
||||||
|
|
||||||
|
// 岛屿最小态预览
|
||||||
|
attributes
|
||||||
|
.previewContext(contentState, viewKind: .dynamicIsland(.minimal))
|
||||||
|
.previewDisplayName("Dynamic Island • Minimal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
39
ios/StatusWidget/TerminalLiveActivityAttributes.swift
Normal file
39
ios/StatusWidget/TerminalLiveActivityAttributes.swift
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
//
|
||||||
|
// TerminalLiveActivityAttributes.swift
|
||||||
|
// StatusWidget
|
||||||
|
//
|
||||||
|
// Defines ActivityKit attributes and content state for SSH/Terminal Live Activities.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import ActivityKit
|
||||||
|
|
||||||
|
@available(iOS 16.1, *)
|
||||||
|
public struct TerminalAttributes: ActivityAttributes {
|
||||||
|
public struct ContentState: Codable, Hashable {
|
||||||
|
public var id: String
|
||||||
|
public var title: String
|
||||||
|
public var subtitle: String
|
||||||
|
public var status: String
|
||||||
|
public var startTime: Date
|
||||||
|
public var hasTerminal: Bool
|
||||||
|
public var connectionCount: Int
|
||||||
|
|
||||||
|
public init(id: String, title: String, subtitle: String, status: String, startTime: Date, hasTerminal: Bool, connectionCount: Int = 1) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.status = status
|
||||||
|
self.startTime = startTime
|
||||||
|
self.hasTerminal = hasTerminal
|
||||||
|
self.connectionCount = connectionCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var id: String
|
||||||
|
|
||||||
|
public init(id: String) {
|
||||||
|
self.id = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
8
ios/StatusWidget/de.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/de.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Terminal";
|
||||||
|
"Connected" = "Verbunden";
|
||||||
|
"Connecting" = "Verbindung wird hergestellt";
|
||||||
|
"Disconnected" = "Getrennt";
|
||||||
|
"Multiple SSH sessions active" = "Mehrere aktive SSH-Sitzungen";
|
||||||
|
"1 connection" = "1 Verbindung";
|
||||||
|
"%d connections" = "%d Verbindungen";
|
||||||
|
|
||||||
8
ios/StatusWidget/en.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/en.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Terminal";
|
||||||
|
"Connected" = "Connected";
|
||||||
|
"Connecting" = "Connecting";
|
||||||
|
"Disconnected" = "Disconnected";
|
||||||
|
"Multiple SSH sessions active" = "Multiple SSH sessions active";
|
||||||
|
"1 connection" = "1 connection";
|
||||||
|
"%d connections" = "%d connections";
|
||||||
|
|
||||||
8
ios/StatusWidget/es.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/es.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Terminal";
|
||||||
|
"Connected" = "Conectado";
|
||||||
|
"Connecting" = "Conectando";
|
||||||
|
"Disconnected" = "Desconectado";
|
||||||
|
"Multiple SSH sessions active" = "Varias sesiones SSH activas";
|
||||||
|
"1 connection" = "1 conexión";
|
||||||
|
"%d connections" = "%d conexiones";
|
||||||
|
|
||||||
8
ios/StatusWidget/fr.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/fr.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Terminal";
|
||||||
|
"Connected" = "Connecté";
|
||||||
|
"Connecting" = "Connexion en cours";
|
||||||
|
"Disconnected" = "Déconnecté";
|
||||||
|
"Multiple SSH sessions active" = "Plusieurs sessions SSH actives";
|
||||||
|
"1 connection" = "1 connexion";
|
||||||
|
"%d connections" = "%d connexions";
|
||||||
|
|
||||||
8
ios/StatusWidget/id.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/id.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Terminal";
|
||||||
|
"Connected" = "Terhubung";
|
||||||
|
"Connecting" = "Menghubungkan";
|
||||||
|
"Disconnected" = "Terputus";
|
||||||
|
"Multiple SSH sessions active" = "Beberapa sesi SSH aktif";
|
||||||
|
"1 connection" = "1 koneksi";
|
||||||
|
"%d connections" = "%d koneksi";
|
||||||
|
|
||||||
8
ios/StatusWidget/ja.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/ja.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "ターミナル";
|
||||||
|
"Connected" = "接続済み";
|
||||||
|
"Connecting" = "接続中";
|
||||||
|
"Disconnected" = "切断";
|
||||||
|
"Multiple SSH sessions active" = "複数の SSH セッションがアクティブ";
|
||||||
|
"1 connection" = "1 件の接続";
|
||||||
|
"%d connections" = "%d 件の接続";
|
||||||
|
|
||||||
8
ios/StatusWidget/pt-BR.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/pt-BR.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Terminal";
|
||||||
|
"Connected" = "Conectado";
|
||||||
|
"Connecting" = "Conectando";
|
||||||
|
"Disconnected" = "Desconectado";
|
||||||
|
"Multiple SSH sessions active" = "Várias sessões SSH ativas";
|
||||||
|
"1 connection" = "1 conexão";
|
||||||
|
"%d connections" = "%d conexões";
|
||||||
|
|
||||||
8
ios/StatusWidget/ru.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/ru.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Терминал";
|
||||||
|
"Connected" = "Подключено";
|
||||||
|
"Connecting" = "Подключение";
|
||||||
|
"Disconnected" = "Отключено";
|
||||||
|
"Multiple SSH sessions active" = "Несколько активных сеансов SSH";
|
||||||
|
"1 connection" = "1 подключение";
|
||||||
|
"%d connections" = "%d подключений";
|
||||||
|
|
||||||
8
ios/StatusWidget/zh-Hans.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/zh-Hans.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "终端";
|
||||||
|
"Connected" = "已连接";
|
||||||
|
"Connecting" = "连接中";
|
||||||
|
"Disconnected" = "已断开连接";
|
||||||
|
"Multiple SSH sessions active" = "多个 SSH 会话正在活动";
|
||||||
|
"1 connection" = "1 个连接";
|
||||||
|
"%d connections" = "%d 个连接";
|
||||||
|
|
||||||
8
ios/StatusWidget/zh-Hant.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/zh-Hant.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "終端機";
|
||||||
|
"Connected" = "已連線";
|
||||||
|
"Connecting" = "連線中";
|
||||||
|
"Disconnected" = "已中斷連線";
|
||||||
|
"Multiple SSH sessions active" = "多個 SSH 連線運行中";
|
||||||
|
"1 connection" = "1 個連線";
|
||||||
|
"%d connections" = "%d 個連線";
|
||||||
|
|
||||||
@@ -9,22 +9,62 @@ import SwiftUI
|
|||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@ObservedObject var _mgr = PhoneConnMgr()
|
@ObservedObject var _mgr = PhoneConnMgr()
|
||||||
|
@State private var selection: Int = 0
|
||||||
|
@State private var refreshAllCounter: Int = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let _count = _mgr.urls.count == 0 ? 1 : _mgr.urls.count
|
let hasServers = !_mgr.urls.isEmpty
|
||||||
TabView {
|
let pagesCount = hasServers ? _mgr.urls.count : 1
|
||||||
ForEach(0 ..< _count, id:\.self) { index in
|
TabView(selection: $selection) {
|
||||||
let url = _count == 1 && _mgr.urls.count == 0 ? nil : _mgr.urls[index]
|
ForEach(0 ..< pagesCount, id:\.self) { index in
|
||||||
PageView(url: url, state: .loading)
|
let url = hasServers ? _mgr.urls[index] : nil
|
||||||
|
PageView(
|
||||||
|
url: url,
|
||||||
|
state: .loading,
|
||||||
|
refreshAllCounter: refreshAllCounter,
|
||||||
|
onRefreshAll: { refreshAllCounter += 1 }
|
||||||
|
)
|
||||||
|
.tag(index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tabViewStyle(PageTabViewStyle())
|
.tabViewStyle(PageTabViewStyle())
|
||||||
|
// 当 URL 列表变化时,尽量保持当前选中的页面不变
|
||||||
|
.onChange(of: _mgr.urls) { newValue in
|
||||||
|
let newCount = newValue.count
|
||||||
|
// 当没有服务器时,只有占位页
|
||||||
|
if newCount == 0 {
|
||||||
|
selection = 0
|
||||||
|
} else if selection >= newCount {
|
||||||
|
// 如果当前选择超出范围,则跳到最后一个有效页
|
||||||
|
selection = max(0, newCount - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 持久化当前选择,供 Widget 使用
|
||||||
|
.onChange(of: selection) { newIndex in
|
||||||
|
let appGroupId = "group.com.lollipopkit.toolbox"
|
||||||
|
if let defaults = UserDefaults(suiteName: appGroupId) {
|
||||||
|
defaults.set(newIndex, forKey: "watch_shared_selected_index")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// 尽量恢复上一次的选择
|
||||||
|
let appGroupId = "group.com.lollipopkit.toolbox"
|
||||||
|
let saved = UserDefaults(suiteName: appGroupId)?.integer(forKey: "watch_shared_selected_index") ?? 0
|
||||||
|
if !_mgr.urls.isEmpty {
|
||||||
|
selection = min(max(0, saved), _mgr.urls.count - 1)
|
||||||
|
} else {
|
||||||
|
selection = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PageView: View {
|
struct PageView: View {
|
||||||
let url: String?
|
let url: String?
|
||||||
@State var state: ContentState
|
@State var state: ContentState
|
||||||
|
// 触发所有页面刷新的计数器
|
||||||
|
let refreshAllCounter: Int
|
||||||
|
let onRefreshAll: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if url == nil {
|
if url == nil {
|
||||||
@@ -36,35 +76,50 @@ struct PageView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
switch state {
|
Group {
|
||||||
case .loading:
|
switch state {
|
||||||
ProgressView().padding().onAppear {
|
case .loading:
|
||||||
getStatus(url: url!)
|
ProgressView().padding().onAppear {
|
||||||
}
|
getStatus(url: url!)
|
||||||
case .error(let err):
|
}
|
||||||
|
case .error(let err):
|
||||||
switch err {
|
switch err {
|
||||||
case .http(let description):
|
case .http(let description):
|
||||||
VStack(alignment: .center) {
|
VStack(alignment: .center) {
|
||||||
Text(description)
|
Text(description)
|
||||||
Button(action: {
|
HStack(spacing: 10) {
|
||||||
state = .loading
|
Button(action: {
|
||||||
}){
|
state = .loading
|
||||||
Image(systemName: "arrow.clockwise")
|
}){
|
||||||
}.buttonStyle(.plain)
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}.buttonStyle(.plain)
|
||||||
|
Button(action: {
|
||||||
|
onRefreshAll()
|
||||||
|
}){
|
||||||
|
Image(systemName: "arrow.triangle.2.circlepath")
|
||||||
|
}.buttonStyle(.plain)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case .url(_):
|
case .url(_):
|
||||||
Link("View help", destination: helpUrl)
|
Link("View help", destination: helpUrl)
|
||||||
}
|
}
|
||||||
case .normal(let status):
|
case .normal(let status):
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(status.name).font(.system(.title, design: .monospaced))
|
Text(status.name).font(.system(.title, design: .monospaced))
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: {
|
HStack(spacing: 10) {
|
||||||
state = .loading
|
Button(action: {
|
||||||
}){
|
state = .loading
|
||||||
Image(systemName: "arrow.clockwise")
|
}){
|
||||||
}.buttonStyle(.plain)
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}.buttonStyle(.plain)
|
||||||
|
Button(action: {
|
||||||
|
onRefreshAll()
|
||||||
|
}){
|
||||||
|
Image(systemName: "arrow.triangle.2.circlepath")
|
||||||
|
}.buttonStyle(.plain)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
DetailItem(icon: "cpu", text: status.cpu)
|
DetailItem(icon: "cpu", text: status.cpu)
|
||||||
@@ -72,6 +127,12 @@ struct PageView: View {
|
|||||||
DetailItem(icon: "externaldrive", text: status.disk)
|
DetailItem(icon: "externaldrive", text: status.disk)
|
||||||
DetailItem(icon: "network", text: status.net)
|
DetailItem(icon: "network", text: status.net)
|
||||||
}.frame(maxWidth: .infinity, maxHeight: .infinity).padding([.horizontal], 11)
|
}.frame(maxWidth: .infinity, maxHeight: .infinity).padding([.horizontal], 11)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: refreshAllCounter) { _ in
|
||||||
|
if let url = url {
|
||||||
|
getStatus(url: url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,25 +148,32 @@ struct PageView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
|
let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
|
||||||
guard error == nil else {
|
// 所有 UI 状态更新必须在主线程执行,否则可能导致 TabView 跳回第一页等问题
|
||||||
state = .error(.http(error!.localizedDescription))
|
func setStateOnMain(_ newState: ContentState) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.state = newState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = error {
|
||||||
|
setStateOnMain(.error(.http(error.localizedDescription)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let data = data else {
|
guard let data = data else {
|
||||||
state = .error(.http("empty data"))
|
setStateOnMain(.error(.http("empty data")))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let jsonAll = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
|
guard let jsonAll = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
|
||||||
state = .error(.http("json parse fail"))
|
setStateOnMain(.error(.http("json parse fail")))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let code = jsonAll["code"] as? Int else {
|
guard let code = jsonAll["code"] as? Int else {
|
||||||
state = .error(.http("code is nil"))
|
setStateOnMain(.error(.http("code is nil")))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (code != 0) {
|
if (code != 0) {
|
||||||
let msg = jsonAll["msg"] as? String ?? ""
|
let msg = jsonAll["msg"] as? String ?? ""
|
||||||
state = .error(.http(msg))
|
setStateOnMain(.error(.http(msg)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,10 +183,35 @@ struct PageView: View {
|
|||||||
let cpu = json["cpu"] as? String ?? ""
|
let cpu = json["cpu"] as? String ?? ""
|
||||||
let mem = json["mem"] as? String ?? ""
|
let mem = json["mem"] as? String ?? ""
|
||||||
let net = json["net"] as? String ?? ""
|
let net = json["net"] as? String ?? ""
|
||||||
state = .normal(Status(name: name, cpu: cpu, mem: mem, disk: disk, net: net))
|
let status = Status(name: name, cpu: cpu, mem: mem, disk: disk, net: net)
|
||||||
|
setStateOnMain(.normal(status))
|
||||||
|
// 将最新数据写入 App Group,供表盘/叠放的 Widget 使用
|
||||||
|
let appGroupId = "group.com.lollipopkit.toolbox"
|
||||||
|
if let defaults = UserDefaults(suiteName: appGroupId) {
|
||||||
|
var statusMap = (defaults.dictionary(forKey: "watch_shared_status_by_url") as? [String: [String: String]]) ?? [:]
|
||||||
|
statusMap[url.absoluteString] = [
|
||||||
|
"name": status.name,
|
||||||
|
"cpu": status.cpu,
|
||||||
|
"mem": status.mem,
|
||||||
|
"disk": status.disk,
|
||||||
|
"net": status.net
|
||||||
|
]
|
||||||
|
defaults.set(statusMap, forKey: "watch_shared_status_by_url")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
task.resume()
|
task.resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 监听“刷新全部”触发器变化,主动刷新当前页
|
||||||
|
@ViewBuilder
|
||||||
|
var _onRefreshAllHook: some View {
|
||||||
|
EmptyView()
|
||||||
|
.onChange(of: refreshAllCounter) { _ in
|
||||||
|
if let url = url {
|
||||||
|
getStatus(url: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ContentView_Previews: PreviewProvider {
|
struct ContentView_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -14,13 +14,20 @@ class PhoneConnMgr: NSObject, WCSessionDelegate, ObservableObject {
|
|||||||
set {
|
set {
|
||||||
Store.setCtx(newValue)
|
Store.setCtx(newValue)
|
||||||
updateUrls(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 {
|
get {
|
||||||
return _ctx
|
return _ctx
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var userInfo: [String: Any] = [:]
|
||||||
@Published var urls: [String] = []
|
@Published var urls: [String] = []
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
if !WCSession.isSupported() {
|
if !WCSession.isSupported() {
|
||||||
@@ -29,24 +36,91 @@ class PhoneConnMgr: NSObject, WCSessionDelegate, ObservableObject {
|
|||||||
session = WCSession.default
|
session = WCSession.default
|
||||||
session?.delegate = self
|
session?.delegate = self
|
||||||
session?.activate()
|
session?.activate()
|
||||||
|
|
||||||
ctx = Store.getCtx()
|
_ctx = Store.getCtx()
|
||||||
|
updateUrls(_ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUrls(_ val: [String: Any]) {
|
func updateUrls(_ val: [String: Any]) {
|
||||||
if let urls = val["urls"] as? [String] {
|
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:
|
// Receive realtime msgs
|
||||||
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) {
|
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||||
ctx = applicationContext
|
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
|
template-arb-file: app_en.arb
|
||||||
output-localization-file: l10n.dart
|
output-localization-file: l10n.dart
|
||||||
output-dir: lib/generated/l10n
|
output-dir: lib/generated/l10n
|
||||||
synthetic-package: false
|
|
||||||
untranslated-messages-file: untranlated.json
|
untranslated-messages-file: untranlated.json
|
||||||
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:fl_lib/generated/l10n/lib_l10n.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:icons_plus/icons_plus.dart';
|
import 'package:icons_plus/icons_plus.dart';
|
||||||
import 'package:responsive_framework/responsive_framework.dart';
|
import 'package:server_box/core/app_navigator.dart';
|
||||||
import 'package:server_box/core/extension/context/locale.dart';
|
import 'package:server_box/core/extension/context/locale.dart';
|
||||||
import 'package:server_box/data/res/build_data.dart';
|
import 'package:server_box/data/res/build_data.dart';
|
||||||
import 'package:server_box/data/res/store.dart';
|
import 'package:server_box/data/res/store.dart';
|
||||||
@@ -12,9 +12,16 @@ import 'package:server_box/view/page/home.dart';
|
|||||||
|
|
||||||
part 'intro.dart';
|
part 'intro.dart';
|
||||||
|
|
||||||
class MyApp extends StatelessWidget {
|
class MyApp extends StatefulWidget {
|
||||||
const MyApp({super.key});
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MyApp> createState() => _MyAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MyAppState extends State<MyApp> {
|
||||||
|
late final Future<List<IntroPageBuilder>> _introFuture = _IntroPage.builders;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
_setup(context);
|
_setup(context);
|
||||||
@@ -40,17 +47,13 @@ class MyApp extends StatelessWidget {
|
|||||||
light: ThemeData(
|
light: ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
colorSchemeSeed: UIs.colorSeed,
|
colorSchemeSeed: UIs.colorSeed,
|
||||||
appBarTheme: AppBarTheme(
|
appBarTheme: AppBarTheme(scrolledUnderElevation: 0.0),
|
||||||
scrolledUnderElevation: 0.0,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
dark: ThemeData(
|
dark: ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
colorSchemeSeed: UIs.colorSeed,
|
colorSchemeSeed: UIs.colorSeed,
|
||||||
appBarTheme: AppBarTheme(
|
appBarTheme: AppBarTheme(scrolledUnderElevation: 0.0),
|
||||||
scrolledUnderElevation: 0.0,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -58,19 +61,14 @@ class MyApp extends StatelessWidget {
|
|||||||
Widget _buildDynamicColor(BuildContext context) {
|
Widget _buildDynamicColor(BuildContext context) {
|
||||||
return DynamicColorBuilder(
|
return DynamicColorBuilder(
|
||||||
builder: (light, dark) {
|
builder: (light, dark) {
|
||||||
final lightTheme = ThemeData(
|
final lightTheme = ThemeData(useMaterial3: true, colorScheme: light);
|
||||||
useMaterial3: true,
|
final darkTheme = ThemeData(useMaterial3: true, brightness: Brightness.dark, colorScheme: dark);
|
||||||
colorScheme: light,
|
|
||||||
);
|
|
||||||
final darkTheme = ThemeData(
|
|
||||||
useMaterial3: true,
|
|
||||||
brightness: Brightness.dark,
|
|
||||||
colorScheme: dark,
|
|
||||||
);
|
|
||||||
if (context.isDark && dark != null) {
|
if (context.isDark && dark != null) {
|
||||||
UIs.primaryColor = dark.primary;
|
UIs.primaryColor = dark.primary;
|
||||||
|
UIs.colorSeed = dark.primary;
|
||||||
} else if (!context.isDark && light != null) {
|
} else if (!context.isDark && light != null) {
|
||||||
UIs.primaryColor = light.primary;
|
UIs.primaryColor = light.primary;
|
||||||
|
UIs.colorSeed = light.primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
return _buildApp(context, light: lightTheme, dark: darkTheme);
|
return _buildApp(context, light: lightTheme, dark: darkTheme);
|
||||||
@@ -78,11 +76,7 @@ class MyApp extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildApp(
|
Widget _buildApp(BuildContext ctx, {required ThemeData light, required ThemeData dark}) {
|
||||||
BuildContext ctx, {
|
|
||||||
required ThemeData light,
|
|
||||||
required ThemeData dark,
|
|
||||||
}) {
|
|
||||||
final tMode = Stores.setting.themeMode.fetch();
|
final tMode = Stores.setting.themeMode.fetch();
|
||||||
// Issue #57
|
// Issue #57
|
||||||
final themeMode = switch (tMode) {
|
final themeMode = switch (tMode) {
|
||||||
@@ -94,19 +88,10 @@ class MyApp extends StatelessWidget {
|
|||||||
|
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
key: ValueKey(locale),
|
key: ValueKey(locale),
|
||||||
builder: (context, child) => ResponsiveBreakpoints.builder(
|
navigatorKey: AppNavigator.key,
|
||||||
child: child ?? UIs.placeholder,
|
builder: ResponsivePoints.builder,
|
||||||
breakpoints: const [
|
|
||||||
Breakpoint(start: 0, end: 450, name: MOBILE),
|
|
||||||
Breakpoint(start: 451, end: 800, name: TABLET),
|
|
||||||
Breakpoint(start: 801, end: 1920, name: DESKTOP),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
locale: locale,
|
locale: locale,
|
||||||
localizationsDelegates: const [
|
localizationsDelegates: const [LibLocalizations.delegate, ...AppLocalizations.localizationsDelegates],
|
||||||
LibLocalizations.delegate,
|
|
||||||
...AppLocalizations.localizationsDelegates,
|
|
||||||
],
|
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
localeListResolutionCallback: LocaleUtil.resolve,
|
localeListResolutionCallback: LocaleUtil.resolve,
|
||||||
navigatorObservers: [AppRouteObserver.instance],
|
navigatorObservers: [AppRouteObserver.instance],
|
||||||
@@ -114,24 +99,26 @@ class MyApp extends StatelessWidget {
|
|||||||
themeMode: themeMode,
|
themeMode: themeMode,
|
||||||
theme: light.fixWindowsFont,
|
theme: light.fixWindowsFont,
|
||||||
darkTheme: (tMode < 3 ? dark : dark.toAmoled).fixWindowsFont,
|
darkTheme: (tMode < 3 ? dark : dark.toAmoled).fixWindowsFont,
|
||||||
home: Builder(
|
home: FutureBuilder<List<IntroPageBuilder>>(
|
||||||
builder: (context) {
|
future: _introFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
context.setLibL10n();
|
context.setLibL10n();
|
||||||
final appL10n = AppLocalizations.of(context);
|
final appL10n = AppLocalizations.of(context);
|
||||||
if (appL10n != null) l10n = appL10n;
|
if (appL10n != null) l10n = appL10n;
|
||||||
|
|
||||||
Widget child;
|
Widget child;
|
||||||
final intros = _IntroPage.builders;
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
if (intros.isNotEmpty) {
|
child = const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||||
child = _IntroPage(intros);
|
} else {
|
||||||
|
final intros = snapshot.data ?? [];
|
||||||
|
if (intros.isNotEmpty) {
|
||||||
|
child = _IntroPage(intros);
|
||||||
|
} else {
|
||||||
|
child = const HomePage();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
child = const HomePage();
|
return VirtualWindowFrame(title: BuildData.name, child: child);
|
||||||
|
|
||||||
return VirtualWindowFrame(
|
|
||||||
title: BuildData.name,
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
8
lib/core/app_navigator.dart
Normal file
8
lib/core/app_navigator.dart
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// Global navigator access used for cross-cutting flows (e.g. dialogs).
|
||||||
|
abstract final class AppNavigator {
|
||||||
|
static final key = GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
|
static BuildContext? get context => key.currentContext;
|
||||||
|
}
|
||||||
@@ -12,19 +12,90 @@ abstract final class MethodChans {
|
|||||||
|
|
||||||
/// Issue #662
|
/// Issue #662
|
||||||
static void startService() {
|
static void startService() {
|
||||||
// if (Stores.setting.fgService.fetch() != true) return;
|
if (Stores.setting.fgService.fetch() != true) return;
|
||||||
// _channel.invokeMethod('startService');
|
_channel.invokeMethod('startService');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Issue #662
|
/// Issue #662
|
||||||
static void stopService() {
|
static void stopService() {
|
||||||
// if (Stores.setting.fgService.fetch() != true) return;
|
if (Stores.setting.fgService.fetch() != true) return;
|
||||||
// _channel.invokeMethod('stopService');
|
_channel.invokeMethod('stopService');
|
||||||
}
|
}
|
||||||
|
|
||||||
static void updateHomeWidget() async {
|
static void updateHomeWidget() async {
|
||||||
if (!isIOS || !isAndroid) return;
|
if (!isIOS && !isAndroid) return;
|
||||||
if (!Stores.setting.autoUpdateHomeWidget.fetch()) return;
|
if (!Stores.setting.autoUpdateHomeWidget.fetch()) return;
|
||||||
await _channel.invokeMethod('updateHomeWidget');
|
await _channel.invokeMethod('updateHomeWidget');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update Android foreground service notifications for SSH sessions
|
||||||
|
/// The [payload] is a JSON string describing sessions list.
|
||||||
|
static Future<void> updateSessions(String payload) async {
|
||||||
|
if (!isAndroid) return;
|
||||||
|
try {
|
||||||
|
Loggers.app.info('Updating Android sessions: $payload');
|
||||||
|
await _channel.invokeMethod('updateSessions', payload);
|
||||||
|
} catch (_) {
|
||||||
|
// 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() {
|
UnixPerm toUnixPerm() {
|
||||||
return UnixPerm(
|
return UnixPerm(
|
||||||
user: UnixPermOp(
|
user: UnixPermOp(r: userRead, w: userWrite, x: userExecute),
|
||||||
r: userRead,
|
group: UnixPermOp(r: groupRead, w: groupWrite, x: groupExecute),
|
||||||
w: userWrite,
|
other: UnixPermOp(r: otherRead, w: otherWrite, x: otherExecute),
|
||||||
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:dartssh2/dartssh2.dart';
|
||||||
import 'package:fl_lib/fl_lib.dart';
|
import 'package:fl_lib/fl_lib.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:server_box/data/model/server/system.dart';
|
||||||
|
|
||||||
import 'package:server_box/data/res/misc.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);
|
typedef PwdRequestFunc = Future<String?> Function(String? user);
|
||||||
|
|
||||||
extension SSHClientX on SSHClient {
|
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(
|
Future<(SSHSession, String)> exec(
|
||||||
OnStdin onStdin, {
|
OnStdin onStdin, {
|
||||||
String? entry,
|
String? entry,
|
||||||
@@ -22,9 +69,14 @@ extension SSHClientX on SSHClient {
|
|||||||
bool stdout = true,
|
bool stdout = true,
|
||||||
bool stderr = true,
|
bool stderr = true,
|
||||||
Map<String, String>? env,
|
Map<String, String>? env,
|
||||||
|
SystemType? systemType,
|
||||||
}) async {
|
}) async {
|
||||||
final session = await execute(
|
final session = await execute(
|
||||||
entry ?? 'cat | sh',
|
entry ??
|
||||||
|
switch (systemType) {
|
||||||
|
SystemType.windows => 'powershell -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass',
|
||||||
|
_ => 'cat | sh',
|
||||||
|
},
|
||||||
pty: pty,
|
pty: pty,
|
||||||
environment: env,
|
environment: env,
|
||||||
);
|
);
|
||||||
@@ -80,10 +132,9 @@ extension SSHClientX on SSHClient {
|
|||||||
if (data.contains('[sudo] password for ')) {
|
if (data.contains('[sudo] password for ')) {
|
||||||
isRequestingPwd = true;
|
isRequestingPwd = true;
|
||||||
final user = Miscs.pwdRequestWithUserReg.firstMatch(data)?.group(1);
|
final user = Miscs.pwdRequestWithUserReg.firstMatch(data)?.group(1);
|
||||||
if (context == null) return;
|
final ctx = context ?? WidgetsBinding.instance.focusManager.primaryFocus?.context;
|
||||||
final pwd = context.mounted
|
if (ctx == null) return;
|
||||||
? await context.showPwdDialog(title: user, id: id)
|
final pwd = ctx.mounted ? await ctx.showPwdDialog(title: user, id: id) : null;
|
||||||
: null;
|
|
||||||
if (pwd == null || pwd.isEmpty) {
|
if (pwd == null || pwd.isEmpty) {
|
||||||
session.stdin.close();
|
session.stdin.close();
|
||||||
} else {
|
} 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();
|
const BakSyncer._() : super();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> saveToFile() => BackupV2.backup();
|
Future<void> saveToFile() async {
|
||||||
|
final pwd = await SecureStoreProps.bakPwd.read();
|
||||||
|
await BackupV2.backup(null, pwd?.isEmpty == true ? null : pwd);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Mergeable> fromFile(String path) async {
|
Future<Mergeable> fromFile(String path) async {
|
||||||
final content = await File(path).readAsString();
|
final content = await File(path).readAsString();
|
||||||
return MergeableUtils.fromJsonString(content).$1;
|
final pwd = await SecureStoreProps.bakPwd.read();
|
||||||
|
try {
|
||||||
|
if (Cryptor.isEncrypted(content)) {
|
||||||
|
return MergeableUtils.fromJsonString(content, pwd).$1;
|
||||||
|
}
|
||||||
|
return MergeableUtils.fromJsonString(content).$1;
|
||||||
|
} catch (_) {
|
||||||
|
// Fallback: try without password if detection failed
|
||||||
|
return MergeableUtils.fromJsonString(content).$1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -28,6 +40,9 @@ final class BakSyncer extends SyncIface {
|
|||||||
final webdavEnabled = PrefProps.webdavSync.get();
|
final webdavEnabled = PrefProps.webdavSync.get();
|
||||||
if (webdavEnabled) return Webdav.shared;
|
if (webdavEnabled) return Webdav.shared;
|
||||||
|
|
||||||
|
final gistEnabled = PrefProps.gistSync.get();
|
||||||
|
if (gistEnabled) return GistRs.shared;
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,8 @@ class ChainComparator<T> {
|
|||||||
ChainComparator.empty() : this._create(null, (a, b) => 0);
|
ChainComparator.empty() : this._create(null, (a, b) => 0);
|
||||||
ChainComparator.create() : this._create(null, (a, b) => 0);
|
ChainComparator.create() : this._create(null, (a, b) => 0);
|
||||||
|
|
||||||
static ChainComparator<T> comparing<T, F extends Comparable<F>>(
|
static ChainComparator<T> comparing<T, F extends Comparable<F>>(F Function(T) extractor) {
|
||||||
F Function(T) extractor) {
|
return ChainComparator._create(null, (a, b) => extractor(a).compareTo(extractor(b)));
|
||||||
return ChainComparator._create(
|
|
||||||
null, (a, b) => extractor(a).compareTo(extractor(b)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int compare(T a, T b) {
|
int compare(T a, T b) {
|
||||||
@@ -26,8 +24,9 @@ class ChainComparator<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ChainComparator<T> thenCompareBy<F extends Comparable<F>>(
|
ChainComparator<T> thenCompareBy<F extends Comparable<F>>(
|
||||||
F Function(T) extractor,
|
F Function(T) extractor, {
|
||||||
{bool reversed = false}) {
|
bool reversed = false,
|
||||||
|
}) {
|
||||||
return ChainComparator._create(
|
return ChainComparator._create(
|
||||||
this,
|
this,
|
||||||
reversed
|
reversed
|
||||||
@@ -36,18 +35,12 @@ class ChainComparator<T> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ChainComparator<T> thenWithComparator(Comparator<T> comparator,
|
ChainComparator<T> thenWithComparator(Comparator<T> comparator, {bool reversed = false}) {
|
||||||
{bool reversed = false}) {
|
return ChainComparator._create(this, !reversed ? comparator : (a, b) => comparator(b, a));
|
||||||
return ChainComparator._create(
|
|
||||||
this,
|
|
||||||
!reversed ? comparator : (a, b) => comparator(b, a),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ChainComparator<T> thenCompareByReversed<F extends Comparable<F>>(
|
ChainComparator<T> thenCompareByReversed<F extends Comparable<F>>(F Function(T) extractor) {
|
||||||
F Function(T) extractor) {
|
return ChainComparator._create(this, (a, b) => -extractor(a).compareTo(extractor(b)));
|
||||||
return ChainComparator._create(
|
|
||||||
this, (a, b) => -extractor(a).compareTo(extractor(b)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ChainComparator<T> thenTrueFirst(bool Function(T) f) {
|
ChainComparator<T> thenTrueFirst(bool Function(T) f) {
|
||||||
@@ -58,13 +51,12 @@ class ChainComparator<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ChainComparator<T> reversed() {
|
ChainComparator<T> reversed() {
|
||||||
return ChainComparator._create(null, (a, b) => this.compare(b, a));
|
return ChainComparator._create(null, (a, b) => compare(b, a));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Comparators {
|
class Comparators {
|
||||||
static Comparator<String> compareStringCaseInsensitive(
|
static Comparator<String> compareStringCaseInsensitive({bool uppercaseFirst = false}) {
|
||||||
{bool uppercaseFirst = false}) {
|
|
||||||
return (String a, String b) {
|
return (String a, String b) {
|
||||||
final r = a.toLowerCase().compareTo(b.toLowerCase());
|
final r = a.toLowerCase().compareTo(b.toLowerCase());
|
||||||
if (r != 0) return r;
|
if (r != 0) return r;
|
||||||
|
|||||||
303
lib/core/utils/executable_manager.dart
Normal file
303
lib/core/utils/executable_manager.dart
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:fl_lib/fl_lib.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:server_box/data/store/setting.dart';
|
||||||
|
|
||||||
|
/// Exception thrown when executable management fails
|
||||||
|
class ExecutableException implements Exception {
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
ExecutableException(this.message);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'ExecutableException: $message';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Information about an executable
|
||||||
|
class ExecutableInfo {
|
||||||
|
final String name;
|
||||||
|
final String? spokenName;
|
||||||
|
final String? version;
|
||||||
|
|
||||||
|
ExecutableInfo({required this.name, this.version, this.spokenName});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic executable manager for downloading and managing external tools
|
||||||
|
abstract final class ExecutableManager {
|
||||||
|
static const String _executablesDirName = 'executables';
|
||||||
|
static const int _posixExecuteBitsMask = 0x49; // Equivalent to POSIX octal 0o111
|
||||||
|
static late final Directory _executablesDir;
|
||||||
|
static final Map<String, ExecutableInfo> _customExecutables = {};
|
||||||
|
static bool _customExecutablesLoaded = false;
|
||||||
|
|
||||||
|
static Future<void> initialize() async {
|
||||||
|
final appDir = await getApplicationSupportDirectory();
|
||||||
|
_executablesDir = Directory(path.join(appDir.path, _executablesDirName));
|
||||||
|
if (!await _executablesDir.exists()) {
|
||||||
|
await _executablesDir.create(recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ensureCustomExecutablesLoaded();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Predefined executables
|
||||||
|
static final Map<String, ExecutableInfo> _predefinedExecutables = {
|
||||||
|
'cloudflared': ExecutableInfo(name: 'cloudflared'),
|
||||||
|
'ssh': ExecutableInfo(name: 'ssh'),
|
||||||
|
'nc': ExecutableInfo(name: 'nc'),
|
||||||
|
'socat': ExecutableInfo(name: 'socat'),
|
||||||
|
};
|
||||||
|
|
||||||
|
static void _ensureCustomExecutablesLoaded() {
|
||||||
|
if (_customExecutablesLoaded) return;
|
||||||
|
|
||||||
|
final List<dynamic> stored = SettingStore.instance.proxyCmdCustomExecs.get();
|
||||||
|
for (final raw in stored) {
|
||||||
|
final info = _parseExecutableInfo(raw);
|
||||||
|
if (info == null) continue;
|
||||||
|
_customExecutables[info.name] = info;
|
||||||
|
_predefinedExecutables[info.name] = info;
|
||||||
|
}
|
||||||
|
|
||||||
|
_customExecutablesLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _persistCustomExecutables() {
|
||||||
|
final values = _customExecutables.values
|
||||||
|
.map((info) => {
|
||||||
|
'name': info.name,
|
||||||
|
if (info.spokenName != null) 'spokenName': info.spokenName,
|
||||||
|
if (info.version != null) 'version': info.version,
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
SettingStore.instance.proxyCmdCustomExecs.set(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ExecutableInfo? _parseExecutableInfo(dynamic raw) {
|
||||||
|
if (raw is String) {
|
||||||
|
try {
|
||||||
|
return _parseExecutableInfo(jsonDecode(raw));
|
||||||
|
} catch (e) {
|
||||||
|
Loggers.app.warning('Failed to decode custom executable entry: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (raw is! Map) return null;
|
||||||
|
|
||||||
|
final name = raw['name']?.toString();
|
||||||
|
if (name == null || name.isEmpty) return null;
|
||||||
|
|
||||||
|
return ExecutableInfo(
|
||||||
|
name: name,
|
||||||
|
spokenName: raw['spokenName']?.toString(),
|
||||||
|
version: raw['version']?.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if an executable exists in PATH or local directory
|
||||||
|
static Future<bool> isExecutableAvailable(String name) async {
|
||||||
|
// First check if it's in PATH
|
||||||
|
final pathExecutable = await _lookupExecutableInSystemPath(name);
|
||||||
|
if (pathExecutable != null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check local executables directory
|
||||||
|
final localExecutable = _getLocalExecutablePath(name);
|
||||||
|
if (await localExecutable.exists()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the path to an executable (either in PATH or local)
|
||||||
|
static Future<String> getExecutablePath(String name) async {
|
||||||
|
// First check if it's in PATH
|
||||||
|
final pathExecutable = await _lookupExecutableInSystemPath(name);
|
||||||
|
if (pathExecutable != null) {
|
||||||
|
return pathExecutable;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check local executables directory
|
||||||
|
final localExecutable = _getLocalExecutablePath(name);
|
||||||
|
if (await localExecutable.exists()) {
|
||||||
|
return localExecutable.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ExecutableException('Executable $name not found in PATH or local directory');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download an executable if it's not available
|
||||||
|
static Future<String> ensureExecutable(String name) async {
|
||||||
|
if (await isExecutableAvailable(name)) {
|
||||||
|
return await getExecutablePath(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ExecutableException('Executable "$name" not found and automatic installation is not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a local executable
|
||||||
|
static Future<void> removeExecutable(String name) async {
|
||||||
|
final localExecutable = _getLocalExecutablePath(name);
|
||||||
|
if (await localExecutable.exists()) {
|
||||||
|
await localExecutable.delete();
|
||||||
|
Loggers.app.info('Removed local executable: $name');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all locally downloaded executables
|
||||||
|
static Future<List<String>> listLocalExecutables() async {
|
||||||
|
if (!await _executablesDir.exists()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final executables = <String>[];
|
||||||
|
await for (final entity in _executablesDir.list()) {
|
||||||
|
if (entity is File && _isExecutable(entity)) {
|
||||||
|
executables.add(path.basenameWithoutExtension(entity.path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return executables;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the size of a local executable
|
||||||
|
static Future<int> getExecutableSize(String name) async {
|
||||||
|
final localExecutable = _getLocalExecutablePath(name);
|
||||||
|
if (await localExecutable.exists()) {
|
||||||
|
return await localExecutable.length();
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the version of an executable
|
||||||
|
static Future<String?> getExecutableVersion(String name) async {
|
||||||
|
try {
|
||||||
|
final executablePath = await getExecutablePath(name);
|
||||||
|
|
||||||
|
// Try common version flags
|
||||||
|
final versionFlags = ['--version', '-v', '-V', 'version'];
|
||||||
|
|
||||||
|
for (final flag in versionFlags) {
|
||||||
|
try {
|
||||||
|
final result = await Process.run(executablePath, [flag]);
|
||||||
|
if (result.exitCode == 0) {
|
||||||
|
final output = result.stdout.toString().trim();
|
||||||
|
if (output.isNotEmpty) {
|
||||||
|
return output.split('\n').first; // Return first line only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Try next flag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Loggers.app.warning('Error getting version for $name: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate an executable by trying to run it with a help flag
|
||||||
|
static Future<bool> validateExecutable(String name) async {
|
||||||
|
try {
|
||||||
|
final executablePath = await getExecutablePath(name);
|
||||||
|
|
||||||
|
// Try to run the executable with a help flag
|
||||||
|
final helpFlags = ['--help', '-h', '-help'];
|
||||||
|
|
||||||
|
for (final flag in helpFlags) {
|
||||||
|
try {
|
||||||
|
final result = await Process.run(executablePath, [flag]);
|
||||||
|
if (result.exitCode == 0 || result.exitCode == 1) {
|
||||||
|
// Help often returns 1
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Try next flag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Loggers.app.warning('Error validating $name: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String?> _lookupExecutableInSystemPath(String name) async {
|
||||||
|
final command = Platform.isWindows ? 'where' : 'which';
|
||||||
|
try {
|
||||||
|
final result = await Process.run(command, [name]);
|
||||||
|
if (result.exitCode != 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final stdoutString = result.stdout.toString().trim();
|
||||||
|
if (stdoutString.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final candidate = stdoutString
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.firstWhere((line) => line.isNotEmpty, orElse: () => '');
|
||||||
|
|
||||||
|
if (candidate.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate;
|
||||||
|
} catch (e) {
|
||||||
|
Loggers.app.warning('Error checking PATH for $name: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the local path for an executable
|
||||||
|
static File _getLocalExecutablePath(String name) {
|
||||||
|
final extension = Platform.isWindows ? '.exe' : '';
|
||||||
|
return File(path.join(_executablesDir.path, '$name$extension'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a file is executable
|
||||||
|
static bool _isExecutable(File file) {
|
||||||
|
if (Platform.isWindows) {
|
||||||
|
return file.path.endsWith('.exe');
|
||||||
|
} else {
|
||||||
|
// Check file permissions
|
||||||
|
final stat = file.statSync();
|
||||||
|
return (stat.mode & _posixExecuteBitsMask) != 0; // Check execute bits
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get predefined executable info
|
||||||
|
static ExecutableInfo? getExecutableInfo(String name) {
|
||||||
|
_ensureCustomExecutablesLoaded();
|
||||||
|
return _predefinedExecutables[name];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a custom executable definition
|
||||||
|
static void addCustomExecutable(String name, ExecutableInfo info) {
|
||||||
|
_ensureCustomExecutablesLoaded();
|
||||||
|
_customExecutables[name] = info;
|
||||||
|
_predefinedExecutables[name] = info;
|
||||||
|
_persistCustomExecutables();
|
||||||
|
Loggers.app.info('Adding custom executable: $name');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a custom executable definition
|
||||||
|
static void removeCustomExecutable(String name) {
|
||||||
|
_ensureCustomExecutablesLoaded();
|
||||||
|
final removed = _customExecutables.remove(name);
|
||||||
|
if (removed != null) {
|
||||||
|
_predefinedExecutables.remove(name);
|
||||||
|
_persistCustomExecutables();
|
||||||
|
Loggers.app.info('Removing custom executable: $name');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
300
lib/core/utils/proxy_command_executor.dart
Normal file
300
lib/core/utils/proxy_command_executor.dart
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:dartssh2/dartssh2.dart';
|
||||||
|
import 'package:fl_lib/fl_lib.dart';
|
||||||
|
import 'package:server_box/core/utils/executable_manager.dart';
|
||||||
|
import 'package:server_box/core/utils/proxy_socket.dart';
|
||||||
|
import 'package:server_box/data/model/server/proxy_command_config.dart';
|
||||||
|
import 'package:server_box/data/store/setting.dart';
|
||||||
|
|
||||||
|
/// Exception thrown when proxy command execution fails
|
||||||
|
class ProxyCommandException implements Exception {
|
||||||
|
final String message;
|
||||||
|
final int? exitCode;
|
||||||
|
final String? stdout;
|
||||||
|
final String? stderr;
|
||||||
|
|
||||||
|
ProxyCommandException({required this.message, this.exitCode, this.stdout, this.stderr});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'ProxyCommandException: $message'
|
||||||
|
'${exitCode != null ? ' (exit code: $exitCode)' : ''}'
|
||||||
|
'${stderr != null ? '\nStderr: $stderr' : ''}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic proxy command executor that handles SSH ProxyCommand functionality
|
||||||
|
abstract final class ProxyCommandExecutor {
|
||||||
|
static final Map<String, ProxyCommandConfig> _customPresets = {};
|
||||||
|
static bool _customPresetsLoaded = false;
|
||||||
|
|
||||||
|
static void _ensureCustomPresetsLoaded() {
|
||||||
|
if (_customPresetsLoaded) return;
|
||||||
|
|
||||||
|
final List<dynamic> stored = SettingStore.instance.proxyCmdCustomPresets.get();
|
||||||
|
for (final raw in stored) {
|
||||||
|
final preset = _parsePreset(raw);
|
||||||
|
if (preset == null) continue;
|
||||||
|
_customPresets[preset.key] = preset.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
_customPresetsLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _persistCustomPresets() {
|
||||||
|
final list = _customPresets.entries
|
||||||
|
.map((entry) => {
|
||||||
|
'name': entry.key,
|
||||||
|
'config': entry.value.toJson(),
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
SettingStore.instance.proxyCmdCustomPresets.set(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
static MapEntry<String, ProxyCommandConfig>? _parsePreset(dynamic raw) {
|
||||||
|
dynamic payload = raw;
|
||||||
|
if (payload is String) {
|
||||||
|
try {
|
||||||
|
payload = jsonDecode(payload);
|
||||||
|
} catch (e) {
|
||||||
|
Loggers.app.warning('Failed to decode custom proxy preset entry: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload is! Map) return null;
|
||||||
|
|
||||||
|
final name = payload['name']?.toString();
|
||||||
|
final configRaw = payload['config'];
|
||||||
|
if (name == null || name.isEmpty || configRaw is! Map) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final config = ProxyCommandConfig.fromJson(Map<String, dynamic>.from(configRaw));
|
||||||
|
return MapEntry(name, config);
|
||||||
|
} catch (e) {
|
||||||
|
Loggers.app.warning('Failed to parse custom proxy preset "$name": $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a proxy command and return a socket connected through the proxy
|
||||||
|
static Future<SSHSocket> executeProxyCommand(
|
||||||
|
ProxyCommandConfig config, {
|
||||||
|
required String hostname,
|
||||||
|
required int port,
|
||||||
|
required String user,
|
||||||
|
}) async {
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
throw ProxyCommandException(message: 'ProxyCommand is not supported on iOS');
|
||||||
|
}
|
||||||
|
|
||||||
|
final finalCommand = config.getFinalCommand(hostname: hostname, port: port, user: user);
|
||||||
|
final tokens = _tokenizeCommand(finalCommand);
|
||||||
|
if (tokens.isEmpty) {
|
||||||
|
throw ProxyCommandException(message: 'ProxyCommand resolved to an empty command');
|
||||||
|
}
|
||||||
|
final executableToken = tokens.first;
|
||||||
|
|
||||||
|
Loggers.app.info('Executing proxy command: $finalCommand');
|
||||||
|
|
||||||
|
// Ensure executable is available if required
|
||||||
|
String executablePath;
|
||||||
|
if (config.requiresExecutable && config.executableName != null) {
|
||||||
|
executablePath = await ExecutableManager.ensureExecutable(config.executableName!);
|
||||||
|
} else {
|
||||||
|
executablePath = await ExecutableManager.getExecutablePath(executableToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse command and arguments
|
||||||
|
final args = tokens.skip(1).toList();
|
||||||
|
|
||||||
|
// Set up environment
|
||||||
|
final environment = {...Platform.environment, ...?config.environment};
|
||||||
|
|
||||||
|
// Start the process
|
||||||
|
Process process;
|
||||||
|
try {
|
||||||
|
process = await Process.start(
|
||||||
|
executablePath,
|
||||||
|
args,
|
||||||
|
workingDirectory: config.workingDirectory,
|
||||||
|
environment: environment,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
throw ProxyCommandException(message: 'Failed to start proxy command: $e', exitCode: -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up timeout handling
|
||||||
|
var timedOut = false;
|
||||||
|
final timeoutTimer = Timer(config.timeout, () {
|
||||||
|
timedOut = true;
|
||||||
|
process.kill();
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// For ProxyCommand, we create a ProxySocket that wraps the process
|
||||||
|
final socket = ProxySocket(process);
|
||||||
|
|
||||||
|
// Monitor the process for immediate failures
|
||||||
|
unawaited(
|
||||||
|
process.exitCode.then((code) {
|
||||||
|
if (code != 0 && !socket.closed && !timedOut) {
|
||||||
|
socket.close();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return socket;
|
||||||
|
} catch (e) {
|
||||||
|
process.kill();
|
||||||
|
rethrow;
|
||||||
|
} finally {
|
||||||
|
timeoutTimer.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate proxy command configuration
|
||||||
|
static Future<String?> validateConfig(ProxyCommandConfig config) async {
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
return 'ProxyCommand is not supported on iOS';
|
||||||
|
}
|
||||||
|
|
||||||
|
final testCommand = config.getFinalCommand(hostname: 'test.example.com', port: 22, user: 'testuser');
|
||||||
|
late final List<String> tokens;
|
||||||
|
try {
|
||||||
|
tokens = _tokenizeCommand(testCommand);
|
||||||
|
} on ProxyCommandException catch (e) {
|
||||||
|
return e.message;
|
||||||
|
}
|
||||||
|
if (tokens.isEmpty) {
|
||||||
|
return 'Proxy command must not be empty';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if required placeholders are present
|
||||||
|
if (!config.command.contains('%h')) {
|
||||||
|
return 'Proxy command must contain %h (hostname) placeholder';
|
||||||
|
}
|
||||||
|
|
||||||
|
String executablePath;
|
||||||
|
|
||||||
|
// If executable is required, check if it exists and reuse resolved path
|
||||||
|
if (config.requiresExecutable && config.executableName != null) {
|
||||||
|
try {
|
||||||
|
executablePath = await ExecutableManager.ensureExecutable(config.executableName!);
|
||||||
|
} catch (e) {
|
||||||
|
return e.toString();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
executablePath = await ExecutableManager.getExecutablePath(tokens.first);
|
||||||
|
} catch (e) {
|
||||||
|
return e.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to validate command syntax (dry run)
|
||||||
|
try {
|
||||||
|
await Process.run(executablePath, ['--help']);
|
||||||
|
} catch (e) {
|
||||||
|
return 'Command validation failed: $e';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null; // No error
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get available proxy command presets
|
||||||
|
static Map<String, ProxyCommandConfig> getPresets() {
|
||||||
|
_ensureCustomPresetsLoaded();
|
||||||
|
return {
|
||||||
|
...proxyCommandPresets,
|
||||||
|
..._customPresets,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a custom preset
|
||||||
|
static Future<void> addCustomPreset(String name, ProxyCommandConfig config) async {
|
||||||
|
_ensureCustomPresetsLoaded();
|
||||||
|
_customPresets[name] = config;
|
||||||
|
_persistCustomPresets();
|
||||||
|
Loggers.app.info('Adding custom proxy preset: $name');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a custom preset
|
||||||
|
static Future<void> removeCustomPreset(String name) async {
|
||||||
|
_ensureCustomPresetsLoaded();
|
||||||
|
final removed = _customPresets.remove(name);
|
||||||
|
if (removed != null) {
|
||||||
|
_persistCustomPresets();
|
||||||
|
Loggers.app.info('Removing custom proxy preset: $name');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> tokenizeCommand(String command) => _tokenizeCommand(command);
|
||||||
|
|
||||||
|
static List<String> _tokenizeCommand(String command) {
|
||||||
|
final tokens = <String>[];
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
String? quote;
|
||||||
|
var escaped = false;
|
||||||
|
|
||||||
|
void flush() {
|
||||||
|
if (buffer.isEmpty) return;
|
||||||
|
tokens.add(buffer.toString());
|
||||||
|
buffer.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final rune in command.runes) {
|
||||||
|
final char = String.fromCharCode(rune);
|
||||||
|
|
||||||
|
if (escaped) {
|
||||||
|
buffer.write(char);
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quote != null) {
|
||||||
|
if (char == '\\' && quote == '"') {
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char == quote) {
|
||||||
|
quote = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
buffer.write(char);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char == '\\') {
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char == '"' || char == "'") {
|
||||||
|
quote = char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char.trim().isEmpty) {
|
||||||
|
flush();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.write(char);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quote != null) {
|
||||||
|
throw ProxyCommandException(message: 'ProxyCommand has unmatched quote');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (escaped) {
|
||||||
|
throw ProxyCommandException(message: 'ProxyCommand ends with an incomplete escape sequence');
|
||||||
|
}
|
||||||
|
|
||||||
|
flush();
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
}
|
||||||
125
lib/core/utils/proxy_socket.dart
Normal file
125
lib/core/utils/proxy_socket.dart
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:dartssh2/dartssh2.dart';
|
||||||
|
import 'package:fl_lib/fl_lib.dart';
|
||||||
|
|
||||||
|
/// Socket implementation that communicates through a Process stdin/stdout
|
||||||
|
/// This is used for ProxyCommand functionality where the SSH connection
|
||||||
|
/// is proxied through an external command
|
||||||
|
class ProxySocket implements SSHSocket {
|
||||||
|
final Process _process;
|
||||||
|
final StreamController<Uint8List> _incomingController =
|
||||||
|
StreamController<Uint8List>();
|
||||||
|
final StreamController<List<int>> _outgoingController =
|
||||||
|
StreamController<List<int>>();
|
||||||
|
final Completer<void> _doneCompleter = Completer<void>();
|
||||||
|
|
||||||
|
bool _closed = false;
|
||||||
|
late StreamSubscription<Uint8List> _stdoutSubscription;
|
||||||
|
late StreamSubscription<Uint8List> _stderrSubscription;
|
||||||
|
|
||||||
|
ProxySocket(this._process) {
|
||||||
|
// Set up stdout reading
|
||||||
|
_stdoutSubscription = _process.stdout
|
||||||
|
.transform(Uint8ListStreamTransformer())
|
||||||
|
.listen(_onIncomingData,
|
||||||
|
onError: _onError,
|
||||||
|
onDone: _onProcessDone,
|
||||||
|
cancelOnError: true);
|
||||||
|
|
||||||
|
// Set up stderr reading (for logging)
|
||||||
|
_stderrSubscription = _process.stderr
|
||||||
|
.transform(Uint8ListStreamTransformer())
|
||||||
|
.listen((data) {
|
||||||
|
Loggers.app.warning('Proxy stderr: ${String.fromCharCodes(data)}');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up outgoing data
|
||||||
|
_outgoingController.stream.listen(_onOutgoingData);
|
||||||
|
|
||||||
|
// Handle process exit
|
||||||
|
_process.exitCode.then((code) {
|
||||||
|
if (!_closed && code != 0) {
|
||||||
|
_onError('Proxy process exited with code: $code');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<Uint8List> get stream => _incomingController.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
StreamSink<List<int>> get sink => _outgoingController.sink;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> get done => _doneCompleter.future;
|
||||||
|
|
||||||
|
/// Check if the socket is closed
|
||||||
|
bool get closed => _closed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() async {
|
||||||
|
if (_closed) return;
|
||||||
|
_closed = true;
|
||||||
|
|
||||||
|
await _stdoutSubscription.cancel();
|
||||||
|
await _stderrSubscription.cancel();
|
||||||
|
await _outgoingController.close();
|
||||||
|
await _incomingController.close();
|
||||||
|
|
||||||
|
if (!_doneCompleter.isCompleted) {
|
||||||
|
_doneCompleter.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill the process if it's still running
|
||||||
|
try {
|
||||||
|
_process.kill();
|
||||||
|
} catch (e) {
|
||||||
|
Loggers.app.warning('Error killing proxy process: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void destroy() {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onIncomingData(Uint8List data) {
|
||||||
|
if (!_closed) {
|
||||||
|
_incomingController.add(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onOutgoingData(List<int> data) {
|
||||||
|
if (!_closed) {
|
||||||
|
_process.stdin.add(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onError(dynamic error, [StackTrace? stackTrace]) {
|
||||||
|
if (!_closed) {
|
||||||
|
_incomingController.addError(error, stackTrace);
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onProcessDone() {
|
||||||
|
if (!_closed) {
|
||||||
|
_incomingController.close();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transformer to convert `Stream<List<int>>` to `Stream<Uint8List>`
|
||||||
|
class Uint8ListStreamTransformer
|
||||||
|
extends StreamTransformerBase<List<int>, Uint8List> {
|
||||||
|
const Uint8ListStreamTransformer();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<Uint8List> bind(Stream<List<int>> stream) {
|
||||||
|
return stream.map((data) => Uint8List.fromList(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:dartssh2/dartssh2.dart';
|
import 'package:dartssh2/dartssh2.dart';
|
||||||
import 'package:fl_lib/fl_lib.dart';
|
import 'package:fl_lib/fl_lib.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:server_box/core/app_navigator.dart';
|
||||||
|
import 'package:server_box/core/extension/context/locale.dart';
|
||||||
|
import 'package:server_box/core/utils/proxy_command_executor.dart';
|
||||||
import 'package:server_box/data/model/app/error.dart';
|
import 'package:server_box/data/model/app/error.dart';
|
||||||
import 'package:server_box/data/model/server/server_private_info.dart';
|
import 'package:server_box/data/model/server/server_private_info.dart';
|
||||||
import 'package:server_box/data/res/store.dart';
|
import 'package:server_box/data/res/store.dart';
|
||||||
@@ -24,19 +30,12 @@ String decyptPem(List<String> args) {
|
|||||||
return sshKey.first.toPem();
|
return sshKey.first.toPem();
|
||||||
}
|
}
|
||||||
|
|
||||||
enum GenSSHClientStatus {
|
enum GenSSHClientStatus { socket, key, pwd }
|
||||||
socket,
|
|
||||||
key,
|
|
||||||
pwd,
|
|
||||||
}
|
|
||||||
|
|
||||||
String getPrivateKey(String id) {
|
String getPrivateKey(String id) {
|
||||||
final pki = Stores.key.fetchOne(id);
|
final pki = Stores.key.fetchOne(id);
|
||||||
if (pki == null) {
|
if (pki == null) {
|
||||||
throw SSHErr(
|
throw SSHErr(type: SSHErrType.noPrivateKey, message: l10n.privateKeyNotFoundFmt(id));
|
||||||
type: SSHErrType.noPrivateKey,
|
|
||||||
message: 'key [$id] not found',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return pki.key;
|
return pki.key;
|
||||||
}
|
}
|
||||||
@@ -58,14 +57,20 @@ Future<SSHClient> genClient(
|
|||||||
Spi? jumpSpi,
|
Spi? jumpSpi,
|
||||||
|
|
||||||
/// Handle keyboard-interactive authentication
|
/// 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 {
|
}) async {
|
||||||
onStatus?.call(GenSSHClientStatus.socket);
|
onStatus?.call(GenSSHClientStatus.socket);
|
||||||
|
|
||||||
|
final hostKeyCache = Map<String, String>.from(knownHostFingerprints ?? _loadKnownHostFingerprints());
|
||||||
|
final hostKeyPersist = onHostKeyAccepted ?? _persistHostKeyFingerprint;
|
||||||
|
final hostKeyPrompt = onHostKeyPrompt ?? _defaultHostKeyPrompt;
|
||||||
|
|
||||||
String? alterUser;
|
String? alterUser;
|
||||||
|
|
||||||
final socket = await () async {
|
// Check for Jump Server first - this needs special handling
|
||||||
// Proxy
|
|
||||||
final jumpSpi_ = () {
|
final jumpSpi_ = () {
|
||||||
// Multi-thread or key login
|
// Multi-thread or key login
|
||||||
if (jumpSpi != null) return jumpSpi;
|
if (jumpSpi != null) return jumpSpi;
|
||||||
@@ -73,42 +78,102 @@ Future<SSHClient> genClient(
|
|||||||
if (spi.jumpId != null) return Stores.server.box.get(spi.jumpId);
|
if (spi.jumpId != null) return Stores.server.box.get(spi.jumpId);
|
||||||
}();
|
}();
|
||||||
if (jumpSpi_ != null) {
|
if (jumpSpi_ != null) {
|
||||||
|
// For jump server, we establish connection through the jump client
|
||||||
final jumpClient = await genClient(
|
final jumpClient = await genClient(
|
||||||
jumpSpi_,
|
jumpSpi_,
|
||||||
privateKey: jumpPrivateKey,
|
privateKey: jumpPrivateKey,
|
||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
|
knownHostFingerprints: hostKeyCache,
|
||||||
|
onHostKeyAccepted: hostKeyPersist,
|
||||||
|
onHostKeyPrompt: onHostKeyPrompt,
|
||||||
);
|
);
|
||||||
|
|
||||||
return await jumpClient.forwardLocal(
|
final forwardChannel = await jumpClient.forwardLocal(spi.ip, spi.port);
|
||||||
spi.ip,
|
|
||||||
spi.port,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Direct
|
final hostKeyVerifier = _HostKeyVerifier(
|
||||||
try {
|
spi: spi,
|
||||||
return await SSHSocket.connect(
|
cache: hostKeyCache,
|
||||||
spi.ip,
|
persistCallback: hostKeyPersist,
|
||||||
spi.port,
|
prompt: hostKeyPrompt,
|
||||||
timeout: timeout,
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
|
||||||
Loggers.app.warning('genClient', e);
|
final keyId = spi.keyId;
|
||||||
if (spi.alterUrl == null) rethrow;
|
if (keyId == null) {
|
||||||
try {
|
onStatus?.call(GenSSHClientStatus.pwd);
|
||||||
final res = spi.fromStringUrl();
|
return SSHClient(
|
||||||
alterUser = res.$2;
|
forwardChannel,
|
||||||
return await SSHSocket.connect(
|
username: spi.user,
|
||||||
res.$1,
|
onPasswordRequest: () => spi.pwd,
|
||||||
res.$3,
|
onUserInfoRequest: onKeyboardInteractive,
|
||||||
timeout: timeout,
|
onVerifyHostKey: hostKeyVerifier.call,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
|
||||||
Loggers.app.warning('genClient alterUrl', e);
|
|
||||||
rethrow;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
privateKey ??= getPrivateKey(keyId);
|
||||||
|
onStatus?.call(GenSSHClientStatus.key);
|
||||||
|
return SSHClient(
|
||||||
|
forwardChannel,
|
||||||
|
username: spi.user,
|
||||||
|
identities: await compute(loadIndentity, privateKey),
|
||||||
|
onUserInfoRequest: onKeyboardInteractive,
|
||||||
|
onVerifyHostKey: hostKeyVerifier.call,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}();
|
|
||||||
|
// For ProxyCommand and direct connections, get SSHSocket
|
||||||
|
SSHSocket? socket;
|
||||||
|
try {
|
||||||
|
final proxyCommand = spi.proxyCommand;
|
||||||
|
// ProxyCommand support - Check for ProxyCommand configuration first
|
||||||
|
if (proxyCommand != null && !Platform.isIOS) {
|
||||||
|
try {
|
||||||
|
Loggers.app.info('Connecting via ProxyCommand: ${proxyCommand.command}');
|
||||||
|
socket = await ProxyCommandExecutor.executeProxyCommand(
|
||||||
|
proxyCommand,
|
||||||
|
hostname: spi.ip,
|
||||||
|
port: spi.port,
|
||||||
|
user: spi.user,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
Loggers.app.warning('ProxyCommand failed', e);
|
||||||
|
if (!proxyCommand.retryOnFailure) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
// If retry is enabled, fall through to direct connection
|
||||||
|
Loggers.app.info('ProxyCommand failed, falling back to direct connection');
|
||||||
|
}
|
||||||
|
} else if (proxyCommand != null && Platform.isIOS) {
|
||||||
|
Loggers.app.info('ProxyCommand configuration is ignored on iOS');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct connection (or fallback)
|
||||||
|
socket ??= await () async {
|
||||||
|
try {
|
||||||
|
return await SSHSocket.connect(spi.ip, spi.port, timeout: timeout);
|
||||||
|
} catch (e) {
|
||||||
|
Loggers.app.warning('genClient', e);
|
||||||
|
if (spi.alterUrl == null) rethrow;
|
||||||
|
try {
|
||||||
|
final res = spi.parseAlterUrl();
|
||||||
|
alterUser = res.$2;
|
||||||
|
return await SSHSocket.connect(res.$1, res.$3, timeout: timeout);
|
||||||
|
} catch (e) {
|
||||||
|
Loggers.app.warning('genClient alterUrl', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}();
|
||||||
|
} catch (e) {
|
||||||
|
Loggers.app.warning('Failed to establish connection', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
final hostKeyVerifier = _HostKeyVerifier(
|
||||||
|
spi: spi,
|
||||||
|
cache: hostKeyCache,
|
||||||
|
persistCallback: hostKeyPersist,
|
||||||
|
prompt: hostKeyPrompt,
|
||||||
|
);
|
||||||
|
|
||||||
final keyId = spi.keyId;
|
final keyId = spi.keyId;
|
||||||
if (keyId == null) {
|
if (keyId == null) {
|
||||||
@@ -118,8 +183,7 @@ Future<SSHClient> genClient(
|
|||||||
username: alterUser ?? spi.user,
|
username: alterUser ?? spi.user,
|
||||||
onPasswordRequest: () => spi.pwd,
|
onPasswordRequest: () => spi.pwd,
|
||||||
onUserInfoRequest: onKeyboardInteractive,
|
onUserInfoRequest: onKeyboardInteractive,
|
||||||
// printDebug: debugPrint,
|
onVerifyHostKey: hostKeyVerifier.call,
|
||||||
// printTrace: debugPrint,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
privateKey ??= getPrivateKey(keyId);
|
privateKey ??= getPrivateKey(keyId);
|
||||||
@@ -128,10 +192,220 @@ Future<SSHClient> genClient(
|
|||||||
return SSHClient(
|
return SSHClient(
|
||||||
socket,
|
socket,
|
||||||
username: spi.user,
|
username: spi.user,
|
||||||
// Must use [compute] here, instead of [Computer.shared.start]
|
|
||||||
identities: await compute(loadIndentity, privateKey),
|
identities: await compute(loadIndentity, privateKey),
|
||||||
onUserInfoRequest: onKeyboardInteractive,
|
onUserInfoRequest: onKeyboardInteractive,
|
||||||
// printDebug: debugPrint,
|
onVerifyHostKey: hostKeyVerifier.call,
|
||||||
// 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:fl_lib/fl_lib.dart';
|
||||||
import 'package:flutter/material.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/model/server/server_private_info.dart';
|
||||||
import 'package:server_box/data/provider/app.dart';
|
|
||||||
|
|
||||||
abstract final class KeybordInteractive {
|
abstract final class KeybordInteractive {
|
||||||
static FutureOr<List<String>?> defaultHandle(
|
static FutureOr<List<String>?> defaultHandle(Spi spi, {BuildContext? ctx}) async {
|
||||||
Spi spi, {
|
|
||||||
BuildContext? ctx,
|
|
||||||
}) async {
|
|
||||||
try {
|
try {
|
||||||
final res = await (ctx ?? AppProvider.ctx)?.showPwdDialog(
|
final res = await (ctx ?? WidgetsBinding.instance.focusManager.primaryFocus?.context)?.showPwdDialog(
|
||||||
title: l10n.pwd,
|
title: libL10n.pwd,
|
||||||
id: spi.id,
|
id: spi.id,
|
||||||
label: spi.id,
|
label: spi.id,
|
||||||
);
|
);
|
||||||
|
|||||||
205
lib/core/utils/ssh_config.dart
Normal file
205
lib/core/utils/ssh_config.dart
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
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) {
|
||||||
|
// Normalize whitespace
|
||||||
|
final parts = value.trim().split(RegExp(r'\s+'));
|
||||||
|
|
||||||
|
// Try to find a token that looks like a user@host[:port]
|
||||||
|
// This covers common patterns like:
|
||||||
|
// - ProxyJump user@host
|
||||||
|
// - ProxyCommand ssh -W %h:%p user@host
|
||||||
|
for (final token in parts) {
|
||||||
|
if (token.contains('@')) {
|
||||||
|
// Strip any surrounding quotes just in case
|
||||||
|
var cleaned = token;
|
||||||
|
if ((cleaned.startsWith("'") && cleaned.endsWith("'")) ||
|
||||||
|
(cleaned.startsWith('"') && cleaned.endsWith('"'))) {
|
||||||
|
cleaned = cleaned.substring(1, cleaned.length - 1);
|
||||||
|
}
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProxyJump may also be provided as just a hostname (no user@)
|
||||||
|
// In that case we don't have enough information to build an oldId-style reference,
|
||||||
|
// so we ignore it here and let the user configure a jump server manually.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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');
|
_logger.info('Restore success');
|
||||||
}
|
}
|
||||||
|
|
||||||
factory Backup.fromJsonString(String raw) =>
|
factory Backup.fromJsonString(String raw) => Backup.fromJson(json.decode(_diyDecrypt(raw)));
|
||||||
Backup.fromJson(json.decode(_diyDecrypt(raw)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _diyEncrypt(String raw) => json.encode(
|
String _diyEncrypt(String raw) => json.encode(raw.codeUnits.map((e) => e * 2 + 1).toList(growable: false));
|
||||||
raw.codeUnits.map((e) => e * 2 + 1).toList(growable: false),
|
|
||||||
);
|
|
||||||
|
|
||||||
String _diyDecrypt(String raw) {
|
String _diyDecrypt(String raw) {
|
||||||
try {
|
try {
|
||||||
@@ -234,4 +231,3 @@ String _diyDecrypt(String raw) {
|
|||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import 'dart:io';
|
|||||||
import 'package:fl_lib/fl_lib.dart';
|
import 'package:fl_lib/fl_lib.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:server_box/data/provider/private_key.dart';
|
||||||
|
import 'package:server_box/data/provider/server/all.dart';
|
||||||
|
import 'package:server_box/data/provider/snippet.dart';
|
||||||
import 'package:server_box/data/res/misc.dart';
|
import 'package:server_box/data/res/misc.dart';
|
||||||
import 'package:server_box/data/res/store.dart';
|
import 'package:server_box/data/res/store.dart';
|
||||||
|
|
||||||
@@ -44,17 +47,17 @@ abstract class BackupV2 with _$BackupV2 implements Mergeable {
|
|||||||
Future<void> merge({bool force = false}) async {
|
Future<void> merge({bool force = false}) async {
|
||||||
_loggerV2.info('Merging...');
|
_loggerV2.info('Merging...');
|
||||||
|
|
||||||
// Merge each store
|
// Merge each store and check if changes were made
|
||||||
await Mergeable.mergeStore(backupData: spis, store: Stores.server, force: force);
|
final serverChanged = await Mergeable.mergeStore(backupData: spis, store: Stores.server, force: force);
|
||||||
await Mergeable.mergeStore(backupData: snippets, store: Stores.snippet, force: force);
|
final snippetChanged = await Mergeable.mergeStore(backupData: snippets, store: Stores.snippet, force: force);
|
||||||
await Mergeable.mergeStore(backupData: keys, store: Stores.key, force: force);
|
final keyChanged = await Mergeable.mergeStore(backupData: keys, store: Stores.key, force: force);
|
||||||
await Mergeable.mergeStore(backupData: container, store: Stores.container, force: force);
|
await Mergeable.mergeStore(backupData: container, store: Stores.container, force: force);
|
||||||
await Mergeable.mergeStore(backupData: history, store: Stores.history, force: force);
|
await Mergeable.mergeStore(backupData: history, store: Stores.history, force: force);
|
||||||
await Mergeable.mergeStore(backupData: settings, store: Stores.setting, force: force);
|
await Mergeable.mergeStore(backupData: settings, store: Stores.setting, force: force);
|
||||||
|
|
||||||
// Reload providers and notify listeners
|
if (serverChanged) GlobalRef.gRef?.read(serversProvider.notifier).reload();
|
||||||
Provider.reload();
|
if (snippetChanged) GlobalRef.gRef?.read(snippetProvider.notifier).reload();
|
||||||
RNodes.app.notify();
|
if (keyChanged) GlobalRef.gRef?.read(privateKeyProvider.notifier).reload();
|
||||||
|
|
||||||
_loggerV2.info('Merge completed');
|
_loggerV2.info('Merge completed');
|
||||||
}
|
}
|
||||||
@@ -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 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);
|
final path = Paths.doc.joinPath(name ?? Miscs.bakFileName);
|
||||||
await File(path).writeAsString(result);
|
await File(path).writeAsString(result);
|
||||||
return path;
|
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>;
|
final map = json.decode(jsonString) as Map<String, dynamic>;
|
||||||
return BackupV2.fromJson(map);
|
return BackupV2.fromJson(map);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// dart format width=80
|
|
||||||
// coverage:ignore-file
|
|
||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint
|
// ignore_for_file: type=lint
|
||||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||||
|
|
||||||
@@ -83,6 +82,136 @@ as Map<String, Object?>,
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Adds pattern-matching-related methods to [BackupV2].
|
||||||
|
extension BackupV2Patterns on BackupV2 {
|
||||||
|
/// A variant of `map` that fallback to returning `orElse`.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return orElse();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _BackupV2 value)? $default,{required TResult orElse(),}){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _BackupV2() when $default != null:
|
||||||
|
return $default(_that);case _:
|
||||||
|
return orElse();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A `switch`-like method, using callbacks.
|
||||||
|
///
|
||||||
|
/// Callbacks receives the raw object, upcasted.
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case final Subclass2 value:
|
||||||
|
/// return ...;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _BackupV2 value) $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _BackupV2():
|
||||||
|
return $default(_that);case _:
|
||||||
|
throw StateError('Unexpected subclass');
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A variant of `map` that fallback to returning `null`.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return null;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _BackupV2 value)? $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _BackupV2() when $default != null:
|
||||||
|
return $default(_that);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A variant of `when` that fallback to an `orElse` callback.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return orElse();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( int version, int date, Map<String, Object?> spis, Map<String, Object?> snippets, Map<String, Object?> keys, Map<String, Object?> container, Map<String, Object?> history, Map<String, Object?> settings)? $default,{required TResult orElse(),}) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _BackupV2() when $default != null:
|
||||||
|
return $default(_that.version,_that.date,_that.spis,_that.snippets,_that.keys,_that.container,_that.history,_that.settings);case _:
|
||||||
|
return orElse();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A `switch`-like method, using callbacks.
|
||||||
|
///
|
||||||
|
/// As opposed to `map`, this offers destructuring.
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case Subclass2(:final field2):
|
||||||
|
/// return ...;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( int version, int date, Map<String, Object?> spis, Map<String, Object?> snippets, Map<String, Object?> keys, Map<String, Object?> container, Map<String, Object?> history, Map<String, Object?> settings) $default,) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _BackupV2():
|
||||||
|
return $default(_that.version,_that.date,_that.spis,_that.snippets,_that.keys,_that.container,_that.history,_that.settings);case _:
|
||||||
|
throw StateError('Unexpected subclass');
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A variant of `when` that fallback to returning `null`
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return null;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( int version, int date, Map<String, Object?> spis, Map<String, Object?> snippets, Map<String, Object?> keys, Map<String, Object?> container, Map<String, Object?> history, Map<String, Object?> settings)? $default,) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _BackupV2() when $default != null:
|
||||||
|
return $default(_that.version,_that.date,_that.spis,_that.snippets,_that.keys,_that.container,_that.history,_that.settings);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
/// @nodoc
|
/// @nodoc
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
|
|
||||||
|
|||||||
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';
|
import 'package:server_box/data/model/app/bak/backup2.dart';
|
||||||
|
|
||||||
abstract final class MergeableUtils {
|
abstract final class MergeableUtils {
|
||||||
static (Mergeable, String) fromJsonString(String json) {
|
static (Mergeable, String) fromJsonString(String json, [String? password]) {
|
||||||
try {
|
try {
|
||||||
final bak = BackupV2.fromJsonString(json);
|
final bak = BackupV2.fromJsonString(json, password);
|
||||||
return (bak, DateTime.fromMillisecondsSinceEpoch(bak.date).hms());
|
return (bak, DateTime.fromMillisecondsSinceEpoch(bak.date).hms());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
final bak = Backup.fromJsonString(json);
|
final bak = Backup.fromJsonString(json);
|
||||||
|
|||||||
@@ -1,29 +1,19 @@
|
|||||||
import 'package:fl_lib/fl_lib.dart';
|
import 'package:fl_lib/fl_lib.dart';
|
||||||
import 'package:server_box/core/extension/context/locale.dart';
|
import 'package:server_box/core/extension/context/locale.dart';
|
||||||
|
|
||||||
enum SSHErrType {
|
enum SSHErrType { unknown, connect, auth, noPrivateKey, chdir, segements, writeScript, getStatus }
|
||||||
unknown,
|
|
||||||
connect,
|
|
||||||
auth,
|
|
||||||
noPrivateKey,
|
|
||||||
chdir,
|
|
||||||
segements,
|
|
||||||
writeScript,
|
|
||||||
getStatus,
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
class SSHErr extends Err<SSHErrType> {
|
class SSHErr extends Err<SSHErrType> {
|
||||||
SSHErr({required super.type, super.message});
|
const SSHErr({required super.type, super.message});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String? get solution => switch (type) {
|
String? get solution => switch (type) {
|
||||||
SSHErrType.chdir => l10n.needHomeDir,
|
SSHErrType.chdir => l10n.needHomeDir,
|
||||||
SSHErrType.auth => l10n.authFailTip,
|
SSHErrType.auth => l10n.authFailTip,
|
||||||
SSHErrType.writeScript => l10n.writeScriptFailTip,
|
SSHErrType.writeScript => l10n.writeScriptFailTip,
|
||||||
SSHErrType.noPrivateKey => l10n.noPrivateKeyTip,
|
SSHErrType.noPrivateKey => l10n.noPrivateKeyTip,
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ContainerErrType {
|
enum ContainerErrType {
|
||||||
@@ -39,47 +29,34 @@ enum ContainerErrType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ContainerErr extends Err<ContainerErrType> {
|
class ContainerErr extends Err<ContainerErrType> {
|
||||||
ContainerErr({required super.type, super.message});
|
const ContainerErr({required super.type, super.message});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String? get solution => null;
|
String? get solution => null;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ICloudErrType {
|
enum ICloudErrType { generic, notFound, multipleFiles }
|
||||||
generic,
|
|
||||||
notFound,
|
|
||||||
multipleFiles,
|
|
||||||
}
|
|
||||||
|
|
||||||
class ICloudErr extends Err<ICloudErrType> {
|
class ICloudErr extends Err<ICloudErrType> {
|
||||||
ICloudErr({required super.type, super.message});
|
const ICloudErr({required super.type, super.message});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String? get solution => null;
|
String? get solution => null;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum WebdavErrType {
|
enum WebdavErrType { generic, notFound }
|
||||||
generic,
|
|
||||||
notFound,
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
class WebdavErr extends Err<WebdavErrType> {
|
class WebdavErr extends Err<WebdavErrType> {
|
||||||
WebdavErr({required super.type, super.message});
|
const WebdavErr({required super.type, super.message});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String? get solution => null;
|
String? get solution => null;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PveErrType {
|
enum PveErrType { unknown, net, loginFailed }
|
||||||
unknown,
|
|
||||||
net,
|
|
||||||
loginFailed,
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
class PveErr extends Err<PveErrType> {
|
class PveErr extends Err<PveErrType> {
|
||||||
PveErr({required super.type, super.message});
|
const PveErr({required super.type, super.message});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String? get solution => null;
|
String? get solution => null;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ enum ContainerMenu {
|
|||||||
restart,
|
restart,
|
||||||
rm,
|
rm,
|
||||||
logs,
|
logs,
|
||||||
terminal,
|
terminal
|
||||||
//stats,
|
//stats,
|
||||||
;
|
;
|
||||||
|
|
||||||
@@ -27,22 +27,22 @@ enum ContainerMenu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
IconData get icon => switch (this) {
|
IconData get icon => switch (this) {
|
||||||
ContainerMenu.start => Icons.play_arrow,
|
ContainerMenu.start => Icons.play_arrow,
|
||||||
ContainerMenu.stop => Icons.stop,
|
ContainerMenu.stop => Icons.stop,
|
||||||
ContainerMenu.restart => Icons.restart_alt,
|
ContainerMenu.restart => Icons.restart_alt,
|
||||||
ContainerMenu.rm => Icons.delete,
|
ContainerMenu.rm => Icons.delete,
|
||||||
ContainerMenu.logs => Icons.logo_dev,
|
ContainerMenu.logs => Icons.logo_dev,
|
||||||
ContainerMenu.terminal => Icons.terminal,
|
ContainerMenu.terminal => Icons.terminal,
|
||||||
// DockerMenuType.stats => Icons.bar_chart,
|
// DockerMenuType.stats => Icons.bar_chart,
|
||||||
};
|
};
|
||||||
|
|
||||||
String get toStr => switch (this) {
|
String get toStr => switch (this) {
|
||||||
ContainerMenu.start => l10n.start,
|
ContainerMenu.start => l10n.start,
|
||||||
ContainerMenu.stop => l10n.stop,
|
ContainerMenu.stop => l10n.stop,
|
||||||
ContainerMenu.restart => l10n.restart,
|
ContainerMenu.restart => l10n.restart,
|
||||||
ContainerMenu.rm => libL10n.delete,
|
ContainerMenu.rm => libL10n.delete,
|
||||||
ContainerMenu.logs => libL10n.log,
|
ContainerMenu.logs => libL10n.log,
|
||||||
ContainerMenu.terminal => l10n.terminal,
|
ContainerMenu.terminal => l10n.terminal,
|
||||||
// DockerMenuType.stats => s.stats,
|
// DockerMenuType.stats => s.stats,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ enum ServerFuncBtn {
|
|||||||
snippet(),
|
snippet(),
|
||||||
iperf(),
|
iperf(),
|
||||||
// pve(),
|
// pve(),
|
||||||
systemd(1058),
|
systemd(1058);
|
||||||
;
|
|
||||||
|
|
||||||
final int? addedVersion;
|
final int? addedVersion;
|
||||||
|
|
||||||
@@ -41,24 +40,24 @@ enum ServerFuncBtn {
|
|||||||
].map((e) => e.index).toList();
|
].map((e) => e.index).toList();
|
||||||
|
|
||||||
IconData get icon => switch (this) {
|
IconData get icon => switch (this) {
|
||||||
sftp => Icons.insert_drive_file,
|
sftp => Icons.insert_drive_file,
|
||||||
snippet => Icons.code,
|
snippet => Icons.code,
|
||||||
//pkg => Icons.system_security_update,
|
//pkg => Icons.system_security_update,
|
||||||
container => FontAwesome.docker_brand,
|
container => FontAwesome.docker_brand,
|
||||||
process => Icons.list_alt_outlined,
|
process => Icons.list_alt_outlined,
|
||||||
terminal => Icons.terminal,
|
terminal => Icons.terminal,
|
||||||
iperf => Icons.speed,
|
iperf => Icons.speed,
|
||||||
systemd => MingCute.plugin_2_fill,
|
systemd => MingCute.plugin_2_fill,
|
||||||
};
|
};
|
||||||
|
|
||||||
String get toStr => switch (this) {
|
String get toStr => switch (this) {
|
||||||
sftp => 'SFTP',
|
sftp => 'SFTP',
|
||||||
snippet => l10n.snippet,
|
snippet => l10n.snippet,
|
||||||
//pkg => l10n.pkg,
|
//pkg => l10n.pkg,
|
||||||
container => l10n.container,
|
container => l10n.container,
|
||||||
process => l10n.process,
|
process => l10n.process,
|
||||||
terminal => l10n.terminal,
|
terminal => l10n.terminal,
|
||||||
iperf => 'iperf',
|
iperf => 'iperf',
|
||||||
systemd => 'Systemd',
|
systemd => 'Systemd',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,16 +8,16 @@ enum NetViewType {
|
|||||||
traffic;
|
traffic;
|
||||||
|
|
||||||
NetViewType get next => switch (this) {
|
NetViewType get next => switch (this) {
|
||||||
conn => speed,
|
conn => speed,
|
||||||
speed => traffic,
|
speed => traffic,
|
||||||
traffic => conn,
|
traffic => conn,
|
||||||
};
|
};
|
||||||
|
|
||||||
String get toStr => switch (this) {
|
String get toStr => switch (this) {
|
||||||
NetViewType.conn => l10n.conn,
|
NetViewType.conn => l10n.conn,
|
||||||
NetViewType.traffic => l10n.traffic,
|
NetViewType.traffic => l10n.traffic,
|
||||||
NetViewType.speed => l10n.speed,
|
NetViewType.speed => l10n.speed,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// If no device is specified, return the cached value (only real devices,
|
/// If no device is specified, return the cached value (only real devices,
|
||||||
/// such as ethX, wlanX...).
|
/// such as ethX, wlanX...).
|
||||||
@@ -26,32 +26,17 @@ enum NetViewType {
|
|||||||
try {
|
try {
|
||||||
switch (this) {
|
switch (this) {
|
||||||
case NetViewType.conn:
|
case NetViewType.conn:
|
||||||
return (
|
return ('${l10n.conn}:\n${ss.tcp.maxConn}', '${libL10n.fail}:\n${ss.tcp.fail}');
|
||||||
'${l10n.conn}:\n${ss.tcp.maxConn}',
|
|
||||||
'${libL10n.fail}:\n${ss.tcp.fail}',
|
|
||||||
);
|
|
||||||
case NetViewType.speed:
|
case NetViewType.speed:
|
||||||
if (notSepcifyDev) {
|
if (notSepcifyDev) {
|
||||||
return (
|
return ('↓:\n${ss.netSpeed.cachedVals.speedIn}', '↑:\n${ss.netSpeed.cachedVals.speedOut}');
|
||||||
'↓:\n${ss.netSpeed.cachedVals.speedIn}',
|
|
||||||
'↑:\n${ss.netSpeed.cachedVals.speedOut}',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return (
|
return ('↓:\n${ss.netSpeed.speedIn(device: dev)}', '↑:\n${ss.netSpeed.speedOut(device: dev)}');
|
||||||
'↓:\n${ss.netSpeed.speedIn(device: dev)}',
|
|
||||||
'↑:\n${ss.netSpeed.speedOut(device: dev)}',
|
|
||||||
);
|
|
||||||
case NetViewType.traffic:
|
case NetViewType.traffic:
|
||||||
if (notSepcifyDev) {
|
if (notSepcifyDev) {
|
||||||
return (
|
return ('↓:\n${ss.netSpeed.cachedVals.sizeIn}', '↑:\n${ss.netSpeed.cachedVals.sizeOut}');
|
||||||
'↓:\n${ss.netSpeed.cachedVals.sizeIn}',
|
|
||||||
'↑:\n${ss.netSpeed.cachedVals.sizeOut}',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return (
|
return ('↓:\n${ss.netSpeed.sizeIn(device: dev)}', '↑:\n${ss.netSpeed.sizeOut(device: dev)}');
|
||||||
'↓:\n${ss.netSpeed.sizeIn(device: dev)}',
|
|
||||||
'↑:\n${ss.netSpeed.sizeOut(device: dev)}',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
Loggers.app.warning('NetViewType.build', e, s);
|
Loggers.app.warning('NetViewType.build', e, s);
|
||||||
@@ -60,14 +45,14 @@ enum NetViewType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int toJson() => switch (this) {
|
int toJson() => switch (this) {
|
||||||
NetViewType.conn => 0,
|
NetViewType.conn => 0,
|
||||||
NetViewType.speed => 1,
|
NetViewType.speed => 1,
|
||||||
NetViewType.traffic => 2,
|
NetViewType.traffic => 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
static NetViewType fromJson(int json) => switch (json) {
|
static NetViewType fromJson(int json) => switch (json) {
|
||||||
0 => NetViewType.conn,
|
0 => NetViewType.conn,
|
||||||
1 => NetViewType.speed,
|
1 => NetViewType.speed,
|
||||||
_ => NetViewType.traffic,
|
_ => 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 -a -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:fl_lib/fl_lib.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hive_ce_flutter/adapters.dart';
|
||||||
import 'package:icons_plus/icons_plus.dart';
|
import 'package:icons_plus/icons_plus.dart';
|
||||||
import 'package:server_box/core/extension/context/locale.dart';
|
import 'package:server_box/core/extension/context/locale.dart';
|
||||||
import 'package:server_box/view/page/server/tab/tab.dart';
|
import 'package:server_box/view/page/server/tab/tab.dart';
|
||||||
@@ -8,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/ssh/tab.dart';
|
||||||
import 'package:server_box/view/page/storage/local.dart';
|
import 'package:server_box/view/page/storage/local.dart';
|
||||||
|
|
||||||
|
part 'tab.g.dart';
|
||||||
|
|
||||||
|
@HiveType(typeId: 103)
|
||||||
enum AppTab {
|
enum AppTab {
|
||||||
|
@HiveField(0)
|
||||||
server,
|
server,
|
||||||
|
@HiveField(1)
|
||||||
ssh,
|
ssh,
|
||||||
|
@HiveField(2)
|
||||||
file,
|
file,
|
||||||
snippet,
|
@HiveField(3)
|
||||||
|
snippet
|
||||||
//settings,
|
//settings,
|
||||||
;
|
;
|
||||||
|
|
||||||
@@ -29,60 +37,60 @@ enum AppTab {
|
|||||||
NavigationDestination get navDestination {
|
NavigationDestination get navDestination {
|
||||||
return switch (this) {
|
return switch (this) {
|
||||||
server => NavigationDestination(
|
server => NavigationDestination(
|
||||||
icon: const Icon(BoxIcons.bx_server),
|
icon: const Icon(BoxIcons.bx_server),
|
||||||
label: l10n.server,
|
label: l10n.server,
|
||||||
selectedIcon: const Icon(BoxIcons.bxs_server),
|
selectedIcon: const Icon(BoxIcons.bxs_server),
|
||||||
),
|
),
|
||||||
// settings => NavigationDestination(
|
// settings => NavigationDestination(
|
||||||
// icon: const Icon(Icons.settings),
|
// icon: const Icon(Icons.settings),
|
||||||
// label: libL10n.setting,
|
// label: libL10n.setting,
|
||||||
// selectedIcon: const Icon(Icons.settings),
|
// selectedIcon: const Icon(Icons.settings),
|
||||||
// ),
|
// ),
|
||||||
ssh => const NavigationDestination(
|
ssh => const NavigationDestination(
|
||||||
icon: Icon(Icons.terminal_outlined),
|
icon: Icon(Icons.terminal_outlined),
|
||||||
label: 'SSH',
|
label: 'SSH',
|
||||||
selectedIcon: Icon(Icons.terminal),
|
selectedIcon: Icon(Icons.terminal),
|
||||||
),
|
),
|
||||||
snippet => NavigationDestination(
|
snippet => NavigationDestination(
|
||||||
icon: const Icon(Icons.code),
|
icon: const Icon(Icons.code),
|
||||||
label: l10n.snippet,
|
label: l10n.snippet,
|
||||||
selectedIcon: const Icon(Icons.code),
|
selectedIcon: const Icon(Icons.code),
|
||||||
),
|
),
|
||||||
file => NavigationDestination(
|
file => NavigationDestination(
|
||||||
icon: const Icon(Icons.folder_open),
|
icon: const Icon(Icons.folder_open),
|
||||||
label: libL10n.file,
|
label: libL10n.file,
|
||||||
selectedIcon: const Icon(Icons.folder),
|
selectedIcon: const Icon(Icons.folder),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
NavigationRailDestination get navRailDestination {
|
NavigationRailDestination get navRailDestination {
|
||||||
return switch (this) {
|
return switch (this) {
|
||||||
server => NavigationRailDestination(
|
server => NavigationRailDestination(
|
||||||
icon: const Icon(BoxIcons.bx_server),
|
icon: const Icon(BoxIcons.bx_server),
|
||||||
label: Text(l10n.server),
|
label: Text(l10n.server),
|
||||||
selectedIcon: const Icon(BoxIcons.bxs_server),
|
selectedIcon: const Icon(BoxIcons.bxs_server),
|
||||||
),
|
),
|
||||||
// settings => NavigationRailDestination(
|
// settings => NavigationRailDestination(
|
||||||
// icon: const Icon(Icons.settings),
|
// icon: const Icon(Icons.settings),
|
||||||
// label: libL10n.setting,
|
// label: libL10n.setting,
|
||||||
// selectedIcon: const Icon(Icons.settings),
|
// selectedIcon: const Icon(Icons.settings),
|
||||||
// ),
|
// ),
|
||||||
ssh => const NavigationRailDestination(
|
ssh => const NavigationRailDestination(
|
||||||
icon: Icon(Icons.terminal_outlined),
|
icon: Icon(Icons.terminal_outlined),
|
||||||
label: Text('SSH'),
|
label: Text('SSH'),
|
||||||
selectedIcon: Icon(Icons.terminal),
|
selectedIcon: Icon(Icons.terminal),
|
||||||
),
|
),
|
||||||
snippet => NavigationRailDestination(
|
snippet => NavigationRailDestination(
|
||||||
icon: const Icon(Icons.code),
|
icon: const Icon(Icons.code),
|
||||||
label: Text(l10n.snippet),
|
label: Text(l10n.snippet),
|
||||||
selectedIcon: const Icon(Icons.code),
|
selectedIcon: const Icon(Icons.code),
|
||||||
),
|
),
|
||||||
file => NavigationRailDestination(
|
file => NavigationRailDestination(
|
||||||
icon: const Icon(Icons.folder_open),
|
icon: const Icon(Icons.folder_open),
|
||||||
label: Text(libL10n.file),
|
label: Text(libL10n.file),
|
||||||
selectedIcon: const Icon(Icons.folder),
|
selectedIcon: const Icon(Icons.folder),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,4 +101,35 @@ enum AppTab {
|
|||||||
static List<NavigationRailDestination> get navRailDestinations {
|
static List<NavigationRailDestination> get navRailDestinations {
|
||||||
return AppTab.values.map((e) => e.navRailDestination).toList();
|
return AppTab.values.map((e) => e.navRailDestination).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/// Helper function to parse AppTab list from stored object
|
||||||
|
static List<AppTab> parseAppTabsFromObj(dynamic val) {
|
||||||
|
if (val is List) {
|
||||||
|
final tabs = <AppTab>[];
|
||||||
|
for (final e in val) {
|
||||||
|
final tab = _parseAppTabFromElement(e);
|
||||||
|
if (tab != null) {
|
||||||
|
tabs.add(tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tabs.isNotEmpty) return tabs;
|
||||||
|
}
|
||||||
|
return AppTab.values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to parse a single AppTab from various element types
|
||||||
|
static AppTab? _parseAppTabFromElement(dynamic e) {
|
||||||
|
if (e is AppTab) {
|
||||||
|
return e;
|
||||||
|
} else if (e is String) {
|
||||||
|
return AppTab.values.firstWhereOrNull((t) => t.name == e);
|
||||||
|
} else if (e is int) {
|
||||||
|
if (e >= 0 && e < AppTab.values.length) {
|
||||||
|
return AppTab.values[e];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
52
lib/data/model/app/tab.g.dart
Normal file
52
lib/data/model/app/tab.g.dart
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'tab.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// TypeAdapterGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
class AppTabAdapter extends TypeAdapter<AppTab> {
|
||||||
|
@override
|
||||||
|
final typeId = 103;
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppTab read(BinaryReader reader) {
|
||||||
|
switch (reader.readByte()) {
|
||||||
|
case 0:
|
||||||
|
return AppTab.server;
|
||||||
|
case 1:
|
||||||
|
return AppTab.ssh;
|
||||||
|
case 2:
|
||||||
|
return AppTab.file;
|
||||||
|
case 3:
|
||||||
|
return AppTab.snippet;
|
||||||
|
default:
|
||||||
|
return AppTab.server;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void write(BinaryWriter writer, AppTab obj) {
|
||||||
|
switch (obj) {
|
||||||
|
case AppTab.server:
|
||||||
|
writer.writeByte(0);
|
||||||
|
case AppTab.ssh:
|
||||||
|
writer.writeByte(1);
|
||||||
|
case AppTab.file:
|
||||||
|
writer.writeByte(2);
|
||||||
|
case AppTab.snippet:
|
||||||
|
writer.writeByte(3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => typeId.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is AppTabAdapter &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
typeId == other.typeId;
|
||||||
|
}
|
||||||
@@ -24,14 +24,7 @@ final class PodmanImg implements ContainerImg {
|
|||||||
final int? size;
|
final int? size;
|
||||||
final int? containers;
|
final int? containers;
|
||||||
|
|
||||||
PodmanImg({
|
PodmanImg({this.repository, this.tag, this.id, this.created, this.size, this.containers});
|
||||||
this.repository,
|
|
||||||
this.tag,
|
|
||||||
this.id,
|
|
||||||
this.created,
|
|
||||||
this.size,
|
|
||||||
this.containers,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String? get sizeMB => size?.bytes2Str;
|
String? get sizeMB => size?.bytes2Str;
|
||||||
@@ -39,28 +32,27 @@ final class PodmanImg implements ContainerImg {
|
|||||||
@override
|
@override
|
||||||
int? get containersCount => containers;
|
int? get containersCount => containers;
|
||||||
|
|
||||||
factory PodmanImg.fromRawJson(String str) =>
|
factory PodmanImg.fromRawJson(String str) => PodmanImg.fromJson(json.decode(str));
|
||||||
PodmanImg.fromJson(json.decode(str));
|
|
||||||
|
|
||||||
String toRawJson() => json.encode(toJson());
|
String toRawJson() => json.encode(toJson());
|
||||||
|
|
||||||
factory PodmanImg.fromJson(Map<String, dynamic> json) => PodmanImg(
|
factory PodmanImg.fromJson(Map<String, dynamic> json) => PodmanImg(
|
||||||
repository: json['repository'],
|
repository: _asString(json['repository']),
|
||||||
tag: json['tag'],
|
tag: _asString(json['tag']),
|
||||||
id: json['Id'],
|
id: _asString(json['Id']),
|
||||||
created: json['Created'],
|
created: _asInt(json['Created']),
|
||||||
size: json['Size'],
|
size: _asInt(json['Size']),
|
||||||
containers: json['Containers'],
|
containers: _asInt(json['Containers']),
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'repository': repository,
|
'repository': repository,
|
||||||
'tag': tag,
|
'tag': tag,
|
||||||
'Id': id,
|
'Id': id,
|
||||||
'Created': created,
|
'Created': created,
|
||||||
'Size': size,
|
'Size': size,
|
||||||
'Containers': containers,
|
'Containers': containers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
final class DockerImg implements ContainerImg {
|
final class DockerImg implements ContainerImg {
|
||||||
@@ -87,11 +79,9 @@ final class DockerImg implements ContainerImg {
|
|||||||
String? get sizeMB => size;
|
String? get sizeMB => size;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int? get containersCount =>
|
int? get containersCount => containers == 'N/A' ? 0 : int.tryParse(containers);
|
||||||
containers == 'N/A' ? 0 : int.tryParse(containers);
|
|
||||||
|
|
||||||
factory DockerImg.fromRawJson(String str) =>
|
factory DockerImg.fromRawJson(String str) => DockerImg.fromJson(json.decode(str));
|
||||||
DockerImg.fromJson(json.decode(str));
|
|
||||||
|
|
||||||
String toRawJson() => json.encode(toJson());
|
String toRawJson() => json.encode(toJson());
|
||||||
|
|
||||||
@@ -121,11 +111,24 @@ final class DockerImg implements ContainerImg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'Containers': containers,
|
'Containers': containers,
|
||||||
'CreatedAt': createdAt,
|
'CreatedAt': createdAt,
|
||||||
'ID': id,
|
'ID': id,
|
||||||
'Repository': repository,
|
'Repository': repository,
|
||||||
'Size': size,
|
'Size': size,
|
||||||
'Tag': tag,
|
'Tag': tag,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _asString(dynamic val) {
|
||||||
|
if (val == null) return null;
|
||||||
|
if (val is String) return val;
|
||||||
|
return val.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _asInt(dynamic val) {
|
||||||
|
if (val == null) return null;
|
||||||
|
if (val is int) return val;
|
||||||
|
if (val is double) return val.toInt();
|
||||||
|
return int.tryParse(val.toString());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:fl_lib/fl_lib.dart';
|
import 'package:fl_lib/fl_lib.dart';
|
||||||
import 'package:server_box/core/extension/context/locale.dart';
|
import 'package:server_box/core/extension/context/locale.dart';
|
||||||
|
import 'package:server_box/data/model/container/status.dart';
|
||||||
import 'package:server_box/data/model/container/type.dart';
|
import 'package:server_box/data/model/container/type.dart';
|
||||||
import 'package:server_box/data/res/misc.dart';
|
import 'package:server_box/data/res/misc.dart';
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ sealed class ContainerPs {
|
|||||||
final String? image = null;
|
final String? image = null;
|
||||||
String? get name;
|
String? get name;
|
||||||
String? get cmd;
|
String? get cmd;
|
||||||
bool get running;
|
ContainerStatus get status;
|
||||||
|
|
||||||
String? cpu;
|
String? cpu;
|
||||||
String? mem;
|
String? mem;
|
||||||
@@ -42,15 +43,7 @@ final class PodmanPs implements ContainerPs {
|
|||||||
@override
|
@override
|
||||||
String? disk;
|
String? disk;
|
||||||
|
|
||||||
PodmanPs({
|
PodmanPs({this.command, this.created, this.exited, this.id, this.image, this.names, this.startedAt});
|
||||||
this.command,
|
|
||||||
this.created,
|
|
||||||
this.exited,
|
|
||||||
this.id,
|
|
||||||
this.image,
|
|
||||||
this.names,
|
|
||||||
this.startedAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String? get name => names?.firstOrNull;
|
String? get name => names?.firstOrNull;
|
||||||
@@ -59,7 +52,7 @@ final class PodmanPs implements ContainerPs {
|
|||||||
String? get cmd => command?.firstOrNull;
|
String? get cmd => command?.firstOrNull;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get running => exited != true;
|
ContainerStatus get status => ContainerStatus.fromPodmanExited(exited);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void parseStats(String s) {
|
void parseStats(String s) {
|
||||||
@@ -78,36 +71,29 @@ final class PodmanPs implements ContainerPs {
|
|||||||
disk = '${l10n.read} $diskOut / ${l10n.write} $diskIn';
|
disk = '${l10n.read} $diskOut / ${l10n.write} $diskIn';
|
||||||
}
|
}
|
||||||
|
|
||||||
factory PodmanPs.fromRawJson(String str) =>
|
factory PodmanPs.fromRawJson(String str) => PodmanPs.fromJson(json.decode(str));
|
||||||
PodmanPs.fromJson(json.decode(str));
|
|
||||||
|
|
||||||
String toRawJson() => json.encode(toJson());
|
String toRawJson() => json.encode(toJson());
|
||||||
|
|
||||||
factory PodmanPs.fromJson(Map<String, dynamic> json) => PodmanPs(
|
factory PodmanPs.fromJson(Map<String, dynamic> json) => PodmanPs(
|
||||||
command: json['Command'] == null
|
command: json['Command'] == null ? [] : List<String>.from(json['Command']!.map((x) => x)),
|
||||||
? []
|
created: json['Created'] == null ? null : DateTime.parse(json['Created']),
|
||||||
: List<String>.from(json['Command']!.map((x) => x)),
|
exited: json['Exited'],
|
||||||
created:
|
id: json['Id'],
|
||||||
json['Created'] == null ? null : DateTime.parse(json['Created']),
|
image: json['Image'],
|
||||||
exited: json['Exited'],
|
names: json['Names'] == null ? [] : List<String>.from(json['Names']!.map((x) => x)),
|
||||||
id: json['Id'],
|
startedAt: json['StartedAt'],
|
||||||
image: json['Image'],
|
);
|
||||||
names: json['Names'] == null
|
|
||||||
? []
|
|
||||||
: List<String>.from(json['Names']!.map((x) => x)),
|
|
||||||
startedAt: json['StartedAt'],
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'Command':
|
'Command': command == null ? [] : List<dynamic>.from(command!.map((x) => x)),
|
||||||
command == null ? [] : List<dynamic>.from(command!.map((x) => x)),
|
'Created': created?.toIso8601String(),
|
||||||
'Created': created?.toIso8601String(),
|
'Exited': exited,
|
||||||
'Exited': exited,
|
'Id': id,
|
||||||
'Id': id,
|
'Image': image,
|
||||||
'Image': image,
|
'Names': names == null ? [] : List<dynamic>.from(names!.map((x) => x)),
|
||||||
'Names': names == null ? [] : List<dynamic>.from(names!.map((x) => x)),
|
'StartedAt': startedAt,
|
||||||
'StartedAt': startedAt,
|
};
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final class DockerPs implements ContainerPs {
|
final class DockerPs implements ContainerPs {
|
||||||
@@ -127,12 +113,7 @@ final class DockerPs implements ContainerPs {
|
|||||||
@override
|
@override
|
||||||
String? disk;
|
String? disk;
|
||||||
|
|
||||||
DockerPs({
|
DockerPs({this.id, this.image, this.names, this.state});
|
||||||
this.id,
|
|
||||||
this.image,
|
|
||||||
this.names,
|
|
||||||
this.state,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String? get name => names;
|
String? get name => names;
|
||||||
@@ -141,10 +122,7 @@ final class DockerPs implements ContainerPs {
|
|||||||
String? get cmd => null;
|
String? get cmd => null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get running {
|
ContainerStatus get status => ContainerStatus.fromDockerState(state);
|
||||||
if (state?.contains('Exited') == true) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void parseStats(String s) {
|
void parseStats(String s) {
|
||||||
@@ -159,11 +137,6 @@ final class DockerPs implements ContainerPs {
|
|||||||
/// a049d689e7a1 aria2-pro p3terx/aria2-pro Up 3 weeks
|
/// a049d689e7a1 aria2-pro p3terx/aria2-pro Up 3 weeks
|
||||||
factory DockerPs.parse(String raw) {
|
factory DockerPs.parse(String raw) {
|
||||||
final parts = raw.split(Miscs.multiBlankreg);
|
final parts = raw.split(Miscs.multiBlankreg);
|
||||||
return DockerPs(
|
return DockerPs(id: parts[0], state: parts[1], names: parts[2], image: parts[3].trim());
|
||||||
id: parts[0],
|
|
||||||
state: parts[1],
|
|
||||||
names: parts[2],
|
|
||||||
image: parts[3].trim(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user