Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b56e033773 |
23
.github/workflows/analysis.yml
vendored
@@ -17,30 +17,17 @@ 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'
|
channel: 'stable' # or: 'beta', 'dev' or 'master'
|
||||||
cache: true
|
|
||||||
cache-key: 'flutter-:os:-:channel:-:version:-:arch:-:hash:'
|
|
||||||
|
|
||||||
- name: Cache pub dependencies
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
${{ env.PUB_CACHE }}
|
|
||||||
~/.pub-cache
|
|
||||||
.dart_tool/package_config.json
|
|
||||||
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}-${{ hashFiles('**/pubspec.yaml') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}-
|
|
||||||
${{ runner.os }}-pub-
|
|
||||||
|
|
||||||
- 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
@@ -1,66 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
27
.github/workflows/release.yml
vendored
@@ -1,7 +1,6 @@
|
|||||||
name: Flutter Release
|
name: Flutter Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
@@ -9,11 +8,6 @@ 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
|
||||||
@@ -24,12 +18,12 @@ jobs:
|
|||||||
- name: Install Flutter
|
- name: Install Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: 'stable'
|
||||||
flutter-version: "3.35.3"
|
flutter-version: '3.24.1'
|
||||||
- uses: actions/setup-java@v4
|
- uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: "zulu"
|
distribution: 'zulu'
|
||||||
java-version: "17"
|
java-version: '17'
|
||||||
- name: Fetch secrets
|
- name: Fetch secrets
|
||||||
run: |
|
run: |
|
||||||
curl -u ${{ secrets.BASIC_AUTH }} -o android/app/app.key ${{ secrets.URL_PREFIX }}app.key
|
curl -u ${{ secrets.BASIC_AUTH }} -o android/app/app.key ${{ secrets.URL_PREFIX }}app.key
|
||||||
@@ -59,13 +53,6 @@ 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
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt update
|
|
||||||
# Basic
|
|
||||||
sudo apt install -y clang cmake ninja-build pkg-config libgtk-3-dev mesa-utils libvulkan-dev desktop-file-utils wget
|
|
||||||
# App Specific
|
|
||||||
sudo apt install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libunwind-dev libsecret-1-dev
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
dart run fl_build -p linux
|
dart run fl_build -p linux
|
||||||
@@ -103,12 +90,16 @@ 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.22.2'
|
||||||
# - name: Build
|
# - name: Build
|
||||||
# run: dart run fl_build -p ios
|
# run: dart run fl_build -p ios,mac
|
||||||
# - 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 }}
|
||||||
|
|||||||
2
.gitignore
vendored
@@ -46,7 +46,6 @@ app.*.map.json
|
|||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
/android/app/fjy.androidstudio.key
|
/android/app/fjy.androidstudio.key
|
||||||
/android/app/app.key
|
|
||||||
/release
|
/release
|
||||||
test.dart
|
test.dart
|
||||||
|
|
||||||
@@ -65,4 +64,3 @@ untranlated.json
|
|||||||
.vscode/settings.json
|
.vscode/settings.json
|
||||||
more_build_data.json
|
more_build_data.json
|
||||||
trans.txt
|
trans.txt
|
||||||
android/app/.cxx
|
|
||||||
|
|||||||
95
CLAUDE.md
@@ -1,95 +0,0 @@
|
|||||||
# 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
|
|
||||||
35
README.md
@@ -6,17 +6,16 @@ English | [简体中文](README_zh.md)
|
|||||||
<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-GPLv3-yellow">
|
||||||
<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 provides charts to display Linux, Unix and Windows server status and tools to manage servers.
|
A Flutter project which provide charts to display <a href="../../issues/43">Linux</a> server status and tools to manage server.
|
||||||
<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>
|
||||||
|
|
||||||
## 🏙️ Screenshots
|
|
||||||
|
|
||||||
|
## 🏙️ Screenshots
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td><img width="200px" src="https://cdn.lpkt.cn/serverbox/screenshot/1.jpg"></td>
|
<td><img width="200px" src="https://cdn.lpkt.cn/serverbox/screenshot/1.jpg"></td>
|
||||||
@@ -26,26 +25,27 @@ Especially thanks to <a href="https://github.com/TerminalStudio/dartssh2">dartss
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
## 📥 Installation
|
|
||||||
|
|
||||||
|Platform| From|
|
## 📥 Install
|
||||||
|--|--|
|
|
||||||
| iOS / macOS | [AppStore](https://apps.apple.com/app/id1586449703) |
|
Platform | From
|
||||||
| Android | [GitHub](https://github.com/lollipopkit/flutter_server_box/releases) / [CDN](https://cdn.lpkt.cn/serverbox/pkg/?sort=time&order=desc&layout=grid) / [F-Droid](https://f-droid.org/packages/tech.lolli.toolbox) / [OpenAPK](https://www.openapk.net/serverbox/tech.lolli.toolbox/) |
|
--- | ---
|
||||||
| Linux / Windows | [GitHub](https://github.com/lollipopkit/flutter_server_box/releases) / [CDN](https://cdn.lpkt.cn/serverbox/pkg/?sort=time&order=desc&layout=grid) |
|
iOS / macOS | [AppStore](https://apps.apple.com/app/id1586449703)
|
||||||
|
Android | [GitHub](https://github.com/lollipopkit/flutter_server_box/releases) / [CDN](https://cdn.lolli.tech/serverbox/?sort=time&order=desc&layout=grid) / [F-Droid](https://f-droid.org/packages/tech.lolli.toolbox) / [OpenAPK](https://www.openapk.net/serverbox/tech.lolli.toolbox/)
|
||||||
|
Linux / Windows | [GitHub](https://github.com/lollipopkit/flutter_server_box/releases) / [CDN](https://cdn.lolli.tech/serverbox/?sort=time&order=desc&layout=grid)
|
||||||
|
|
||||||
Please only download pkgs from the source that **you trust**!
|
Please only download pkgs from the source that **you trust**!
|
||||||
|
|
||||||
## 🔖 Features
|
|
||||||
|
|
||||||
- `Status chart` (CPU, Sensors, GPU...), `SSH` Term, `SFTP`, `Docker & Process & Systemd`, `S.M.A.R.T`...
|
## 🔖 Feature
|
||||||
|
- `Status chart` (CPU, Sensors, GPU...), `SSH` Term, `SFTP`, `Docker & Process & Systemd`...
|
||||||
- Platform specific: `Bio auth`、`Msg push`、`Home widget`、`watchOS App`...
|
- Platform specific: `Bio auth`、`Msg push`、`Home widget`、`watchOS App`...
|
||||||
- English, 简体中文; Deutsch [@its-tom](https://github.com/its-tom), 繁體中文 [@kalashnikov](https://github.com/kalashnikov), Indonesian [@azkadev](https://github.com/azkadev), Français [@FrancXPT](https://github.com/FrancXPT), Dutch [@QazCetelic](https://github.com/QazCetelic), Türkçe [@mikropsoft](https://github.com/mikropsoft), Українська мова [@CakesTwix](https://github.com/CakesTwix); Español, Русский язык, Português, 日本語 (Generated by GPT)
|
- English, 简体中文; Deutsch [@its-tom](https://github.com/its-tom), 繁體中文 [@kalashnikov](https://github.com/kalashnikov), Indonesian [@azkadev](https://github.com/azkadev), Français [@FrancXPT](https://github.com/FrancXPT), Dutch [@QazCetelic](https://github.com/QazCetelic), Türkçe [@mikropsoft](https://github.com/mikropsoft), Українська мова [@CakesTwix](https://github.com/CakesTwix); Español, Русский язык, Português, 日本語 (Generated by GPT)
|
||||||
|
|
||||||
|
|
||||||
## 🆘 Help
|
## 🆘 Help
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="https://qm.qq.com/q/daCGa7eShG"><img alt="qq" src="https://img.shields.io/badge/QQ-Group-pink"></a>
|
|
||||||
<a href="https://t.me/lpktg"><img alt="donate" src="https://img.shields.io/badge/Telegram-lpktg-green"></a>
|
<a href="https://t.me/lpktg"><img alt="donate" src="https://img.shields.io/badge/Telegram-lpktg-green"></a>
|
||||||
<a href="https://discord.gg/SsVNbRhK7w"><img alt="discord" src="https://img.shields.io/badge/Discord-lpkt-purple"></a>
|
<a href="https://discord.gg/SsVNbRhK7w"><img alt="discord" src="https://img.shields.io/badge/Discord-lpkt-purple"></a>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,35 +54,30 @@ Please only download pkgs from the source that **you trust**!
|
|||||||
- **Common issues** can be found in [app wiki](https://github.com/lollipopkit/flutter_server_box/wiki).
|
- **Common issues** can be found in [app wiki](https://github.com/lollipopkit/flutter_server_box/wiki).
|
||||||
|
|
||||||
Before you open an issue, please read the following:
|
Before you open an issue, please read the following:
|
||||||
|
|
||||||
1. Paste the **entire log** (click the top right of the home page) in the issue template.
|
1. Paste the **entire log** (click the top right of the home page) in the issue template.
|
||||||
2. Make sure whether the issue is caused by ServerBox app.
|
2. Make sure whether the issue is caused by ServerBox app.
|
||||||
3. Welcome all valid and positive feedback, subjective feedback (such as you think other UI is better) may not be accepted.
|
3. Welcome all valid and positive feedback, subjective feedback (such as you think other UI is better) may not be accepted.
|
||||||
|
|
||||||
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).
|
||||||
|
|
||||||
## 🧱 Contributions
|
|
||||||
|
|
||||||
|
## 🧱 Contribution
|
||||||
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.
|
||||||
2. Clone this repo, run `flutter run` to start the app.
|
2. Clone this repo, run `flutter run` to start the app.
|
||||||
3. Run `dart run fl_build -p PLATFORM` to build the app.
|
3. Run `dart run fl_build -p PLATFORM` to build the app.
|
||||||
|
|
||||||
### Translation
|
### Translation
|
||||||
|
|
||||||
- [Guide](https://blog.lpkt.cn/posts/faq/) can be found in my blog.
|
- [Guide](https://blog.lpkt.cn/posts/faq/) can be found in my blog.
|
||||||
- We need your help! Just feel free to open a PR.
|
- We need your help! Just feel free to open a PR.
|
||||||
|
|
||||||
## 💡 My other apps
|
|
||||||
|
|
||||||
|
## 💡 My other apps
|
||||||
- [GPT Box](https://github.com/lollipopkit/flutter_gpt_box) - A third-party GPT Client for OpenAI API on all platforms.
|
- [GPT Box](https://github.com/lollipopkit/flutter_gpt_box) - A third-party GPT Client for OpenAI API on all platforms.
|
||||||
- [More](https://github.com/lollipopkit) - Tools & etc.
|
- [More](https://github.com/lollipopkit) - Tools & etc.
|
||||||
|
|
||||||
## 📝 License
|
|
||||||
|
|
||||||
|
## 📝 License
|
||||||
`GPL v3 lollipopkit`
|
`GPL v3 lollipopkit`
|
||||||
|
|||||||
34
README_zh.md
@@ -6,17 +6,16 @@
|
|||||||
<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/证书-GPLv3-yellow">
|
||||||
<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 开发的 Linux, Unix, Windows 服务器工具箱,提供服务器状态图表和管理工具。
|
使用 Flutter 开发的 <a href="../../issues/43">Linux</a> 服务器工具箱,提供服务器状态图表和管理工具。
|
||||||
<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>
|
||||||
|
|
||||||
## 🏙️ 截屏
|
|
||||||
|
|
||||||
|
## 🏙️ 截屏
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td><img width="200px" src="https://cdn.lpkt.cn/serverbox/screenshot/1.jpg"></td>
|
<td><img width="200px" src="https://cdn.lpkt.cn/serverbox/screenshot/1.jpg"></td>
|
||||||
@@ -26,19 +25,20 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|
||||||
## 📥 安装
|
## 📥 安装
|
||||||
|
|
||||||
平台|下载
|
平台 | 下载
|
||||||
--|--
|
--- | ---
|
||||||
iOS / macOS | [AppStore](https://apps.apple.com/app/id1586449703)
|
iOS / macOS | [AppStore](https://apps.apple.com/app/id1586449703)
|
||||||
Android | [GitHub](https://github.com/lollipopkit/flutter_server_box/releases) / [CDN](https://cdn.lpkt.cn/serverbox/pkg/?sort=time&order=desc&layout=grid) / [F-Droid](https://f-droid.org/packages/tech.lolli.toolbox) / [OpenAPK](https://www.openapk.net/serverbox/tech.lolli.toolbox/)
|
Android | [GitHub](https://github.com/lollipopkit/flutter_server_box/releases) / [CDN](https://cdn.lolli.tech/serverbox/?sort=time&order=desc&layout=grid) / [F-Droid](https://f-droid.org/packages/tech.lolli.toolbox) / [OpenAPK](https://www.openapk.net/serverbox/tech.lolli.toolbox/)
|
||||||
Linux / Windows | [GitHub](https://github.com/lollipopkit/flutter_server_box/releases) / [CDN](https://cdn.lpkt.cn/serverbox/pkg/?sort=time&order=desc&layout=grid)
|
Linux / Windows | [GitHub](https://github.com/lollipopkit/flutter_server_box/releases) / [CDN](https://cdn.lolli.tech/serverbox/?sort=time&order=desc&layout=grid)
|
||||||
|
|
||||||
请从 **信任** 的来源下载!
|
请从 **信任** 的来源下载!
|
||||||
|
|
||||||
## 🔖 特点
|
|
||||||
|
|
||||||
- `状态图表`(CPU、传感器、GPU 等), `SSH` 终端, `SFTP`, `Docker & 进程 & Systemd` 管理,`S.M.A.R.T`...
|
## 🔖 特点
|
||||||
|
- `状态图表`(CPU、传感器、GPU 等), `SSH` 终端, `SFTP`, `Docker & 进程 & Systemd` 管理...
|
||||||
- 特殊支持:`生物认证`、`推送`、`桌面小部件`、`watchOS App`、`跟随系统颜色`...
|
- 特殊支持:`生物认证`、`推送`、`桌面小部件`、`watchOS App`、`跟随系统颜色`...
|
||||||
- 本地化
|
- 本地化
|
||||||
- English, 简体中文
|
- English, 简体中文
|
||||||
@@ -46,10 +46,10 @@ Linux / Windows | [GitHub](https://github.com/lollipopkit/flutter_server_box/rel
|
|||||||
- Deutsch [@its-tom](https://github.com/its-tom), 繁體中文 [@kalashnikov](https://github.com/kalashnikov), Indonesian [@azkadev](https://github.com/azkadev), Français [@FrancXPT](https://github.com/FrancXPT), Dutch [@QazCetelic](https://github.com/QazCetelic), Türkçe [@mikropsoft](https://github.com/mikropsoft), Українська мова [@CakesTwix](https://github.com/CakesTwix);
|
- Deutsch [@its-tom](https://github.com/its-tom), 繁體中文 [@kalashnikov](https://github.com/kalashnikov), Indonesian [@azkadev](https://github.com/azkadev), Français [@FrancXPT](https://github.com/FrancXPT), Dutch [@QazCetelic](https://github.com/QazCetelic), Türkçe [@mikropsoft](https://github.com/mikropsoft), Українська мова [@CakesTwix](https://github.com/CakesTwix);
|
||||||
- 感谢贡献者们!
|
- 感谢贡献者们!
|
||||||
|
|
||||||
|
|
||||||
## 🆘 帮助
|
## 🆘 帮助
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="https://qm.qq.com/q/daCGa7eShG"><img alt="qq" src="https://img.shields.io/badge/QQ-群-pink"></a>
|
|
||||||
<a href="https://t.me/lpktg"><img alt="donate" src="https://img.shields.io/badge/Telegram-lpktg-green"></a>
|
<a href="https://t.me/lpktg"><img alt="donate" src="https://img.shields.io/badge/Telegram-lpktg-green"></a>
|
||||||
<a href="https://discord.gg/SsVNbRhK7w"><img alt="discord" src="https://img.shields.io/badge/Discord-lpkt-purple"></a>
|
<a href="https://discord.gg/SsVNbRhK7w"><img alt="discord" src="https://img.shields.io/badge/Discord-lpkt-purple"></a>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,32 +58,26 @@ Linux / Windows | [GitHub](https://github.com/lollipopkit/flutter_server_box/rel
|
|||||||
- **常见问题** 可以在 [app wiki](https://github.com/lollipopkit/flutter_server_box/wiki/主页) 查看。
|
- **常见问题** 可以在 [app wiki](https://github.com/lollipopkit/flutter_server_box/wiki/主页) 查看。
|
||||||
|
|
||||||
反馈前须知:
|
反馈前须知:
|
||||||
|
|
||||||
1. 反馈问题请附带 log(点击首页右上角),并以 bug 模版提交。
|
1. 反馈问题请附带 log(点击首页右上角),并以 bug 模版提交。
|
||||||
2. 反馈问题前请检查是否是 serverbox 的问题。
|
2. 反馈问题前请检查是否是 serverbox 的问题。
|
||||||
3. 欢迎所有有效、正面的反馈,主观(比如你觉得其他UI更好看)的反馈不一定会接受
|
3. 欢迎所有有效、正面的反馈,主观(比如你觉得其他UI更好看)的反馈不一定会接受
|
||||||
|
|
||||||
## 🧱 贡献
|
|
||||||
|
|
||||||
|
## 🧱 贡献
|
||||||
任何正面的贡献都欢迎。
|
任何正面的贡献都欢迎。
|
||||||
|
|
||||||
如果我忘记在贡献者列表中添加你的名字,请在你打开的 issue 或 PR 中添加评论让我知道,我会尽快添加。
|
|
||||||
|
|
||||||
### 开发
|
### 开发
|
||||||
|
|
||||||
1. 安装 [Flutter](https://flutter.dev/docs/get-started/install)
|
1. 安装 [Flutter](https://flutter.dev/docs/get-started/install)
|
||||||
2. 克隆这个仓库, 运行 `flutter run` 启动应用
|
2. 克隆这个仓库, 运行 `flutter run` 启动应用
|
||||||
3. 运行 `dart run fl_build -p PLATFORM` 构建应用
|
3. 运行 `dart run fl_build -p PLATFORM` 构建应用
|
||||||
|
|
||||||
### 翻译
|
### 翻译
|
||||||
|
[指南](https://blog.lolli.tech/faq/) 可在我的博客中找到。
|
||||||
[指南](https://blog.lpkt.cn/faq/) 可在我的博客中找到。
|
|
||||||
|
|
||||||
## 💡 我的其它 Apps
|
## 💡 我的其它 Apps
|
||||||
|
|
||||||
- [GPT Box](https://github.com/lollipopkit/flutter_gpt_box) - 支持 OpenAI API 的 第三方全平台客户端。
|
- [GPT Box](https://github.com/lollipopkit/flutter_gpt_box) - 支持 OpenAI API 的 第三方全平台客户端。
|
||||||
- [更多](https://github.com/lollipopkit) - 工具 & etc.
|
- [更多](https://github.com/lollipopkit) - 工具 & etc.
|
||||||
|
|
||||||
## 📝 协议
|
|
||||||
|
|
||||||
|
## 📝 协议
|
||||||
`GPL v3 lollipopkit`
|
`GPL v3 lollipopkit`
|
||||||
|
|||||||
@@ -11,13 +11,11 @@ include: package:flutter_lints/flutter.yaml
|
|||||||
|
|
||||||
analyzer:
|
analyzer:
|
||||||
exclude:
|
exclude:
|
||||||
- "**/*.g.dart"
|
- '**/*.g.dart'
|
||||||
language:
|
language:
|
||||||
# strict-casts: true
|
# strict-casts: true
|
||||||
# strict-inference: true
|
# strict-inference: true
|
||||||
# strict-raw-types: true
|
# strict-raw-types: true
|
||||||
errors:
|
|
||||||
invalid_annotation_target: ignore
|
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
# The lint rules applied to this project can be customized in the
|
# The lint rules applied to this project can be customized in the
|
||||||
@@ -43,9 +41,8 @@ linter:
|
|||||||
annotate_overrides: true
|
annotate_overrides: true
|
||||||
avoid_empty_else: true
|
avoid_empty_else: true
|
||||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
avoid_return_types_on_setters: true
|
avoid_return_types_on_setters: true
|
||||||
directives_ordering: true # Enable sorting of imports
|
|
||||||
|
|
||||||
# Additional information about this file can be found at
|
# Additional information about this file can be found at
|
||||||
# https://dart.dev/guides/language/analysis-options
|
# https://dart.dev/guides/language/analysis-options
|
||||||
|
|||||||
@@ -85,20 +85,13 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
debug {
|
debug {
|
||||||
// No applicationIdSuffix or resValue here
|
applicationIdSuffix '.debug'
|
||||||
}
|
}
|
||||||
|
|
||||||
profile {
|
profile {
|
||||||
// No applicationIdSuffix or resValue here
|
applicationIdSuffix '.debug'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependenciesInfo {
|
|
||||||
// Disables dependency metadata when building APKs.
|
|
||||||
includeInApk = false
|
|
||||||
// Disables dependency metadata when building Android App Bundles.
|
|
||||||
includeInBundle = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
flutter {
|
flutter {
|
||||||
@@ -113,7 +106,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 * 100 + abiVersionCode
|
output.versionCodeOverride = variant.versionCode * 10 + abiVersionCode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,10 @@
|
|||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="@string/app_name"
|
android:label="ServerBox"
|
||||||
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">
|
||||||
@@ -24,7 +23,7 @@
|
|||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|locale|layoutDirection|fontScale|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
@@ -46,15 +45,6 @@
|
|||||||
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,319 +2,87 @@ 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 org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.io.File
|
|
||||||
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) {
|
|
||||||
Log.e("ForegroundService", message, error)
|
|
||||||
try {
|
|
||||||
val logFile = File(getExternalFilesDir(null), "server_box.log")
|
|
||||||
val timestamp = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date())
|
|
||||||
val logMessage = "$timestamp [ForegroundService] ERROR: $message\n${error?.stackTraceToString() ?: ""}\n"
|
|
||||||
logFile.appendText(logMessage)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("ForegroundService", "Failed to write log", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.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 {
|
when (intent?.action) {
|
||||||
// Check notification permission for Android 13+
|
"ACTION_STOP_FOREGROUND" -> {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
stopForegroundService()
|
||||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
|
||||||
this, android.Manifest.permission.POST_NOTIFICATIONS
|
|
||||||
) != android.content.pm.PackageManager.PERMISSION_GRANTED
|
|
||||||
) {
|
|
||||||
Log.w("ForegroundService", "Notification permission denied. Stopping service gracefully.")
|
|
||||||
// Don't call stopForegroundService() here as we haven't started foreground yet
|
|
||||||
stopSelf()
|
|
||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
else -> {
|
||||||
if (intent == null) {
|
val notification = createNotification()
|
||||||
Log.w("ForegroundService", "onStartCommand called with null intent")
|
startForeground(1, notification)
|
||||||
// Don't call stopForegroundService() here as we haven't started foreground yet
|
return START_STICKY
|
||||||
stopSelf()
|
|
||||||
return START_NOT_STICKY
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val action = intent.action
|
|
||||||
Log.d("ForegroundService", "onStartCommand action=$action")
|
|
||||||
|
|
||||||
return when (action) {
|
|
||||||
ACTION_STOP_FOREGROUND -> {
|
|
||||||
// Notify Flutter to stop all connections before stopping service
|
|
||||||
val stopAllIntent = Intent("tech.lolli.toolbox.STOP_ALL_CONNECTIONS")
|
|
||||||
sendBroadcast(stopAllIntent)
|
|
||||||
clearAll()
|
|
||||||
stopForegroundService()
|
|
||||||
START_NOT_STICKY
|
|
||||||
}
|
|
||||||
ACTION_UPDATE_SESSIONS -> {
|
|
||||||
val payload = intent.getStringExtra("payload") ?: "{}"
|
|
||||||
handleUpdateSessions(payload)
|
|
||||||
START_STICKY
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
// Default bring up foreground with placeholder
|
|
||||||
ensureForeground(createMergedNotification(0, emptyList(), emptyList()))
|
|
||||||
START_STICKY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logError("Error in onStartCommand", e)
|
|
||||||
stopSelf()
|
|
||||||
return START_NOT_STICKY
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? {
|
override fun onBind(intent: Intent): IBinder? {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
try {
|
val serviceChannel = NotificationChannel(
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
chanId,
|
||||||
if (manager == null) {
|
chanId,
|
||||||
Log.e("ForegroundService", "Failed to get NotificationManager")
|
NotificationManager.IMPORTANCE_DEFAULT
|
||||||
return
|
)
|
||||||
}
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
val serviceChannel = NotificationChannel(
|
manager.createNotificationChannel(serviceChannel)
|
||||||
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) {
|
private fun createNotification(): 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
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isFgStarted) {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
|
||||||
} else {
|
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
|
||||||
}
|
|
||||||
isFgStarted = true
|
|
||||||
Log.d("ForegroundService", "Foreground service started successfully")
|
|
||||||
} else {
|
|
||||||
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) {
|
|
||||||
logError("Security exception when starting foreground service (likely missing permission)", e)
|
|
||||||
stopSelf()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logError("Failed to start/update foreground", e)
|
|
||||||
// Don't stop the service for other exceptions, just log them
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private fun createMergedNotification(count: Int, lines: List<String>, sessions: List<SessionItem>): Notification {
|
|
||||||
val notificationIntent = Intent(this, MainActivity::class.java)
|
val notificationIntent = Intent(this, MainActivity::class.java)
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE
|
this,
|
||||||
|
0,
|
||||||
|
notificationIntent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
)
|
)
|
||||||
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) {
|
val deleteIntent = Intent(this, ForegroundService::class.java).apply {
|
||||||
|
action = "ACTION_STOP_FOREGROUND"
|
||||||
|
}
|
||||||
|
val deletePendingIntent = PendingIntent.getService(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
deleteIntent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
Notification.Builder(this, chanId)
|
Notification.Builder(this, chanId)
|
||||||
|
.setContentTitle("Server Box")
|
||||||
|
.setContentText("Open the app")
|
||||||
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.addAction(android.R.drawable.ic_delete, "Stop", deletePendingIntent)
|
||||||
|
.build()
|
||||||
} else {
|
} else {
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
Notification.Builder(this)
|
Notification.Builder(this)
|
||||||
|
.setContentTitle("Server Box")
|
||||||
|
.setContentText("Open the app")
|
||||||
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.addAction(android.R.drawable.ic_delete, "Stop", deletePendingIntent)
|
||||||
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
fun stopForegroundService() {
|
||||||
val nm = getSystemService(NotificationManager::class.java)
|
stopForeground(true)
|
||||||
if (nm == null) {
|
|
||||||
logError("NotificationManager null")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val sessions = mutableListOf<SessionItem>()
|
|
||||||
try {
|
|
||||||
val obj = JSONObject(payload)
|
|
||||||
val arr: JSONArray = obj.optJSONArray("sessions") ?: JSONArray()
|
|
||||||
for (i in 0 until arr.length()) {
|
|
||||||
val s = arr.optJSONObject(i) ?: continue
|
|
||||||
val id = s.optString("id")
|
|
||||||
val title = s.optString("title")
|
|
||||||
val sub = s.optString("subtitle")
|
|
||||||
val whenMs = s.optLong("startTimeMs", System.currentTimeMillis())
|
|
||||||
val status = s.optString("status", "connected")
|
|
||||||
if (id.isNotEmpty()) {
|
|
||||||
sessions.add(SessionItem(id, title, sub, whenMs, status))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logError("Failed to parse payload", e)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear if empty
|
|
||||||
if (sessions.isEmpty()) {
|
|
||||||
clearAll()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel any existing individual notifications (we only show merged notification now)
|
|
||||||
val toCancel = postedIds.toSet()
|
|
||||||
toCancel.forEach { nm.cancel(it) }
|
|
||||||
postedIds.clear()
|
|
||||||
notificationIdMap.clear()
|
|
||||||
|
|
||||||
// Create merged notification content
|
|
||||||
val summaryLines = sessions.map { "${it.title}: ${it.status}" }
|
|
||||||
val mergedNotification = createMergedNotification(sessions.size, summaryLines, sessions)
|
|
||||||
ensureForeground(mergedNotification)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearAll() {
|
|
||||||
val nm = getSystemService(NotificationManager::class.java)
|
|
||||||
nm?.cancel(NOTIFICATION_ID)
|
|
||||||
postedIds.forEach { id -> nm?.cancel(id) }
|
|
||||||
postedIds.clear()
|
|
||||||
isFgStarted = false
|
|
||||||
}
|
|
||||||
|
|
||||||
data class SessionItem(
|
|
||||||
val id: String,
|
|
||||||
val title: String,
|
|
||||||
val subtitle: String,
|
|
||||||
val startWhen: Long,
|
|
||||||
val status: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun stopForegroundService() {
|
|
||||||
try {
|
|
||||||
if (isFgStarted) {
|
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
|
||||||
isFgStarted = false
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logError("Error stopping foreground", e)
|
|
||||||
}
|
|
||||||
stopSelf()
|
stopSelf()
|
||||||
Log.d("ForegroundService", "ForegroundService stopped")
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
Log.d("ForegroundService", "Service onDestroy")
|
|
||||||
isRunning = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,57 +4,31 @@ 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
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import android.appwidget.AppWidgetManager
|
|
||||||
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
|
||||||
|
|
||||||
channel = MethodChannel(binaryMessenger, "tech.lolli.toolbox/main_chan")
|
MethodChannel(binaryMessenger, "tech.lolli.toolbox/app_retain").apply {
|
||||||
channel.setMethodCallHandler { method, result ->
|
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 {
|
reqPerm()
|
||||||
reqPerm()
|
val serviceIntent = Intent(this@MainActivity, ForegroundService::class.java)
|
||||||
if (!notificationsAllowed()) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
// Don't start foreground service without notification permission on API 33+
|
startForegroundService(serviceIntent)
|
||||||
result.error("NOTIFICATION_PERMISSION_DENIED", "Notification permission not granted", null)
|
} else {
|
||||||
return@setMethodCallHandler
|
startService(serviceIntent)
|
||||||
}
|
|
||||||
val serviceIntent = Intent(this@MainActivity, ForegroundService::class.java)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
startForegroundService(serviceIntent)
|
|
||||||
} else {
|
|
||||||
startService(serviceIntent)
|
|
||||||
}
|
|
||||||
result.success(null)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Log error but don't crash
|
|
||||||
android.util.Log.e("MainActivity", "Failed to start service: ${e.message}")
|
|
||||||
result.error("SERVICE_ERROR", e.message, null)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"stopService" -> {
|
"stopService" -> {
|
||||||
@@ -62,144 +36,23 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
stopService(serviceIntent)
|
stopService(serviceIntent)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
"updateHomeWidget" -> {
|
|
||||||
val intent = Intent(this@MainActivity, HomeWidget::class.java)
|
|
||||||
intent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
|
|
||||||
sendBroadcast(intent)
|
|
||||||
result.success(null)
|
|
||||||
}
|
|
||||||
"updateSessions" -> {
|
|
||||||
try {
|
|
||||||
if (!notificationsAllowed()) {
|
|
||||||
// Avoid starting/continuing service updates when notifications are blocked
|
|
||||||
result.error("NOTIFICATION_PERMISSION_DENIED", "Notification permission not granted", null)
|
|
||||||
return@setMethodCallHandler
|
|
||||||
}
|
|
||||||
val serviceIntent = Intent(this@MainActivity, ForegroundService::class.java)
|
|
||||||
serviceIntent.action = ACTION_UPDATE_SESSIONS
|
|
||||||
serviceIntent.putExtra("payload", method.arguments as String)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
startForegroundService(serviceIntent)
|
|
||||||
} else {
|
|
||||||
startService(serviceIntent)
|
|
||||||
}
|
|
||||||
result.success(null)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.e("MainActivity", "Failed to update sessions: ${e.message}")
|
|
||||||
result.error("SERVICE_ERROR", e.message, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
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() {
|
private fun reqPerm() {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||||
try {
|
!= PackageManager.PERMISSION_GRANTED) {
|
||||||
// Check if we already have the permission to avoid unnecessary prompts
|
ActivityCompat.requestPermissions(
|
||||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
this,
|
||||||
!= PackageManager.PERMISSION_GRANTED) {
|
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||||
// Check if we should show rationale
|
123,
|
||||||
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 setupStopAllReceiver() {
|
|
||||||
stopAllReceiver = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
|
||||||
if (intent?.action == ACTION_STOP_ALL_CONNECTIONS && ::channel.isInitialized) {
|
|
||||||
try {
|
|
||||||
channel.invokeMethod("stopAllConnections", null)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.e("MainActivity", "Failed to invoke stopAllConnections: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val filter = IntentFilter(ACTION_STOP_ALL_CONNECTIONS)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
ContextCompat.registerReceiver(this, stopAllReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
|
|
||||||
} else {
|
|
||||||
registerReceiver(stopAllReceiver, filter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRequestPermissionsResult(
|
|
||||||
requestCode: Int,
|
|
||||||
permissions: Array<out String>,
|
|
||||||
grantResults: IntArray
|
|
||||||
) {
|
|
||||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
|
||||||
if (requestCode == 123) {
|
|
||||||
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
|
||||||
android.util.Log.i("MainActivity", "Notification permission granted")
|
|
||||||
} else {
|
|
||||||
android.util.Log.w("MainActivity", "Notification permission denied")
|
|
||||||
// Optionally inform user about the limitation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
stopAllReceiver?.let {
|
|
||||||
try {
|
|
||||||
unregisterReceiver(it)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.e("MainActivity", "Failed to unregister receiver: ${e.message}")
|
|
||||||
}
|
|
||||||
stopAllReceiver = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,216 +6,109 @@ import android.appwidget.AppWidgetProvider
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.RemoteViews
|
import android.widget.RemoteViews
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
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.SocketTimeoutException
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
private fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) {
|
private fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) {
|
||||||
// Prevent concurrent updates for the same widget
|
val views = RemoteViews(context.packageName, R.layout.home_widget)
|
||||||
if (activeUpdates.putIfAbsent(appWidgetId, true) == true) {
|
val sp = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)
|
||||||
Log.d(TAG, "Widget $appWidgetId is already updating, skipping")
|
var url = sp.getString("widget_$appWidgetId", null)
|
||||||
return
|
if (url.isNullOrEmpty()) {
|
||||||
|
url = sp.getString("$appWidgetId", null)
|
||||||
|
}
|
||||||
|
if (url.isNullOrEmpty()) {
|
||||||
|
val gUrl = sp.getString("widget_*", null)
|
||||||
|
url = gUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
val views = RemoteViews(context.packageName, R.layout.home_widget)
|
val intentUpdate = Intent(context, HomeWidget::class.java)
|
||||||
val url = getWidgetUrl(context, appWidgetId)
|
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()) {
|
if (url.isNullOrEmpty()) {
|
||||||
Log.w(TAG, "URL not found for widget $appWidgetId")
|
views.setViewVisibility(R.id.widget_cpu_label, View.INVISIBLE)
|
||||||
showErrorState(views, appWidgetManager, appWidgetId, "Please configure the widget URL.")
|
views.setViewVisibility(R.id.widget_mem_label, View.INVISIBLE)
|
||||||
activeUpdates.remove(appWidgetId)
|
views.setViewVisibility(R.id.widget_disk_label, View.INVISIBLE)
|
||||||
|
views.setViewVisibility(R.id.widget_net_label, View.INVISIBLE)
|
||||||
|
views.setTextViewText(R.id.widget_name, "ID: $appWidgetId")
|
||||||
|
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||||
return
|
return
|
||||||
}
|
|
||||||
|
|
||||||
setupClickIntent(context, views, appWidgetId)
|
|
||||||
|
|
||||||
showLoadingState(views, appWidgetManager, appWidgetId)
|
|
||||||
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
withTimeoutOrNull(COROUTINE_TIMEOUT) {
|
|
||||||
try {
|
|
||||||
val serverData = fetchServerData(url)
|
|
||||||
if (serverData != null) {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
showSuccessState(views, appWidgetManager, appWidgetId, serverData)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
showErrorState(views, appWidgetManager, appWidgetId, "Invalid server data received.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error updating widget $appWidgetId: ${e.message}", e)
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
val errorMessage = when (e) {
|
|
||||||
is SocketTimeoutException -> "Connection timeout. Please check your network."
|
|
||||||
is IOException -> "Network error. Please check your connection."
|
|
||||||
is JSONException -> "Invalid data format received from server."
|
|
||||||
else -> "Failed to retrieve data: ${e.message}"
|
|
||||||
}
|
|
||||||
showErrorState(views, appWidgetManager, appWidgetId, errorMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} ?: run {
|
|
||||||
Log.w(TAG, "Widget update timed out for widget $appWidgetId")
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
showErrorState(views, appWidgetManager, appWidgetId, "Update timed out. Please try again.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
activeUpdates.remove(appWidgetId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getWidgetUrl(context: Context, appWidgetId: Int): String? {
|
|
||||||
val sp = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)
|
|
||||||
return sp.getString("widget_$appWidgetId", null)
|
|
||||||
?: sp.getString("$appWidgetId", null)
|
|
||||||
?: sp.getString("widget_*", null)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupClickIntent(context: Context, views: RemoteViews, appWidgetId: Int) {
|
|
||||||
val intentConfigure = Intent(context, WidgetConfigureActivity::class.java).apply {
|
|
||||||
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
|
||||||
}
|
|
||||||
|
|
||||||
val flag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
} else {
|
} else {
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT
|
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 pendingConfigure = PendingIntent.getActivity(context, appWidgetId, intentConfigure, flag)
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
views.setOnClickPendingIntent(R.id.widget_container, pendingConfigure)
|
try {
|
||||||
}
|
val jsonStr = URL(url).readText()
|
||||||
|
val jsonObject = JSONObject(jsonStr)
|
||||||
|
val data = jsonObject.getJSONObject("data")
|
||||||
|
val server = data.getString("name")
|
||||||
|
val cpu = data.getString("cpu")
|
||||||
|
val mem = data.getString("mem")
|
||||||
|
val disk = data.getString("disk")
|
||||||
|
val net = data.getString("net")
|
||||||
|
|
||||||
private suspend fun fetchServerData(url: String): ServerData? = withContext(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.Main) main@ {
|
||||||
var connection: HttpURLConnection? = null
|
// mem or disk is empty -> get status failed
|
||||||
try {
|
// (cpu | net) isEmpty -> data is not ready
|
||||||
connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
if (mem.isEmpty() || disk.isEmpty()) {
|
||||||
requestMethod = "GET"
|
return@main
|
||||||
connectTimeout = NETWORK_TIMEOUT.toInt()
|
}
|
||||||
readTimeout = NETWORK_TIMEOUT.toInt()
|
views.setTextViewText(R.id.widget_name, server)
|
||||||
setRequestProperty("User-Agent", "ServerBox-Widget/1.0")
|
|
||||||
setRequestProperty("Accept", "application/json")
|
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)
|
||||||
|
|
||||||
|
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("ServerBoxHomeWidget: ${e.localizedMessage}")
|
||||||
|
GlobalScope.launch(Dispatchers.Main) main@ {
|
||||||
|
views.setViewVisibility(R.id.widget_cpu_label, View.INVISIBLE)
|
||||||
|
views.setViewVisibility(R.id.widget_mem_label, View.INVISIBLE)
|
||||||
|
views.setViewVisibility(R.id.widget_disk_label, View.INVISIBLE)
|
||||||
|
views.setViewVisibility(R.id.widget_net_label, View.INVISIBLE)
|
||||||
|
views.setTextViewText(R.id.widget_name, "ID: $appWidgetId")
|
||||||
|
views.setTextViewText(R.id.widget_mem, e.localizedMessage)
|
||||||
|
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
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,204 +10,139 @@
|
|||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_name"
|
android:id="@+id/widget_name"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textColor="@color/widgetText"
|
android:textColor="@color/widgetText"
|
||||||
android:textSize="20sp"
|
android:textSize="23sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
android:ellipsize="end"
|
|
||||||
android:alpha="0"
|
|
||||||
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 -->
|
<RelativeLayout
|
||||||
<LinearLayout
|
android:id="@+id/widget_container_inner"
|
||||||
android:id="@+id/widget_content"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
android:gravity="center_vertical"
|
||||||
android:layout_below="@id/widget_name"
|
android:paddingTop="13dp">
|
||||||
android:layout_marginTop="8dp">
|
|
||||||
|
|
||||||
<RelativeLayout
|
<LinearLayout
|
||||||
android:id="@+id/widget_container_inner"
|
android:id="@+id/widget_cpu_label"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:animateLayoutChanges="true">
|
android:paddingBottom="2.7dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
<LinearLayout
|
<ImageView
|
||||||
android:id="@+id/widget_cpu_label"
|
android:layout_width="17dp"
|
||||||
|
android:layout_height="17dp"
|
||||||
|
android:src="@drawable/speed_24">
|
||||||
|
</ImageView>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_cpu"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginBottom="4dp"
|
android:layout_marginStart="11dp"
|
||||||
android:gravity="center_vertical"
|
android:singleLine="true"
|
||||||
android:orientation="horizontal"
|
android:ellipsize = "marquee"
|
||||||
android:alpha="0"
|
android:textColor="@color/widgetSummaryText"
|
||||||
android:animateLayoutChanges="true">
|
android:textSize="12.7sp"
|
||||||
|
tools:text="CPU" />
|
||||||
<ImageView
|
|
||||||
android:layout_width="16dp"
|
|
||||||
android:layout_height="16dp"
|
|
||||||
android:src="@drawable/speed_24"
|
|
||||||
android:layout_gravity="center_vertical"
|
|
||||||
android:contentDescription="CPU usage" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/widget_cpu"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:layout_marginStart="8dp"
|
|
||||||
android:singleLine="true"
|
|
||||||
android:ellipsize="end"
|
|
||||||
android:textColor="@color/widgetSummaryText"
|
|
||||||
android:textSize="12sp"
|
|
||||||
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_height="wrap_content"
|
||||||
|
android:paddingBottom="2.7dp"
|
||||||
|
android:layout_below="@id/widget_cpu_label"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="17dp"
|
||||||
|
android:layout_height="17dp"
|
||||||
|
android:src="@drawable/memory_24">
|
||||||
|
</ImageView>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_mem"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginBottom="4dp"
|
android:layout_marginStart="11dp"
|
||||||
android:layout_below="@id/widget_cpu_label"
|
android:maxLines="1"
|
||||||
android:gravity="center_vertical"
|
android:textColor="@color/widgetSummaryText"
|
||||||
android:orientation="horizontal"
|
android:textSize="12.7sp"
|
||||||
android:alpha="0"
|
tools:text="Mem" />
|
||||||
android:animateLayoutChanges="true">
|
|
||||||
|
|
||||||
<ImageView
|
</LinearLayout>
|
||||||
android:layout_width="16dp"
|
|
||||||
android:layout_height="16dp"
|
|
||||||
android:src="@drawable/memory_24"
|
|
||||||
android:layout_gravity="center_vertical"
|
|
||||||
android:contentDescription="Memory usage" />
|
|
||||||
|
|
||||||
<TextView
|
<LinearLayout
|
||||||
android:id="@+id/widget_mem"
|
android:id="@+id/widget_disk_label"
|
||||||
android:layout_width="0dp"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:paddingBottom="2.7dp"
|
||||||
android:layout_marginStart="8dp"
|
android:layout_below="@id/widget_mem_label"
|
||||||
android:maxLines="1"
|
android:gravity="center_vertical"
|
||||||
android:ellipsize="end"
|
android:orientation="horizontal">
|
||||||
android:textColor="@color/widgetSummaryText"
|
|
||||||
android:textSize="12sp"
|
|
||||||
tools:text="Memory: 4.2GB / 8GB" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
<ImageView
|
||||||
|
android:layout_width="17dp"
|
||||||
|
android:layout_height="17dp"
|
||||||
|
android:src="@drawable/storage_24">
|
||||||
|
</ImageView>
|
||||||
|
|
||||||
<LinearLayout
|
<TextView
|
||||||
android:id="@+id/widget_disk_label"
|
android:id="@+id/widget_disk"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginBottom="4dp"
|
android:layout_marginStart="11dp"
|
||||||
android:layout_below="@id/widget_mem_label"
|
android:maxLines="1"
|
||||||
android:gravity="center_vertical"
|
android:textColor="@color/widgetSummaryText"
|
||||||
android:orientation="horizontal"
|
android:textSize="12.7sp"
|
||||||
android:alpha="0"
|
tools:text="Disk" />
|
||||||
android:animateLayoutChanges="true">
|
|
||||||
|
|
||||||
<ImageView
|
</LinearLayout>
|
||||||
android:layout_width="16dp"
|
|
||||||
android:layout_height="16dp"
|
|
||||||
android:src="@drawable/storage_24"
|
|
||||||
android:layout_gravity="center_vertical"
|
|
||||||
android:contentDescription="Disk usage" />
|
|
||||||
|
|
||||||
<TextView
|
<LinearLayout
|
||||||
android:id="@+id/widget_disk"
|
android:id="@+id/widget_net_label"
|
||||||
android:layout_width="0dp"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_below="@id/widget_disk_label"
|
||||||
android:layout_marginStart="8dp"
|
android:gravity="center_vertical"
|
||||||
android:maxLines="1"
|
android:orientation="horizontal">
|
||||||
android:ellipsize="end"
|
|
||||||
android:textColor="@color/widgetSummaryText"
|
|
||||||
android:textSize="12sp"
|
|
||||||
tools:text="Disk: 125GB / 250GB" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
<ImageView
|
||||||
|
android:layout_width="17dp"
|
||||||
|
android:layout_height="17dp"
|
||||||
|
android:src="@drawable/net_24">
|
||||||
|
</ImageView>
|
||||||
|
|
||||||
<LinearLayout
|
<TextView
|
||||||
android:id="@+id/widget_net_label"
|
android:id="@+id/widget_net"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_below="@id/widget_disk_label"
|
android:layout_marginStart="11dp"
|
||||||
android:gravity="center_vertical"
|
android:maxLines="1"
|
||||||
android:orientation="horizontal"
|
android:textColor="@color/widgetSummaryText"
|
||||||
android:alpha="0"
|
android:textSize="12.7sp"
|
||||||
android:animateLayoutChanges="true">
|
tools:text="Net" />
|
||||||
|
|
||||||
<ImageView
|
</LinearLayout>
|
||||||
android:layout_width="16dp"
|
|
||||||
android:layout_height="16dp"
|
|
||||||
android:src="@drawable/net_24"
|
|
||||||
android:layout_gravity="center_vertical"
|
|
||||||
android:contentDescription="Network usage" />
|
|
||||||
|
|
||||||
<TextView
|
</RelativeLayout>
|
||||||
android:id="@+id/widget_net"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:layout_marginStart="8dp"
|
|
||||||
android:maxLines="1"
|
|
||||||
android:ellipsize="end"
|
|
||||||
android:textColor="@color/widgetSummaryText"
|
|
||||||
android:textSize="12sp"
|
|
||||||
tools:text="Network: 15MB/s ↓ 8MB/s ↑" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
</RelativeLayout>
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<!-- Error message display -->
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/error_message"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_below="@id/widget_name"
|
|
||||||
android:layout_marginTop="8dp"
|
|
||||||
android:textColor="@color/widgetSummaryText"
|
|
||||||
android:textSize="11sp"
|
|
||||||
android:visibility="gone"
|
|
||||||
android:alpha="0"
|
|
||||||
android:animateLayoutChanges="true"
|
|
||||||
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:layout_alignParentEnd="true"
|
android:maxLines="2"
|
||||||
android:maxLines="1"
|
|
||||||
android:textColor="@color/widgetSummaryText"
|
android:textColor="@color/widgetSummaryText"
|
||||||
android:textSize="10sp"
|
android:textSize="11sp"
|
||||||
android:alpha="0"
|
tools:text="UpdateTime" />
|
||||||
android:animateLayoutChanges="true"
|
|
||||||
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>
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<?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>
|
|
||||||
|
Before Width: | Height: | Size: 761 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 411 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 895 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="app_name">ServerBox</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<full-backup-content>
|
|
||||||
<exclude domain="sharedpref" path="FlutterSecureStorage"/>
|
|
||||||
</full-backup-content>
|
|
||||||
@@ -6,7 +6,6 @@
|
|||||||
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>
|
||||||
@@ -9,23 +9,6 @@ rootProject.buildDir = '../build'
|
|||||||
subprojects {
|
subprojects {
|
||||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
||||||
}
|
}
|
||||||
|
|
||||||
subprojects { subproject ->
|
|
||||||
// Only works on com.android.application(the main app module)
|
|
||||||
if (subproject.plugins.hasPlugin('com.android.application')) {
|
|
||||||
subproject.afterEvaluate {
|
|
||||||
android.buildTypes.matching { it.name == 'profile' }.all { buildType ->
|
|
||||||
buildType.applicationIdSuffix = ".profile"
|
|
||||||
buildTypes.profile.resValue 'string', 'app_name', 'SrvBxP'
|
|
||||||
}
|
|
||||||
android.buildTypes.matching { it.name == 'debug' }.all { buildType ->
|
|
||||||
buildType.applicationIdSuffix = ".debug"
|
|
||||||
buildTypes.debug.resValue 'string', 'app_name', 'SrvBxD'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
subprojects {
|
subprojects {
|
||||||
project.evaluationDependsOn(':app')
|
project.evaluationDependsOn(':app')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
org.gradle.jvmargs=-Xmx4G
|
org.gradle.jvmargs=-Xmx4G
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
android.defaults.buildfeatures.buildconfig=true
|
|
||||||
android.nonTransitiveRClass=false
|
|
||||||
android.nonFinalResIds=false
|
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip
|
||||||
|
distributionSha256Sum=6001aba9b2204d26fa25a5800bb9382cf3ee01ccb78fe77317b2872336eb2f80
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||||
id "com.android.application" version '8.6.0' apply false
|
id "com.android.application" version "7.4.2" apply false
|
||||||
id "org.jetbrains.kotlin.android" version "2.1.21" apply false
|
id "org.jetbrains.kotlin.android" version "1.8.10" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include ":app"
|
include ":app"
|
||||||
|
|||||||
6505
coverage/lcov.info
@@ -1,3 +1 @@
|
|||||||
description: This file stores settings for Dart & Flutter DevTools.
|
|
||||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
|
||||||
extensions:
|
extensions:
|
||||||
|
|||||||
@@ -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>13.0</string>
|
<string>12.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, '13.0'
|
# platform :ios, '12.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,14 +1,12 @@
|
|||||||
PODS:
|
PODS:
|
||||||
- app_links (6.4.1):
|
- app_links (0.0.2):
|
||||||
- Flutter
|
- Flutter
|
||||||
- camera_avfoundation (0.0.1):
|
- camera_avfoundation (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- file_picker (0.0.1):
|
- file_picker (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
- flutter_native_splash (2.4.3):
|
- flutter_native_splash (0.0.1):
|
||||||
- Flutter
|
|
||||||
- flutter_secure_storage (6.0.0):
|
|
||||||
- Flutter
|
- Flutter
|
||||||
- icloud_storage (0.0.1):
|
- icloud_storage (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
@@ -33,6 +31,8 @@ PODS:
|
|||||||
- Flutter
|
- Flutter
|
||||||
- watch_connectivity (0.0.1):
|
- watch_connectivity (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- webview_flutter_wkwebview (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- app_links (from `.symlinks/plugins/app_links/ios`)
|
- app_links (from `.symlinks/plugins/app_links/ios`)
|
||||||
@@ -40,7 +40,6 @@ 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`)
|
||||||
@@ -51,6 +50,7 @@ DEPENDENCIES:
|
|||||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||||
- watch_connectivity (from `.symlinks/plugins/watch_connectivity/ios`)
|
- watch_connectivity (from `.symlinks/plugins/watch_connectivity/ios`)
|
||||||
|
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
app_links:
|
app_links:
|
||||||
@@ -63,8 +63,6 @@ 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:
|
||||||
@@ -85,25 +83,27 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||||
watch_connectivity:
|
watch_connectivity:
|
||||||
:path: ".symlinks/plugins/watch_connectivity/ios"
|
:path: ".symlinks/plugins/watch_connectivity/ios"
|
||||||
|
webview_flutter_wkwebview:
|
||||||
|
:path: ".symlinks/plugins/webview_flutter_wkwebview/ios"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a
|
app_links: e7a6750a915a9e161c58d91bc610e8cd1d4d0ad0
|
||||||
camera_avfoundation: be3be85408cd4126f250386828e9b1dfa40ab436
|
camera_avfoundation: dd002b0330f4981e1bbcb46ae9b62829237459a4
|
||||||
file_picker: fb04e739ae6239a76ce1f571863a196a922c87d4
|
file_picker: c79185e70b9b45728cde2a8d8da454e0cb43f287
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||||
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
|
||||||
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
icloud_storage: d9ac7a33ced81df08ba7ea1bf3099cc0ee58f60a
|
||||||
icloud_storage: e55639f0c0d7cb2b0ba9c0b3d5968ccca9cd9aa2
|
local_auth_darwin: 66e40372f1c29f383a314c738c7446e2f7fdadc3
|
||||||
local_auth_darwin: d2e8c53ef0c4f43c646462e3415432c4dab3ae19
|
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
|
||||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
plain_notification_token: b36467dc91939a7b6754267c701bbaca14996ee1
|
||||||
plain_notification_token: 047876b9d80a5b93565ddcc13a487a7e7b906f7d
|
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
|
||||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||||
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
|
wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1
|
||||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
watch_connectivity: 715eb484685e05846eab74795348a44bb2809b82
|
||||||
watch_connectivity: 88e5bea25b473e66ef8d3f960954d154ed0356d6
|
webview_flutter_wkwebview: 2a23822e9039b7b1bc52e5add778e5d89ad488d1
|
||||||
|
|
||||||
PODFILE CHECKSUM: 5a0fb6438066e44ab2c77bd223668d351b8d8461
|
PODFILE CHECKSUM: ec6ef69056f066e8b21a3391082f23b5ad2d37f8
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.15.2
|
||||||
|
|||||||
@@ -9,10 +9,6 @@
|
|||||||
/* 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 */; };
|
||||||
@@ -40,8 +36,6 @@
|
|||||||
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 */
|
||||||
@@ -101,10 +95,6 @@
|
|||||||
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>"; };
|
||||||
@@ -166,26 +156,6 @@
|
|||||||
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 */
|
||||||
@@ -263,7 +233,6 @@
|
|||||||
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 */,
|
||||||
@@ -273,8 +242,6 @@
|
|||||||
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 */,
|
||||||
@@ -296,11 +263,8 @@
|
|||||||
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 */,
|
||||||
@@ -448,7 +412,6 @@
|
|||||||
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;
|
||||||
};
|
};
|
||||||
@@ -457,7 +420,6 @@
|
|||||||
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;
|
||||||
};
|
};
|
||||||
@@ -554,8 +516,6 @@
|
|||||||
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;
|
||||||
};
|
};
|
||||||
@@ -565,8 +525,6 @@
|
|||||||
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 */,
|
||||||
);
|
);
|
||||||
@@ -652,40 +610,6 @@
|
|||||||
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 */
|
||||||
@@ -731,7 +655,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 = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
@@ -748,17 +672,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 = 1262;
|
CURRENT_PROJECT_VERSION = 1104;
|
||||||
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 = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1262;
|
MARKETING_VERSION = 1.0.1104;
|
||||||
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";
|
||||||
@@ -815,7 +739,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 = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -865,7 +789,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 = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
@@ -884,17 +808,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 = 1262;
|
CURRENT_PROJECT_VERSION = 1104;
|
||||||
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 = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1262;
|
MARKETING_VERSION = 1.0.1104;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
|
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
@@ -912,17 +836,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 = 1262;
|
CURRENT_PROJECT_VERSION = 1104;
|
||||||
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 = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1262;
|
MARKETING_VERSION = 1.0.1104;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
|
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
@@ -943,7 +867,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 = 1262;
|
CURRENT_PROJECT_VERSION = 1104;
|
||||||
DEVELOPMENT_TEAM = BA88US33G6;
|
DEVELOPMENT_TEAM = BA88US33G6;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -956,7 +880,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1262;
|
MARKETING_VERSION = 1.0.1104;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
|
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
|
||||||
@@ -982,7 +906,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 = 1262;
|
CURRENT_PROJECT_VERSION = 1104;
|
||||||
DEVELOPMENT_TEAM = BA88US33G6;
|
DEVELOPMENT_TEAM = BA88US33G6;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -995,7 +919,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1262;
|
MARKETING_VERSION = 1.0.1104;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
|
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -1018,7 +942,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 = 1262;
|
CURRENT_PROJECT_VERSION = 1104;
|
||||||
DEVELOPMENT_TEAM = BA88US33G6;
|
DEVELOPMENT_TEAM = BA88US33G6;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -1031,7 +955,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1262;
|
MARKETING_VERSION = 1.0.1104;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
|
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -1054,7 +978,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 = 1262;
|
CURRENT_PROJECT_VERSION = 1104;
|
||||||
DEVELOPMENT_ASSET_PATHS = "";
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
DEVELOPMENT_TEAM = BA88US33G6;
|
DEVELOPMENT_TEAM = BA88US33G6;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -1066,7 +990,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1262;
|
MARKETING_VERSION = 1.0.1104;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
|
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
|
||||||
@@ -1095,7 +1019,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 = 1262;
|
CURRENT_PROJECT_VERSION = 1104;
|
||||||
DEVELOPMENT_ASSET_PATHS = "";
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
DEVELOPMENT_TEAM = BA88US33G6;
|
DEVELOPMENT_TEAM = BA88US33G6;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -1107,7 +1031,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1262;
|
MARKETING_VERSION = 1.0.1104;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
|
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
|
||||||
PRODUCT_NAME = ServerBox;
|
PRODUCT_NAME = ServerBox;
|
||||||
@@ -1133,7 +1057,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 = 1262;
|
CURRENT_PROJECT_VERSION = 1104;
|
||||||
DEVELOPMENT_ASSET_PATHS = "";
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
DEVELOPMENT_TEAM = BA88US33G6;
|
DEVELOPMENT_TEAM = BA88US33G6;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -1145,7 +1069,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1262;
|
MARKETING_VERSION = 1.0.1104;
|
||||||
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;
|
||||||
|
|||||||
@@ -26,7 +26,6 @@
|
|||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
<MacroExpansion>
|
<MacroExpansion>
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
@@ -44,13 +43,11 @@
|
|||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
|
||||||
launchStyle = "0"
|
launchStyle = "0"
|
||||||
useCustomWorkingDirectory = "NO"
|
useCustomWorkingDirectory = "NO"
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
debugDocumentVersioning = "YES"
|
debugDocumentVersioning = "YES"
|
||||||
debugServiceExtension = "internal"
|
debugServiceExtension = "internal"
|
||||||
enableGPUValidationMode = "1"
|
|
||||||
allowLocationSimulation = "YES">
|
allowLocationSimulation = "YES">
|
||||||
<BuildableProductRunnable
|
<BuildableProductRunnable
|
||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
import Flutter
|
import Flutter
|
||||||
import ActivityKit
|
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
@@ -12,48 +11,14 @@ import ActivityKit
|
|||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
|
|
||||||
let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
|
let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
|
||||||
// Home widget channel (legacy)
|
let methodChannel = FlutterMethodChannel(name: "tech.lolli.toolbox/home_widget", binaryMessenger: controller.binaryMessenger)
|
||||||
let homeWidgetChannel = FlutterMethodChannel(name: "tech.lolli.toolbox/home_widget", binaryMessenger: controller.binaryMessenger)
|
methodChannel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,11 +30,4 @@ import ActivityKit
|
|||||||
}
|
}
|
||||||
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,8 +41,6 @@
|
|||||||
<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>
|
||||||
@@ -80,4 +78,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,8 +17,6 @@
|
|||||||
<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,8 +17,6 @@
|
|||||||
<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>
|
||||||
@@ -70,4 +68,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>
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"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";
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"Terminal" = "Terminal";
|
|
||||||
"Connected" = "Connected";
|
|
||||||
"Connecting" = "Connecting";
|
|
||||||
"Disconnected" = "Disconnected";
|
|
||||||
"Multiple SSH sessions active" = "Multiple SSH sessions active";
|
|
||||||
"1 connection" = "1 connection";
|
|
||||||
"%d connections" = "%d connections";
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"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";
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"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";
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"Terminal" = "Terminal";
|
|
||||||
"Connected" = "Terhubung";
|
|
||||||
"Connecting" = "Menghubungkan";
|
|
||||||
"Disconnected" = "Terputus";
|
|
||||||
"Multiple SSH sessions active" = "Beberapa sesi SSH aktif";
|
|
||||||
"1 connection" = "1 koneksi";
|
|
||||||
"%d connections" = "%d koneksi";
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"Terminal" = "ターミナル";
|
|
||||||
"Connected" = "接続済み";
|
|
||||||
"Connecting" = "接続中";
|
|
||||||
"Disconnected" = "切断";
|
|
||||||
"Multiple SSH sessions active" = "複数の SSH セッションがアクティブ";
|
|
||||||
"1 connection" = "1 件の接続";
|
|
||||||
"%d connections" = "%d 件の接続";
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"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";
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"Terminal" = "Терминал";
|
|
||||||
"Connected" = "Подключено";
|
|
||||||
"Connecting" = "Подключение";
|
|
||||||
"Disconnected" = "Отключено";
|
|
||||||
"Multiple SSH sessions active" = "Несколько активных сеансов SSH";
|
|
||||||
"1 connection" = "1 подключение";
|
|
||||||
"%d connections" = "%d подключений";
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"Terminal" = "终端";
|
|
||||||
"Connected" = "已连接";
|
|
||||||
"Connecting" = "连接中";
|
|
||||||
"Disconnected" = "已断开连接";
|
|
||||||
"Multiple SSH sessions active" = "多个 SSH 会话正在活动";
|
|
||||||
"1 connection" = "1 个连接";
|
|
||||||
"%d connections" = "%d 个连接";
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"Terminal" = "終端機";
|
|
||||||
"Connected" = "已連線";
|
|
||||||
"Connecting" = "連線中";
|
|
||||||
"Disconnected" = "已中斷連線";
|
|
||||||
"Multiple SSH sessions active" = "多個 SSH 連線運行中";
|
|
||||||
"1 connection" = "1 個連線";
|
|
||||||
"%d connections" = "%d 個連線";
|
|
||||||
|
|
||||||
@@ -4,15 +4,6 @@
|
|||||||
<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,142 +15,6 @@ 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))
|
||||||
@@ -165,13 +29,11 @@ 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(suiteName: WidgetConstants.appGroupId)?.string(forKey: "accessory_widget_url")
|
url = UserDefaults.standard.string(forKey: accessoryKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#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)!
|
||||||
@@ -249,7 +111,7 @@ struct StatusWidgetEntryView : View {
|
|||||||
Button(intent: RefreshIntent()) {
|
Button(intent: RefreshIntent()) {
|
||||||
Image(systemName: "arrow.clockwise")
|
Image(systemName: "arrow.clockwise")
|
||||||
.resizable()
|
.resizable()
|
||||||
.frame(width: WidgetConstants.Dimensions.refreshIconSmall, height: WidgetConstants.Dimensions.refreshIconSmall * 1.27)
|
.frame(width: 10, height: 12.7)
|
||||||
}.tint(.gray)
|
}.tint(.gray)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,37 +123,6 @@ 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 {
|
||||||
@@ -311,7 +142,6 @@ 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, *) {
|
||||||
@@ -321,7 +151,7 @@ struct StatusWidgetEntryView : View {
|
|||||||
Button(intent: RefreshIntent()) {
|
Button(intent: RefreshIntent()) {
|
||||||
Image(systemName: "arrow.clockwise")
|
Image(systemName: "arrow.clockwise")
|
||||||
.resizable()
|
.resizable()
|
||||||
.frame(width: WidgetConstants.Dimensions.refreshIconSmall, height: WidgetConstants.Dimensions.refreshIconSmall * 1.27)
|
.frame(width: 10, height: 12.7)
|
||||||
}.tint(.gray)
|
}.tint(.gray)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -332,6 +162,9 @@ 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()
|
||||||
@@ -344,16 +177,8 @@ struct StatusWidgetEntryView : View {
|
|||||||
extension View {
|
extension View {
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func widgetBackground() -> some View {
|
func widgetBackground() -> some View {
|
||||||
// Modern card-style background with subtle effects
|
// Set bg to black in Night, white in Day
|
||||||
let backgroundView = LinearGradient(
|
let backgroundView = Color(bgColor.resolve())
|
||||||
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
|
||||||
@@ -363,29 +188,14 @@ extension View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enhanced padding with improved spacing
|
// iOS 17 will auto add a SafeArea, so when iOS < 17, add .padding(.all, 17)
|
||||||
func autoPadding() -> some View {
|
func autoPadding() -> some View {
|
||||||
if #available(iOS 17.0, *) {
|
if #available(iOS 17.0, *) {
|
||||||
return self.padding(.all, WidgetConstants.Spacing.tight)
|
return self
|
||||||
} else {
|
} else {
|
||||||
return self.padding(.all, WidgetConstants.Spacing.extraLoose + 1)
|
return self.padding(.all, 17)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
||||||
@@ -397,15 +207,11 @@ struct StatusWidget: Widget {
|
|||||||
}
|
}
|
||||||
.configurationDisplayName("Status")
|
.configurationDisplayName("Status")
|
||||||
.description("Status of your servers.")
|
.description("Status of your servers.")
|
||||||
#if os(iOS)
|
if #available(iOSApplicationExtension 16.0, *) {
|
||||||
if #available(iOSApplicationExtension 16.0, *) {
|
return cfg.supportedFamilies([.systemSmall, .accessoryRectangular, .accessoryInline])
|
||||||
return cfg.supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular, .accessoryInline])
|
|
||||||
} else {
|
} else {
|
||||||
return cfg.supportedFamilies([.systemSmall, .systemMedium])
|
return cfg.supportedFamilies([.systemSmall])
|
||||||
}
|
}
|
||||||
#else
|
|
||||||
return cfg.supportedFamilies([.systemSmall, .systemMedium])
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,176 +228,31 @@ struct DetailItem: View {
|
|||||||
let color: Color
|
let color: Color
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: WidgetConstants.Spacing.normal) {
|
HStack(spacing: 6.7) {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon).resizable().foregroundColor(color).frame(width: 11, height: 11, alignment: .center)
|
||||||
.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: 12, weight: .medium, design: .rounded))
|
.font(.system(size: 11, design: .monospaced))
|
||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
.lineLimit(1)
|
|
||||||
.minimumScaleFactor(0.8)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, WidgetConstants.Spacing.tight)
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enhanced circular progress indicator
|
// 空心圆,显示百分比
|
||||||
struct CirclePercent: View {
|
struct CirclePercent: View {
|
||||||
// eg: 31.7%
|
// eg: 31.7%
|
||||||
let percent: String
|
let percent: String
|
||||||
@State private var animatedProgress: Double = 0
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
// 31.7% -> 0.317
|
||||||
let percentD = Double(percent.trimmingCharacters(in: .init(charactersIn: "%")))
|
let percentD = Double(percent.trimmingCharacters(in: .init(charactersIn: "%")))
|
||||||
let progress = (percentD ?? 0) / 100
|
let double = (percentD ?? 0) / 100
|
||||||
|
Circle()
|
||||||
ZStack {
|
.trim(from: 0, to: CGFloat(double))
|
||||||
// Background circle
|
.stroke(Color.primary, lineWidth: 3)
|
||||||
Circle()
|
.animation(.easeInOut(duration: 0.5))
|
||||||
.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 GaugeTile: View {
|
|
||||||
let label: String
|
|
||||||
// 0..1
|
|
||||||
let value: Double
|
|
||||||
// eg: "31.7%"
|
|
||||||
let display: String
|
|
||||||
let diameter: CGFloat
|
|
||||||
|
|
||||||
@State private var animatedValue: Double = 0
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: WidgetConstants.Spacing.normal) {
|
|
||||||
ZStack {
|
|
||||||
// Background circle with subtle shadow effect
|
|
||||||
Circle()
|
|
||||||
.stroke(Color.primary.opacity(0.1), lineWidth: 4)
|
|
||||||
.background(
|
|
||||||
Circle()
|
|
||||||
.fill(WidgetConstants.Colors.cardBackground)
|
|
||||||
.shadow(color: .black.opacity(0.05), radius: WidgetConstants.Dimensions.shadowRadius, x: 0, y: 1)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Progress arc with gradient effect
|
|
||||||
Circle()
|
|
||||||
.trim(from: 0, to: CGFloat(max(0, min(1, animatedValue))))
|
|
||||||
.stroke(
|
|
||||||
AngularGradient(
|
|
||||||
gradient: Gradient(colors: [
|
|
||||||
PerformanceUtils.thresholdColor(value).opacity(0.8),
|
|
||||||
PerformanceUtils.thresholdColor(value)
|
|
||||||
]),
|
|
||||||
center: .center,
|
|
||||||
startAngle: .degrees(-90),
|
|
||||||
endAngle: .degrees(270)
|
|
||||||
),
|
|
||||||
style: StrokeStyle(lineWidth: 5, lineCap: .round)
|
|
||||||
)
|
|
||||||
.rotationEffect(.degrees(-90))
|
|
||||||
|
|
||||||
// Center value text with improved typography
|
|
||||||
Text(display)
|
|
||||||
.font(.system(size: diameter < 60 ? 11 : 13, weight: .bold, design: .rounded))
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
.minimumScaleFactor(0.8)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
.frame(width: diameter, height: diameter)
|
|
||||||
.onAppear {
|
|
||||||
withAnimation(.easeOut(duration: 0.8).delay(0.1)) {
|
|
||||||
animatedValue = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: value) { newValue in
|
|
||||||
withAnimation(.easeInOut(duration: 0.6)) {
|
|
||||||
animatedValue = newValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Label with enhanced styling
|
|
||||||
if #available(iOS 16.0, *) {
|
|
||||||
Text(label)
|
|
||||||
.font(.system(size: 11, weight: .medium, design: .rounded))
|
|
||||||
.foregroundColor(WidgetConstants.Colors.secondaryText)
|
|
||||||
.textCase(.uppercase)
|
|
||||||
.tracking(0.5)
|
|
||||||
} else {
|
|
||||||
Text(label)
|
|
||||||
.font(.system(size: 11, weight: .medium, design: .rounded))
|
|
||||||
.foregroundColor(WidgetConstants.Colors.secondaryText)
|
|
||||||
.textCase(.uppercase)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy functions maintained for compatibility - now delegate to optimized versions
|
|
||||||
func parsePercent(_ text: String) -> Double {
|
|
||||||
return ParseCache.parsePercent(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseUsagePercent(_ text: String) -> Double {
|
|
||||||
return ParseCache.parseUsagePercent(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseSizeToBytes(_ text: String) -> Double {
|
|
||||||
return PerformanceUtils.parseSizeToBytes(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
func percentStr(_ value: Double) -> String {
|
|
||||||
return PerformanceUtils.percentStr(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func thresholdColor(_ value: Double) -> Color {
|
|
||||||
return PerformanceUtils.thresholdColor(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DynamicColor {
|
struct DynamicColor {
|
||||||
let dark: UIColor
|
let dark: UIColor
|
||||||
let light: UIColor
|
let light: UIColor
|
||||||
|
|||||||
@@ -12,8 +12,5 @@ import SwiftUI
|
|||||||
struct StatusWidgetBundle: WidgetBundle {
|
struct StatusWidgetBundle: WidgetBundle {
|
||||||
var body: some Widget {
|
var body: some Widget {
|
||||||
StatusWidget()
|
StatusWidget()
|
||||||
if #available(iOSApplicationExtension 16.1, *) {
|
|
||||||
TerminalLiveActivity()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
//
|
|
||||||
// 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
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"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";
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"Terminal" = "Terminal";
|
|
||||||
"Connected" = "Connected";
|
|
||||||
"Connecting" = "Connecting";
|
|
||||||
"Disconnected" = "Disconnected";
|
|
||||||
"Multiple SSH sessions active" = "Multiple SSH sessions active";
|
|
||||||
"1 connection" = "1 connection";
|
|
||||||
"%d connections" = "%d connections";
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"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";
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"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";
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"Terminal" = "Terminal";
|
|
||||||
"Connected" = "Terhubung";
|
|
||||||
"Connecting" = "Menghubungkan";
|
|
||||||
"Disconnected" = "Terputus";
|
|
||||||
"Multiple SSH sessions active" = "Beberapa sesi SSH aktif";
|
|
||||||
"1 connection" = "1 koneksi";
|
|
||||||
"%d connections" = "%d koneksi";
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"Terminal" = "ターミナル";
|
|
||||||
"Connected" = "接続済み";
|
|
||||||
"Connecting" = "接続中";
|
|
||||||
"Disconnected" = "切断";
|
|
||||||
"Multiple SSH sessions active" = "複数の SSH セッションがアクティブ";
|
|
||||||
"1 connection" = "1 件の接続";
|
|
||||||
"%d connections" = "%d 件の接続";
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"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";
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"Terminal" = "Терминал";
|
|
||||||
"Connected" = "Подключено";
|
|
||||||
"Connecting" = "Подключение";
|
|
||||||
"Disconnected" = "Отключено";
|
|
||||||
"Multiple SSH sessions active" = "Несколько активных сеансов SSH";
|
|
||||||
"1 connection" = "1 подключение";
|
|
||||||
"%d connections" = "%d подключений";
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"Terminal" = "终端";
|
|
||||||
"Connected" = "已连接";
|
|
||||||
"Connecting" = "连接中";
|
|
||||||
"Disconnected" = "已断开连接";
|
|
||||||
"Multiple SSH sessions active" = "多个 SSH 会话正在活动";
|
|
||||||
"1 connection" = "1 个连接";
|
|
||||||
"%d connections" = "%d 个连接";
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"Terminal" = "終端機";
|
|
||||||
"Connected" = "已連線";
|
|
||||||
"Connecting" = "連線中";
|
|
||||||
"Disconnected" = "已中斷連線";
|
|
||||||
"Multiple SSH sessions active" = "多個 SSH 連線運行中";
|
|
||||||
"1 connection" = "1 個連線";
|
|
||||||
"%d connections" = "%d 個連線";
|
|
||||||
|
|
||||||
@@ -9,62 +9,22 @@ 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 hasServers = !_mgr.urls.isEmpty
|
let _count = _mgr.urls.count == 0 ? 1 : _mgr.urls.count
|
||||||
let pagesCount = hasServers ? _mgr.urls.count : 1
|
TabView {
|
||||||
TabView(selection: $selection) {
|
ForEach(0 ..< _count, id:\.self) { index in
|
||||||
ForEach(0 ..< pagesCount, id:\.self) { index in
|
let url = _count == 1 && _mgr.urls.count == 0 ? nil : _mgr.urls[index]
|
||||||
let url = hasServers ? _mgr.urls[index] : nil
|
PageView(url: url, state: .loading)
|
||||||
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 {
|
||||||
@@ -76,50 +36,35 @@ struct PageView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Group {
|
switch state {
|
||||||
switch state {
|
case .loading:
|
||||||
case .loading:
|
ProgressView().padding().onAppear {
|
||||||
ProgressView().padding().onAppear {
|
getStatus(url: url!)
|
||||||
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)
|
||||||
HStack(spacing: 10) {
|
Button(action: {
|
||||||
Button(action: {
|
state = .loading
|
||||||
state = .loading
|
}){
|
||||||
}){
|
Image(systemName: "arrow.clockwise")
|
||||||
Image(systemName: "arrow.clockwise")
|
}.buttonStyle(.plain)
|
||||||
}.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()
|
||||||
HStack(spacing: 10) {
|
Button(action: {
|
||||||
Button(action: {
|
state = .loading
|
||||||
state = .loading
|
}){
|
||||||
}){
|
Image(systemName: "arrow.clockwise")
|
||||||
Image(systemName: "arrow.clockwise")
|
}.buttonStyle(.plain)
|
||||||
}.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)
|
||||||
@@ -127,12 +72,6 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,32 +87,25 @@ 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
|
||||||
// 所有 UI 状态更新必须在主线程执行,否则可能导致 TabView 跳回第一页等问题
|
guard error == nil else {
|
||||||
func setStateOnMain(_ newState: ContentState) {
|
state = .error(.http(error!.localizedDescription))
|
||||||
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 {
|
||||||
setStateOnMain(.error(.http("empty data")))
|
state = .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 {
|
||||||
setStateOnMain(.error(.http("json parse fail")))
|
state = .error(.http("json parse fail"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let code = jsonAll["code"] as? Int else {
|
guard let code = jsonAll["code"] as? Int else {
|
||||||
setStateOnMain(.error(.http("code is nil")))
|
state = .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 ?? ""
|
||||||
setStateOnMain(.error(.http(msg)))
|
state = .error(.http(msg))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,35 +115,10 @@ 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 ?? ""
|
||||||
let status = Status(name: name, cpu: cpu, mem: mem, disk: disk, net: net)
|
state = .normal(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,20 +14,13 @@ 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() {
|
||||||
@@ -36,91 +29,24 @@ 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] {
|
||||||
DispatchQueue.main.async {
|
self.urls = urls.filter { !$0.isEmpty }
|
||||||
let list = urls.filter { !$0.isEmpty }
|
|
||||||
self.urls = list
|
|
||||||
// Save URLs to App Group for widget access
|
|
||||||
let appGroupId = "group.com.lollipopkit.toolbox"
|
|
||||||
if let defaults = UserDefaults(suiteName: appGroupId) {
|
|
||||||
defaults.set(list, forKey: "watch_shared_urls")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func session(
|
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
||||||
_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState,
|
|
||||||
error: Error?
|
|
||||||
) {
|
|
||||||
// Request latest data when the session is activated
|
|
||||||
if activationState == .activated {
|
|
||||||
requestLatestData()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Receive realtime msgs
|
// implement session:didReceiveApplicationContext:
|
||||||
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) {
|
||||||
DispatchQueue.main.async {
|
ctx = applicationContext
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
//
|
|
||||||
// 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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
//
|
|
||||||
// WatchStatusWidgetBundle.swift
|
|
||||||
// WatchStatusWidget Extension
|
|
||||||
//
|
|
||||||
// Created by AI Assistant
|
|
||||||
//
|
|
||||||
|
|
||||||
import WidgetKit
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@main
|
|
||||||
struct WatchStatusWidgetBundle: WidgetBundle {
|
|
||||||
var body: some Widget {
|
|
||||||
WatchStatusWidget()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
arb-dir: lib/l10n
|
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
|
|
||||||
untranslated-messages-file: untranlated.json
|
untranslated-messages-file: untranlated.json
|
||||||
123
lib/app.dart
@@ -1,13 +1,14 @@
|
|||||||
import 'package:dynamic_color/dynamic_color.dart';
|
import 'package:dynamic_color/dynamic_color.dart';
|
||||||
import 'package:fl_lib/fl_lib.dart';
|
import 'package:fl_lib/fl_lib.dart';
|
||||||
import 'package:fl_lib/generated/l10n/lib_l10n.dart';
|
import 'package:fl_lib/l10n/gen_l10n/lib_l10n.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:icons_plus/icons_plus.dart';
|
import 'package:flutter_gen/gen_l10n/l10n.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/rebuild.dart';
|
||||||
import 'package:server_box/data/res/store.dart';
|
import 'package:server_box/data/res/store.dart';
|
||||||
import 'package:server_box/generated/l10n/l10n.dart';
|
import 'package:server_box/view/page/home/home.dart';
|
||||||
import 'package:server_box/view/page/home.dart';
|
import 'package:icons_plus/icons_plus.dart';
|
||||||
|
|
||||||
part 'intro.dart';
|
part 'intro.dart';
|
||||||
|
|
||||||
@@ -21,54 +22,48 @@ class MyApp extends StatelessWidget {
|
|||||||
listenable: RNodes.app,
|
listenable: RNodes.app,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
if (!Stores.setting.useSystemPrimaryColor.fetch()) {
|
if (!Stores.setting.useSystemPrimaryColor.fetch()) {
|
||||||
return _build(context);
|
final colorSeed = Color(Stores.setting.colorSeed.fetch());
|
||||||
|
UIs.colorSeed = colorSeed;
|
||||||
|
// Past code uses [UIs.primaryColor] as the primary color
|
||||||
|
UIs.primaryColor = colorSeed;
|
||||||
|
return _buildApp(
|
||||||
|
context,
|
||||||
|
light: ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorSchemeSeed: UIs.colorSeed,
|
||||||
|
),
|
||||||
|
dark: ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
colorSchemeSeed: UIs.colorSeed,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
return DynamicColorBuilder(
|
||||||
return _buildDynamicColor(context);
|
builder: (light, dark) {
|
||||||
|
final lightTheme = ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: light,
|
||||||
|
);
|
||||||
|
final darkTheme = ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
colorScheme: dark,
|
||||||
|
);
|
||||||
|
if (context.isDark && dark != null) {
|
||||||
|
UIs.primaryColor = dark.primary;
|
||||||
|
} else if (!context.isDark && light != null) {
|
||||||
|
UIs.primaryColor = light.primary;
|
||||||
|
}
|
||||||
|
return _buildApp(context, light: lightTheme, dark: darkTheme);
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _build(BuildContext context) {
|
Widget _buildApp(BuildContext ctx,
|
||||||
final colorSeed = Color(Stores.setting.colorSeed.fetch());
|
{required ThemeData light, required ThemeData dark}) {
|
||||||
UIs.colorSeed = colorSeed;
|
|
||||||
UIs.primaryColor = colorSeed;
|
|
||||||
|
|
||||||
return _buildApp(
|
|
||||||
context,
|
|
||||||
light: ThemeData(
|
|
||||||
useMaterial3: true,
|
|
||||||
colorSchemeSeed: UIs.colorSeed,
|
|
||||||
appBarTheme: AppBarTheme(scrolledUnderElevation: 0.0),
|
|
||||||
),
|
|
||||||
dark: ThemeData(
|
|
||||||
useMaterial3: true,
|
|
||||||
brightness: Brightness.dark,
|
|
||||||
colorSchemeSeed: UIs.colorSeed,
|
|
||||||
appBarTheme: AppBarTheme(scrolledUnderElevation: 0.0),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDynamicColor(BuildContext context) {
|
|
||||||
return DynamicColorBuilder(
|
|
||||||
builder: (light, dark) {
|
|
||||||
final lightTheme = ThemeData(useMaterial3: true, colorScheme: light);
|
|
||||||
final darkTheme = ThemeData(useMaterial3: true, brightness: Brightness.dark, colorScheme: dark);
|
|
||||||
if (context.isDark && dark != null) {
|
|
||||||
UIs.primaryColor = dark.primary;
|
|
||||||
UIs.colorSeed = dark.primary;
|
|
||||||
} else if (!context.isDark && light != null) {
|
|
||||||
UIs.primaryColor = light.primary;
|
|
||||||
UIs.colorSeed = light.primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
return _buildApp(context, light: lightTheme, dark: darkTheme);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildApp(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) {
|
||||||
@@ -80,9 +75,11 @@ class MyApp extends StatelessWidget {
|
|||||||
|
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
key: ValueKey(locale),
|
key: ValueKey(locale),
|
||||||
builder: ResponsivePoints.builder,
|
|
||||||
locale: locale,
|
locale: locale,
|
||||||
localizationsDelegates: const [LibLocalizations.delegate, ...AppLocalizations.localizationsDelegates],
|
localizationsDelegates: const [
|
||||||
|
LibLocalizations.delegate,
|
||||||
|
...AppLocalizations.localizationsDelegates,
|
||||||
|
],
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
localeListResolutionCallback: LocaleUtil.resolve,
|
localeListResolutionCallback: LocaleUtil.resolve,
|
||||||
navigatorObservers: [AppRouteObserver.instance],
|
navigatorObservers: [AppRouteObserver.instance],
|
||||||
@@ -90,27 +87,21 @@ 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: FutureBuilder<List<IntroPageBuilder>>(
|
home: VirtualWindowFrame(
|
||||||
future: _IntroPage.builders,
|
child: Builder(
|
||||||
builder: (context, snapshot) {
|
builder: (context) {
|
||||||
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;
|
final intros = _IntroPage.builders;
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
||||||
child = const Scaffold(body: Center(child: CircularProgressIndicator()));
|
|
||||||
} else {
|
|
||||||
final intros = snapshot.data ?? [];
|
|
||||||
if (intros.isNotEmpty) {
|
if (intros.isNotEmpty) {
|
||||||
child = _IntroPage(intros);
|
return _IntroPage(intros);
|
||||||
} else {
|
|
||||||
child = const HomePage();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return VirtualWindowFrame(title: BuildData.name, child: child);
|
return const HomePage();
|
||||||
},
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
import 'package:fl_lib/fl_lib.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:server_box/data/res/misc.dart';
|
|
||||||
import 'package:server_box/data/res/store.dart';
|
|
||||||
|
|
||||||
abstract final class MethodChans {
|
|
||||||
static const _channel = MethodChannel('${Miscs.pkgName}/main_chan');
|
|
||||||
|
|
||||||
static void moveToBg() {
|
|
||||||
_channel.invokeMethod('sendToBackground');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Issue #662
|
|
||||||
static void startService() {
|
|
||||||
if (Stores.setting.fgService.fetch() != true) return;
|
|
||||||
_channel.invokeMethod('startService');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Issue #662
|
|
||||||
static void stopService() {
|
|
||||||
if (Stores.setting.fgService.fetch() != true) return;
|
|
||||||
_channel.invokeMethod('stopService');
|
|
||||||
}
|
|
||||||
|
|
||||||
static void updateHomeWidget() async {
|
|
||||||
if (!isIOS && !isAndroid) return;
|
|
||||||
if (!Stores.setting.autoUpdateHomeWidget.fetch()) return;
|
|
||||||
await _channel.invokeMethod('updateHomeWidget');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update Android foreground service notifications for SSH sessions
|
|
||||||
/// The [payload] is a JSON string describing sessions list.
|
|
||||||
static Future<void> updateSessions(String payload) async {
|
|
||||||
if (!isAndroid) return;
|
|
||||||
try {
|
|
||||||
Loggers.app.info('Updating Android sessions: $payload');
|
|
||||||
await _channel.invokeMethod('updateSessions', payload);
|
|
||||||
} catch (_) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Query whether the Android foreground service is currently running.
|
|
||||||
static Future<bool> isServiceRunning() async {
|
|
||||||
if (!isAndroid) return false;
|
|
||||||
try {
|
|
||||||
final res = await _channel.invokeMethod('isServiceRunning');
|
|
||||||
return res == true;
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// iOS Live Activities controls
|
|
||||||
static Future<void> startLiveActivity(String payload) async {
|
|
||||||
if (!isIOS) return;
|
|
||||||
try {
|
|
||||||
Loggers.app.info('Starting iOS Live Activity: $payload');
|
|
||||||
await _channel.invokeMethod('startLiveActivity', payload);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<void> updateLiveActivity(String payload) async {
|
|
||||||
if (!isIOS) return;
|
|
||||||
try {
|
|
||||||
Loggers.app.info('Updating iOS Live Activity: $payload');
|
|
||||||
await _channel.invokeMethod('updateLiveActivity', payload);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<void> stopLiveActivity() async {
|
|
||||||
if (!isIOS) return;
|
|
||||||
try {
|
|
||||||
Loggers.app.info('Stopping iOS Live Activity');
|
|
||||||
await _channel.invokeMethod('stopLiveActivity');
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Register a handler for native -> Flutter callbacks.
|
|
||||||
/// Currently handles:
|
|
||||||
/// - `disconnectSession` with argument map {id: string}
|
|
||||||
/// - `stopAllConnections` with no arguments
|
|
||||||
static void registerHandler(Future<void> Function(String id) onDisconnect, [VoidCallback? onStopAll]) {
|
|
||||||
_channel.setMethodCallHandler((call) async {
|
|
||||||
switch (call.method) {
|
|
||||||
case 'disconnectSession':
|
|
||||||
final args = call.arguments;
|
|
||||||
final id = args is Map ? args['id'] as String? : args as String?;
|
|
||||||
if (id != null && id.isNotEmpty) {
|
|
||||||
await onDisconnect(id);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
case 'stopAllConnections':
|
|
||||||
onStopAll?.call();
|
|
||||||
return;
|
|
||||||
default:
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
18
lib/core/channel/bg_run.dart
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:server_box/data/res/misc.dart';
|
||||||
|
|
||||||
|
abstract final class BgRunMC {
|
||||||
|
static const _channel = MethodChannel('${Miscs.pkgName}/app_retain');
|
||||||
|
|
||||||
|
static void moveToBg() {
|
||||||
|
_channel.invokeMethod('sendToBackground');
|
||||||
|
}
|
||||||
|
|
||||||
|
static void startService() {
|
||||||
|
_channel.invokeMethod('startService');
|
||||||
|
}
|
||||||
|
|
||||||
|
static void stopService() {
|
||||||
|
_channel.invokeMethod('stopService');
|
||||||
|
}
|
||||||
|
}
|
||||||
12
lib/core/channel/home_widget.dart
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:server_box/data/res/misc.dart';
|
||||||
|
import 'package:server_box/data/res/store.dart';
|
||||||
|
|
||||||
|
abstract final class HomeWidgetMC {
|
||||||
|
static const _channel = MethodChannel('${Miscs.pkgName}/home_widget');
|
||||||
|
|
||||||
|
static void update() {
|
||||||
|
if (!Stores.setting.autoUpdateHomeWidget.fetch()) return;
|
||||||
|
_channel.invokeMethod('update');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,4 @@
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||||
import 'package:server_box/generated/l10n/l10n.dart';
|
import 'package:flutter_gen/gen_l10n/l10n_en.dart';
|
||||||
import 'package:server_box/generated/l10n/l10n_en.dart';
|
|
||||||
|
|
||||||
AppLocalizations l10n = AppLocalizationsEn();
|
AppLocalizations l10n = AppLocalizationsEn();
|
||||||
|
|
||||||
extension LocaleX on BuildContext {
|
|
||||||
AppLocalizations get l10n => AppLocalizations.of(this)!;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
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,9 +12,21 @@ extension SftpFileX on SftpFileMode {
|
|||||||
|
|
||||||
UnixPerm toUnixPerm() {
|
UnixPerm toUnixPerm() {
|
||||||
return UnixPerm(
|
return UnixPerm(
|
||||||
user: UnixPermOp(r: userRead, w: userWrite, x: userExecute),
|
user: RWX(
|
||||||
group: UnixPermOp(r: groupRead, w: groupWrite, x: groupExecute),
|
r: userRead,
|
||||||
other: UnixPermOp(r: otherRead, w: otherWrite, x: otherExecute),
|
w: userWrite,
|
||||||
|
x: userExecute,
|
||||||
|
),
|
||||||
|
group: RWX(
|
||||||
|
r: groupRead,
|
||||||
|
w: groupWrite,
|
||||||
|
x: groupExecute,
|
||||||
|
),
|
||||||
|
other: RWX(
|
||||||
|
r: otherRead,
|
||||||
|
w: otherWrite,
|
||||||
|
x: otherExecute,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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';
|
||||||
|
|
||||||
@@ -14,52 +13,6 @@ 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,
|
||||||
@@ -69,14 +22,9 @@ 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 ??
|
entry ?? 'cat | sh',
|
||||||
switch (systemType) {
|
|
||||||
SystemType.windows => 'powershell -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass',
|
|
||||||
_ => 'cat | sh',
|
|
||||||
},
|
|
||||||
pty: pty,
|
pty: pty,
|
||||||
environment: env,
|
environment: env,
|
||||||
);
|
);
|
||||||
@@ -132,9 +80,10 @@ 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);
|
||||||
final ctx = context ?? WidgetsBinding.instance.focusManager.primaryFocus?.context;
|
if (context == null) return;
|
||||||
if (ctx == null) return;
|
final pwd = context.mounted
|
||||||
final pwd = ctx.mounted ? await ctx.showPwdDialog(title: user, id: id) : null;
|
? await context.showPwdDialog(title: user, id: id)
|
||||||
|
: null;
|
||||||
if (pwd == null || pwd.isEmpty) {
|
if (pwd == null || pwd.isEmpty) {
|
||||||
session.stdin.close();
|
session.stdin.close();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,9 +1,169 @@
|
|||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:server_box/data/model/server/private_key_info.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/view/page/container.dart';
|
||||||
|
import 'package:server_box/view/page/home/home.dart';
|
||||||
|
import 'package:server_box/view/page/iperf.dart';
|
||||||
|
import 'package:server_box/view/page/ping.dart';
|
||||||
|
import 'package:server_box/view/page/private_key/edit.dart';
|
||||||
|
import 'package:server_box/view/page/pve.dart';
|
||||||
|
import 'package:server_box/view/page/server/detail/view.dart';
|
||||||
|
import 'package:server_box/view/page/setting/platform/android.dart';
|
||||||
|
import 'package:server_box/view/page/setting/platform/ios.dart';
|
||||||
|
import 'package:server_box/view/page/setting/seq/srv_func_seq.dart';
|
||||||
|
import 'package:server_box/view/page/snippet/result.dart';
|
||||||
|
import 'package:server_box/view/page/ssh/page.dart';
|
||||||
|
import 'package:server_box/view/page/setting/seq/virt_key.dart';
|
||||||
|
import 'package:server_box/data/model/server/snippet.dart';
|
||||||
|
import 'package:server_box/view/page/process.dart';
|
||||||
|
import 'package:server_box/view/page/server/tab.dart';
|
||||||
|
import 'package:server_box/view/page/setting/seq/srv_detail_seq.dart';
|
||||||
|
import 'package:server_box/view/page/setting/seq/srv_seq.dart';
|
||||||
|
import 'package:server_box/view/page/snippet/edit.dart';
|
||||||
|
import 'package:server_box/view/page/storage/sftp.dart';
|
||||||
|
import 'package:server_box/view/page/storage/sftp_mission.dart';
|
||||||
|
|
||||||
/// The args class for [AppRoute].
|
class AppRoutes {
|
||||||
final class SpiRequiredArgs {
|
final Widget page;
|
||||||
/// The only required argument for this class.
|
final String title;
|
||||||
final Spi spi;
|
|
||||||
|
|
||||||
const SpiRequiredArgs(this.spi);
|
AppRoutes(this.page, this.title);
|
||||||
|
|
||||||
|
Future<T?> go<T>(BuildContext context) {
|
||||||
|
return Navigator.push<T>(
|
||||||
|
context,
|
||||||
|
Stores.setting.cupertinoRoute.fetch()
|
||||||
|
? CupertinoPageRoute(builder: (context) => page)
|
||||||
|
: MaterialPageRoute(builder: (context) => page),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<T?> checkGo<T>({
|
||||||
|
required BuildContext context,
|
||||||
|
required bool Function() check,
|
||||||
|
}) {
|
||||||
|
if (check()) {
|
||||||
|
return go(context);
|
||||||
|
}
|
||||||
|
return Future.value(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static AppRoutes serverDetail({Key? key, required Spi spi}) {
|
||||||
|
return AppRoutes(ServerDetailPage(key: key, spi: spi), 'server_detail');
|
||||||
|
}
|
||||||
|
|
||||||
|
static AppRoutes serverTab({Key? key}) {
|
||||||
|
return AppRoutes(ServerPage(key: key), 'server_tab');
|
||||||
|
}
|
||||||
|
|
||||||
|
static AppRoutes keyEdit({Key? key, PrivateKeyInfo? pki}) {
|
||||||
|
return AppRoutes(
|
||||||
|
PrivateKeyEditPage(pki: pki),
|
||||||
|
'key_${pki == null ? 'add' : 'edit'}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static AppRoutes snippetEdit({Key? key, Snippet? snippet}) {
|
||||||
|
return AppRoutes(
|
||||||
|
SnippetEditPage(snippet: snippet),
|
||||||
|
'snippet_${snippet == null ? 'add' : 'edit'}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static AppRoutes ssh({
|
||||||
|
Key? key,
|
||||||
|
required Spi spi,
|
||||||
|
String? initCmd,
|
||||||
|
Snippet? initSnippet,
|
||||||
|
}) {
|
||||||
|
return AppRoutes(
|
||||||
|
SSHPage(
|
||||||
|
key: key,
|
||||||
|
spi: spi,
|
||||||
|
initCmd: initCmd,
|
||||||
|
initSnippet: initSnippet,
|
||||||
|
),
|
||||||
|
'ssh_term',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static AppRoutes sshVirtKeySetting({Key? key}) {
|
||||||
|
return AppRoutes(SSHVirtKeySettingPage(key: key), 'ssh_virt_key_setting');
|
||||||
|
}
|
||||||
|
|
||||||
|
static AppRoutes sftpMission({Key? key}) {
|
||||||
|
return AppRoutes(SftpMissionPage(key: key), 'sftp_mission');
|
||||||
|
}
|
||||||
|
|
||||||
|
static AppRoutes sftp(
|
||||||
|
{Key? key, required Spi spi, String? initPath, bool isSelect = false}) {
|
||||||
|
return AppRoutes(
|
||||||
|
SftpPage(
|
||||||
|
key: key,
|
||||||
|
spi: spi,
|
||||||
|
initPath: initPath,
|
||||||
|
isSelect: isSelect,
|
||||||
|
),
|
||||||
|
'sftp');
|
||||||
|
}
|
||||||
|
|
||||||
|
static AppRoutes docker({Key? key, required Spi spi}) {
|
||||||
|
return AppRoutes(ContainerPage(key: key, spi: spi), 'docker');
|
||||||
|
}
|
||||||
|
|
||||||
|
// static AppRoutes fullscreen({Key? key}) {
|
||||||
|
// return AppRoutes(FullScreenPage(key: key), 'fullscreen');
|
||||||
|
// }
|
||||||
|
|
||||||
|
static AppRoutes home({Key? key}) {
|
||||||
|
return AppRoutes(HomePage(key: key), 'home');
|
||||||
|
}
|
||||||
|
|
||||||
|
static AppRoutes ping({Key? key}) {
|
||||||
|
return AppRoutes(PingPage(key: key), 'ping');
|
||||||
|
}
|
||||||
|
|
||||||
|
static AppRoutes process({Key? key, required Spi spi}) {
|
||||||
|
return AppRoutes(ProcessPage(key: key, spi: spi), 'process');
|
||||||
|
}
|
||||||
|
|
||||||
|
static AppRoutes serverOrder({Key? key}) {
|
||||||
|
return AppRoutes(ServerOrderPage(key: key), 'server_order');
|
||||||
|
}
|
||||||
|
|
||||||
|
static AppRoutes serverDetailOrder({Key? key}) {
|
||||||
|
return AppRoutes(ServerDetailOrderPage(key: key), 'server_detail_order');
|
||||||
|
}
|
||||||
|
|
||||||
|
static AppRoutes iosSettings({Key? key}) {
|
||||||
|
return AppRoutes(IOSSettingsPage(key: key), 'ios_setting');
|
||||||
|
}
|
||||||
|
|
||||||
|
static AppRoutes androidSettings({Key? key}) {
|
||||||
|
return AppRoutes(AndroidSettingsPage(key: key), 'android_setting');
|
||||||
|
}
|
||||||
|
|
||||||
|
static AppRoutes snippetResult(
|
||||||
|
{Key? key, required List<SnippetResult?> results}) {
|
||||||
|
return AppRoutes(
|
||||||
|
SnippetResultPage(
|
||||||
|
key: key,
|
||||||
|
results: results,
|
||||||
|
),
|
||||||
|
'snippet_result');
|
||||||
|
}
|
||||||
|
|
||||||
|
static AppRoutes iperf({Key? key, required Spi spi}) {
|
||||||
|
return AppRoutes(IPerfPage(key: key, spi: spi), 'iperf');
|
||||||
|
}
|
||||||
|
|
||||||
|
static AppRoutes serverFuncBtnsOrder({Key? key}) {
|
||||||
|
return AppRoutes(ServerFuncBtnsOrderPage(key: key), 'server_func_btns_seq');
|
||||||
|
}
|
||||||
|
|
||||||
|
static AppRoutes pve({Key? key, required Spi spi}) {
|
||||||
|
return AppRoutes(PvePage(key: key, spi: spi), 'pve');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,408 +0,0 @@
|
|||||||
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));
|
|
||||||
}
|
|
||||||
@@ -1,47 +1,39 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:fl_lib/fl_lib.dart';
|
import 'package:fl_lib/fl_lib.dart';
|
||||||
import 'package:server_box/data/model/app/bak/backup2.dart';
|
import 'package:server_box/data/model/app/backup.dart';
|
||||||
import 'package:server_box/data/model/app/bak/utils.dart';
|
import 'package:server_box/data/store/no_backup.dart';
|
||||||
|
|
||||||
const bakSync = BakSyncer._();
|
const bakSync = BakSyncer._();
|
||||||
|
|
||||||
final icloud = ICloud(containerId: 'iCloud.tech.lolli.serverbox');
|
final class BakSyncer extends SyncIface<Backup> {
|
||||||
|
|
||||||
final class BakSyncer extends SyncIface {
|
|
||||||
const BakSyncer._() : super();
|
const BakSyncer._() : super();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> saveToFile() async {
|
Future<void> saveToFile() => Backup.backup();
|
||||||
final pwd = await SecureStoreProps.bakPwd.read();
|
|
||||||
await BackupV2.backup(null, pwd?.isEmpty == true ? null : pwd);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Mergeable> fromFile(String path) async {
|
Future<Backup> fromFile(String path) async {
|
||||||
final content = await File(path).readAsString();
|
final content = await File(path).readAsString();
|
||||||
final pwd = await SecureStoreProps.bakPwd.read();
|
return Backup.fromJsonString(content);
|
||||||
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
|
||||||
RemoteStorage? get remoteStorage {
|
Future<RemoteStorage?> get remoteStorage async {
|
||||||
final icloudEnabled = PrefProps.icloudSync.get();
|
if (isMacOS || isIOS) await icloud.init('iCloud.tech.lolli.serverbox');
|
||||||
|
final settings = NoBackupStore.instance;
|
||||||
|
await webdav.init(WebdavInitArgs(
|
||||||
|
url: settings.webdavUrl.fetch(),
|
||||||
|
user: settings.webdavUser.fetch(),
|
||||||
|
pwd: settings.webdavPwd.fetch(),
|
||||||
|
prefix: 'serverbox/',
|
||||||
|
));
|
||||||
|
|
||||||
|
final icloudEnabled = settings.icloudSync.fetch();
|
||||||
if (icloudEnabled) return icloud;
|
if (icloudEnabled) return icloud;
|
||||||
|
|
||||||
final webdavEnabled = PrefProps.webdavSync.get();
|
final webdavEnabled = settings.webdavSync.fetch();
|
||||||
if (webdavEnabled) return Webdav.shared;
|
if (webdavEnabled) return webdav;
|
||||||
|
|
||||||
final gistEnabled = PrefProps.gistSync.get();
|
|
||||||
if (gistEnabled) return GistRs.shared;
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ 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>>(F Function(T) extractor) {
|
static ChainComparator<T> comparing<T, F extends Comparable<F>>(
|
||||||
return ChainComparator._create(null, (a, b) => extractor(a).compareTo(extractor(b)));
|
F Function(T) extractor) {
|
||||||
|
return ChainComparator._create(
|
||||||
|
null, (a, b) => extractor(a).compareTo(extractor(b)));
|
||||||
}
|
}
|
||||||
|
|
||||||
int compare(T a, T b) {
|
int compare(T a, T b) {
|
||||||
@@ -24,9 +26,8 @@ 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
|
||||||
@@ -35,12 +36,18 @@ class ChainComparator<T> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ChainComparator<T> thenWithComparator(Comparator<T> comparator, {bool reversed = false}) {
|
ChainComparator<T> thenWithComparator(Comparator<T> comparator,
|
||||||
return ChainComparator._create(this, !reversed ? comparator : (a, b) => comparator(b, a));
|
{bool reversed = false}) {
|
||||||
|
return ChainComparator._create(
|
||||||
|
this,
|
||||||
|
!reversed ? comparator : (a, b) => comparator(b, a),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ChainComparator<T> thenCompareByReversed<F extends Comparable<F>>(F Function(T) extractor) {
|
ChainComparator<T> thenCompareByReversed<F extends Comparable<F>>(
|
||||||
return ChainComparator._create(this, (a, b) => -extractor(a).compareTo(extractor(b)));
|
F Function(T) extractor) {
|
||||||
|
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) {
|
||||||
@@ -51,12 +58,13 @@ class ChainComparator<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ChainComparator<T> reversed() {
|
ChainComparator<T> reversed() {
|
||||||
return ChainComparator._create(null, (a, b) => compare(b, a));
|
return ChainComparator._create(null, (a, b) => this.compare(b, a));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Comparators {
|
class Comparators {
|
||||||
static Comparator<String> compareStringCaseInsensitive({bool uppercaseFirst = false}) {
|
static Comparator<String> compareStringCaseInsensitive(
|
||||||
|
{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;
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ 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: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/res/store.dart';
|
import 'package:server_box/data/res/store.dart';
|
||||||
|
|
||||||
|
import 'package:server_box/data/model/server/server_private_info.dart';
|
||||||
|
|
||||||
/// Must put this func out of any Class.
|
/// Must put this func out of any Class.
|
||||||
///
|
///
|
||||||
/// Because of this function is called by [compute].
|
/// Because of this function is called by [compute].
|
||||||
@@ -24,12 +25,19 @@ String decyptPem(List<String> args) {
|
|||||||
return sshKey.first.toPem();
|
return sshKey.first.toPem();
|
||||||
}
|
}
|
||||||
|
|
||||||
enum GenSSHClientStatus { socket, key, pwd }
|
enum GenSSHClientStatus {
|
||||||
|
socket,
|
||||||
|
key,
|
||||||
|
pwd,
|
||||||
|
}
|
||||||
|
|
||||||
String getPrivateKey(String id) {
|
String getPrivateKey(String id) {
|
||||||
final pki = Stores.key.fetchOne(id);
|
final pki = Stores.key.get(id);
|
||||||
if (pki == null) {
|
if (pki == null) {
|
||||||
throw SSHErr(type: SSHErrType.noPrivateKey, message: 'key [$id] not found');
|
throw SSHErr(
|
||||||
|
type: SSHErrType.noPrivateKey,
|
||||||
|
message: 'key [$id] not found',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return pki.key;
|
return pki.key;
|
||||||
}
|
}
|
||||||
@@ -51,7 +59,7 @@ Future<SSHClient> genClient(
|
|||||||
Spi? jumpSpi,
|
Spi? jumpSpi,
|
||||||
|
|
||||||
/// Handle keyboard-interactive authentication
|
/// Handle keyboard-interactive authentication
|
||||||
SSHUserInfoRequestHandler? onKeyboardInteractive,
|
FutureOr<List<String>?> Function(SSHUserInfoRequest)? onKeyboardInteractive,
|
||||||
}) async {
|
}) async {
|
||||||
onStatus?.call(GenSSHClientStatus.socket);
|
onStatus?.call(GenSSHClientStatus.socket);
|
||||||
|
|
||||||
@@ -66,21 +74,36 @@ 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) {
|
||||||
final jumpClient = await genClient(jumpSpi_, privateKey: jumpPrivateKey, timeout: timeout);
|
final jumpClient = await genClient(
|
||||||
|
jumpSpi_,
|
||||||
|
privateKey: jumpPrivateKey,
|
||||||
|
timeout: timeout,
|
||||||
|
);
|
||||||
|
|
||||||
return await jumpClient.forwardLocal(spi.ip, spi.port);
|
return await jumpClient.forwardLocal(
|
||||||
|
spi.ip,
|
||||||
|
spi.port,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct
|
// Direct
|
||||||
try {
|
try {
|
||||||
return await SSHSocket.connect(spi.ip, spi.port, timeout: timeout);
|
return await SSHSocket.connect(
|
||||||
|
spi.ip,
|
||||||
|
spi.port,
|
||||||
|
timeout: timeout,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Loggers.app.warning('genClient', e);
|
Loggers.app.warning('genClient', e);
|
||||||
if (spi.alterUrl == null) rethrow;
|
if (spi.alterUrl == null) rethrow;
|
||||||
try {
|
try {
|
||||||
final res = spi.parseAlterUrl();
|
final res = spi.fromStringUrl();
|
||||||
alterUser = res.$2;
|
alterUser = res.$2;
|
||||||
return await SSHSocket.connect(res.$1, res.$3, timeout: timeout);
|
return await SSHSocket.connect(
|
||||||
|
res.$1,
|
||||||
|
res.$3,
|
||||||
|
timeout: timeout,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Loggers.app.warning('genClient alterUrl', e);
|
Loggers.app.warning('genClient alterUrl', e);
|
||||||
rethrow;
|
rethrow;
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
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,13 +2,18 @@ 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(Spi spi, {BuildContext? ctx}) async {
|
static FutureOr<List<String>?> defaultHandle(
|
||||||
|
Spi spi, {
|
||||||
|
BuildContext? ctx,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
final res = await (ctx ?? WidgetsBinding.instance.focusManager.primaryFocus?.context)?.showPwdDialog(
|
final res = await (ctx ?? AppProvider.ctx)?.showPwdDialog(
|
||||||
title: libL10n.pwd,
|
title: l10n.pwd,
|
||||||
id: spi.id,
|
id: spi.id,
|
||||||
label: spi.id,
|
label: spi.id,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,188 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'package:fl_lib/fl_lib.dart';
|
|
||||||
import 'package:server_box/data/model/server/server_private_info.dart';
|
|
||||||
|
|
||||||
/// Utility class to parse SSH config files under `~/.ssh/config`
|
|
||||||
abstract final class SSHConfig {
|
|
||||||
static const String _defaultPath = '~/.ssh/config';
|
|
||||||
|
|
||||||
static String? get _homePath {
|
|
||||||
final homePath = isWindows ? Platform.environment['USERPROFILE'] : Platform.environment['HOME'];
|
|
||||||
if (homePath == null || homePath.isEmpty) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return homePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get possible SSH config file paths, with macOS-specific handling
|
|
||||||
static List<String> get _possibleConfigPaths {
|
|
||||||
final paths = <String>[];
|
|
||||||
final homePath = _homePath;
|
|
||||||
|
|
||||||
if (homePath != null) {
|
|
||||||
// Standard path
|
|
||||||
paths.add('$homePath/.ssh/config');
|
|
||||||
|
|
||||||
// On macOS, also try the actual user home directory
|
|
||||||
if (isMacOS) {
|
|
||||||
// Try to get the real user home directory
|
|
||||||
final username = Platform.environment['USER'];
|
|
||||||
if (username != null) {
|
|
||||||
paths.add('/Users/$username/.ssh/config');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return paths;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse SSH config file and return a list of Spi objects
|
|
||||||
static Future<List<Spi>> parseConfig([String? configPath]) async {
|
|
||||||
final (file, exists) = configExists(configPath);
|
|
||||||
if (!exists || file == null) {
|
|
||||||
Loggers.app.info('SSH config file does not exist at path: ${configPath ?? _defaultPath}');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
final content = await file.readAsString();
|
|
||||||
return _parseSSHConfig(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse SSH config content
|
|
||||||
static List<Spi> _parseSSHConfig(String content) {
|
|
||||||
final servers = <Spi>[];
|
|
||||||
final lines = content.split('\n');
|
|
||||||
|
|
||||||
String? currentHost;
|
|
||||||
String? hostname;
|
|
||||||
String? user;
|
|
||||||
int port = 22;
|
|
||||||
String? identityFile;
|
|
||||||
String? jumpHost;
|
|
||||||
|
|
||||||
void addServer() {
|
|
||||||
if (currentHost != null && currentHost != '*' && hostname != null) {
|
|
||||||
final spi = Spi(
|
|
||||||
id: ShortId.generate(),
|
|
||||||
name: currentHost,
|
|
||||||
ip: hostname,
|
|
||||||
port: port,
|
|
||||||
user: user ?? 'root', // Default user is 'root'
|
|
||||||
keyId: identityFile,
|
|
||||||
jumpId: jumpHost,
|
|
||||||
);
|
|
||||||
servers.add(spi);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (final line in lines) {
|
|
||||||
final trimmed = line.trim();
|
|
||||||
if (trimmed.isEmpty || trimmed.startsWith('#')) continue;
|
|
||||||
|
|
||||||
// Handle inline comments
|
|
||||||
final commentIndex = trimmed.indexOf('#');
|
|
||||||
final cleanLine = commentIndex != -1 ? trimmed.substring(0, commentIndex).trim() : trimmed;
|
|
||||||
if (cleanLine.isEmpty) continue;
|
|
||||||
|
|
||||||
final parts = cleanLine.split(RegExp(r'\s+'));
|
|
||||||
if (parts.length < 2) continue;
|
|
||||||
|
|
||||||
final key = parts[0].toLowerCase();
|
|
||||||
var value = parts.sublist(1).join(' ');
|
|
||||||
|
|
||||||
// Remove quotes from values
|
|
||||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
||||||
value = value.substring(1, value.length - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (key) {
|
|
||||||
case 'host':
|
|
||||||
// Save previous host config
|
|
||||||
addServer();
|
|
||||||
|
|
||||||
// Reset for new host
|
|
||||||
final originalValue = parts.sublist(1).join(' ');
|
|
||||||
final isQuoted =
|
|
||||||
(originalValue.startsWith('"') && originalValue.endsWith('"')) ||
|
|
||||||
(originalValue.startsWith("'") && originalValue.endsWith("'"));
|
|
||||||
|
|
||||||
currentHost = value;
|
|
||||||
// Skip hosts with multiple patterns (contains spaces but not quoted)
|
|
||||||
if (currentHost.contains(' ') && !isQuoted) {
|
|
||||||
currentHost = null; // Mark as invalid to skip
|
|
||||||
}
|
|
||||||
hostname = null;
|
|
||||||
user = null;
|
|
||||||
port = 22;
|
|
||||||
identityFile = null;
|
|
||||||
jumpHost = null;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'hostname':
|
|
||||||
hostname = value;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'user':
|
|
||||||
user = value;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'port':
|
|
||||||
port = int.tryParse(value) ?? 22;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'identityfile':
|
|
||||||
identityFile = value; // Store the path directly
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'proxyjump':
|
|
||||||
case 'proxycommand':
|
|
||||||
jumpHost = _extractJumpHost(value);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the last server
|
|
||||||
addServer();
|
|
||||||
|
|
||||||
return servers;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract jump host from ProxyJump or ProxyCommand
|
|
||||||
static String? _extractJumpHost(String value) {
|
|
||||||
// For ProxyJump, the format is usually: user@host:port
|
|
||||||
// For ProxyCommand, it's more complex and might need custom parsing
|
|
||||||
if (value.contains('@')) {
|
|
||||||
return value.split(' ').first;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if SSH config file exists, trying multiple possible paths
|
|
||||||
static (File?, bool) configExists([String? configPath]) {
|
|
||||||
if (configPath != null) {
|
|
||||||
// If specific path is provided, use it directly
|
|
||||||
final homePath = _homePath;
|
|
||||||
if (homePath == null) {
|
|
||||||
Loggers.app.warning('Cannot determine home directory for SSH config parsing.');
|
|
||||||
return (null, false);
|
|
||||||
}
|
|
||||||
final expandedPath = configPath.replaceFirst('~', homePath);
|
|
||||||
dprint('Checking SSH config at path: $expandedPath');
|
|
||||||
final file = File(expandedPath);
|
|
||||||
return (file, file.existsSync());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try multiple possible paths
|
|
||||||
for (final path in _possibleConfigPaths) {
|
|
||||||
dprint('Checking SSH config at path: $path');
|
|
||||||
final file = File(path);
|
|
||||||
if (file.existsSync()) {
|
|
||||||
dprint('Found SSH config at: $path');
|
|
||||||
return (file, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dprint('SSH config file not found in any of the expected locations');
|
|
||||||
return (null, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,7 @@ import 'package:server_box/data/model/server/private_key_info.dart';
|
|||||||
import 'package:server_box/data/model/server/server_private_info.dart';
|
import 'package:server_box/data/model/server/server_private_info.dart';
|
||||||
import 'package:server_box/data/model/server/snippet.dart';
|
import 'package:server_box/data/model/server/snippet.dart';
|
||||||
import 'package:server_box/data/res/misc.dart';
|
import 'package:server_box/data/res/misc.dart';
|
||||||
|
import 'package:server_box/data/res/rebuild.dart';
|
||||||
import 'package:server_box/data/res/store.dart';
|
import 'package:server_box/data/res/store.dart';
|
||||||
|
|
||||||
part 'backup.g.dart';
|
part 'backup.g.dart';
|
||||||
@@ -45,24 +46,19 @@ class Backup implements Mergeable {
|
|||||||
|
|
||||||
Map<String, dynamic> toJson() => _$BackupToJson(this);
|
Map<String, dynamic> toJson() => _$BackupToJson(this);
|
||||||
|
|
||||||
static Future<Backup> loadFromStore() async {
|
Backup.loadFromStore()
|
||||||
final lastModTime = Stores.lastModTime;
|
: version = backupFormatVersion,
|
||||||
return Backup(
|
date = DateTime.now().toString().split('.').firstOrNull ?? '',
|
||||||
version: backupFormatVersion,
|
spis = Stores.server.fetch(),
|
||||||
date: DateTime.now().toString().split('.').firstOrNull ?? '',
|
snippets = Stores.snippet.fetch(),
|
||||||
spis: Stores.server.fetch(),
|
keys = Stores.key.fetch(),
|
||||||
snippets: Stores.snippet.fetch(),
|
container = Stores.container.box.toJson(),
|
||||||
keys: Stores.key.fetch(),
|
lastModTime = Stores.lastModTime,
|
||||||
container: Stores.container.getAllMap(),
|
history = Stores.history.box.toJson(),
|
||||||
lastModTime: lastModTime,
|
settings = Stores.setting.box.toJson();
|
||||||
history: Stores.history.getAllMap(),
|
|
||||||
settings: Stores.setting.getAllMap(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<String> backup([String? name]) async {
|
static Future<String> backup([String? name]) async {
|
||||||
final bak = await Backup.loadFromStore();
|
final result = _diyEncrypt(json.encode(Backup.loadFromStore().toJson()));
|
||||||
final result = _diyEncrypt(json.encode(bak.toJson()));
|
|
||||||
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;
|
||||||
@@ -70,7 +66,7 @@ class Backup implements Mergeable {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> merge({bool force = false}) async {
|
Future<void> merge({bool force = false}) async {
|
||||||
final curTime = Stores.lastModTime;
|
final curTime = Stores.lastModTime ?? 0;
|
||||||
final bakTime = lastModTime ?? 0;
|
final bakTime = lastModTime ?? 0;
|
||||||
final shouldRestore = force || curTime < bakTime;
|
final shouldRestore = force || curTime < bakTime;
|
||||||
if (!shouldRestore) {
|
if (!shouldRestore) {
|
||||||
@@ -213,10 +209,13 @@ class Backup implements Mergeable {
|
|||||||
_logger.info('Restore success');
|
_logger.info('Restore success');
|
||||||
}
|
}
|
||||||
|
|
||||||
factory Backup.fromJsonString(String raw) => Backup.fromJson(json.decode(_diyDecrypt(raw)));
|
factory Backup.fromJsonString(String raw) =>
|
||||||
|
Backup.fromJson(json.decode(_diyDecrypt(raw)));
|
||||||
}
|
}
|
||||||
|
|
||||||
String _diyEncrypt(String raw) => json.encode(raw.codeUnits.map((e) => e * 2 + 1).toList(growable: false));
|
String _diyEncrypt(String raw) => json.encode(
|
||||||
|
raw.codeUnits.map((e) => e * 2 + 1).toList(growable: false),
|
||||||
|
);
|
||||||
|
|
||||||
String _diyDecrypt(String raw) {
|
String _diyDecrypt(String raw) {
|
||||||
try {
|
try {
|
||||||
37
lib/data/model/app/backup.g.dart
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'backup.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
Backup _$BackupFromJson(Map<String, dynamic> json) => Backup(
|
||||||
|
version: (json['version'] as num).toInt(),
|
||||||
|
date: json['date'] as String,
|
||||||
|
spis: (json['spis'] as List<dynamic>)
|
||||||
|
.map((e) => Spi.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
snippets: (json['snippets'] as List<dynamic>)
|
||||||
|
.map((e) => Snippet.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
keys: (json['keys'] as List<dynamic>)
|
||||||
|
.map((e) => PrivateKeyInfo.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
container: json['container'] as Map<String, dynamic>,
|
||||||
|
history: json['history'] as Map<String, dynamic>,
|
||||||
|
settings: json['settings'] as Map<String, dynamic>?,
|
||||||
|
lastModTime: (json['lastModTime'] as num?)?.toInt(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$BackupToJson(Backup instance) => <String, dynamic>{
|
||||||
|
'version': instance.version,
|
||||||
|
'date': instance.date,
|
||||||
|
'spis': instance.spis,
|
||||||
|
'snippets': instance.snippets,
|
||||||
|
'keys': instance.keys,
|
||||||
|
'container': instance.container,
|
||||||
|
'history': instance.history,
|
||||||
|
'lastModTime': instance.lastModTime,
|
||||||
|
'settings': instance.settings,
|
||||||
|
};
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'backup.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// JsonSerializableGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
Backup _$BackupFromJson(Map<String, dynamic> json) => Backup(
|
|
||||||
version: (json['version'] as num).toInt(),
|
|
||||||
date: json['date'] as String,
|
|
||||||
spis: (json['spis'] as List<dynamic>)
|
|
||||||
.map((e) => Spi.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList(),
|
|
||||||
snippets: (json['snippets'] as List<dynamic>)
|
|
||||||
.map((e) => Snippet.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList(),
|
|
||||||
keys: (json['keys'] as List<dynamic>)
|
|
||||||
.map((e) => PrivateKeyInfo.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList(),
|
|
||||||
container: json['container'] as Map<String, dynamic>,
|
|
||||||
history: json['history'] as Map<String, dynamic>,
|
|
||||||
settings: json['settings'] as Map<String, dynamic>?,
|
|
||||||
lastModTime: (json['lastModTime'] as num?)?.toInt(),
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, dynamic> _$BackupToJson(Backup instance) => <String, dynamic>{
|
|
||||||
'version': instance.version,
|
|
||||||
'date': instance.date,
|
|
||||||
'spis': instance.spis,
|
|
||||||
'snippets': instance.snippets,
|
|
||||||
'keys': instance.keys,
|
|
||||||
'container': instance.container,
|
|
||||||
'history': instance.history,
|
|
||||||
'lastModTime': instance.lastModTime,
|
|
||||||
'settings': instance.settings,
|
|
||||||
};
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:fl_lib/fl_lib.dart';
|
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
import 'package:server_box/data/provider/private_key.dart';
|
|
||||||
import 'package:server_box/data/provider/server/all.dart';
|
|
||||||
import 'package:server_box/data/provider/snippet.dart';
|
|
||||||
import 'package:server_box/data/res/misc.dart';
|
|
||||||
import 'package:server_box/data/res/store.dart';
|
|
||||||
|
|
||||||
part 'backup2.freezed.dart';
|
|
||||||
part 'backup2.g.dart';
|
|
||||||
|
|
||||||
final _loggerV2 = Logger('BackupV2');
|
|
||||||
|
|
||||||
@freezed
|
|
||||||
abstract class BackupV2 with _$BackupV2 implements Mergeable {
|
|
||||||
const BackupV2._();
|
|
||||||
|
|
||||||
/// Construct a backup with the latest format (v2).
|
|
||||||
///
|
|
||||||
/// All `Map<String, dynamic>` are:
|
|
||||||
/// ```json
|
|
||||||
/// {
|
|
||||||
/// "key1": Model{},
|
|
||||||
/// "_lastModTime": {
|
|
||||||
/// "key1": 1234567890,
|
|
||||||
/// },
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
const factory BackupV2({
|
|
||||||
required int version,
|
|
||||||
required int date,
|
|
||||||
required Map<String, Object?> spis,
|
|
||||||
required Map<String, Object?> snippets,
|
|
||||||
required Map<String, Object?> keys,
|
|
||||||
required Map<String, Object?> container,
|
|
||||||
required Map<String, Object?> history,
|
|
||||||
required Map<String, Object?> settings,
|
|
||||||
}) = _BackupV2;
|
|
||||||
|
|
||||||
factory BackupV2.fromJson(Map<String, dynamic> json) => _$BackupV2FromJson(json);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> merge({bool force = false}) async {
|
|
||||||
_loggerV2.info('Merging...');
|
|
||||||
|
|
||||||
// Merge each store and check if changes were made
|
|
||||||
final serverChanged = await Mergeable.mergeStore(backupData: spis, store: Stores.server, force: force);
|
|
||||||
final snippetChanged = await Mergeable.mergeStore(backupData: snippets, store: Stores.snippet, force: force);
|
|
||||||
final keyChanged = await Mergeable.mergeStore(backupData: keys, store: Stores.key, force: force);
|
|
||||||
await Mergeable.mergeStore(backupData: container, store: Stores.container, force: force);
|
|
||||||
await Mergeable.mergeStore(backupData: history, store: Stores.history, force: force);
|
|
||||||
await Mergeable.mergeStore(backupData: settings, store: Stores.setting, force: force);
|
|
||||||
|
|
||||||
if (serverChanged) GlobalRef.gRef?.read(serversProvider.notifier).reload();
|
|
||||||
if (snippetChanged) GlobalRef.gRef?.read(snippetProvider.notifier).reload();
|
|
||||||
if (keyChanged) GlobalRef.gRef?.read(privateKeyProvider.notifier).reload();
|
|
||||||
|
|
||||||
_loggerV2.info('Merge completed');
|
|
||||||
}
|
|
||||||
|
|
||||||
static const formatVer = 2;
|
|
||||||
|
|
||||||
static Future<BackupV2> loadFromStore() async {
|
|
||||||
return BackupV2(
|
|
||||||
version: formatVer,
|
|
||||||
date: DateTimeX.timestamp,
|
|
||||||
spis: Stores.server.getAllMap(includeInternalKeys: true),
|
|
||||||
snippets: Stores.snippet.getAllMap(includeInternalKeys: true),
|
|
||||||
keys: Stores.key.getAllMap(includeInternalKeys: true),
|
|
||||||
container: Stores.container.getAllMap(includeInternalKeys: true),
|
|
||||||
history: Stores.history.getAllMap(includeInternalKeys: true),
|
|
||||||
settings: Stores.setting.getAllMap(includeInternalKeys: true),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<String> backup([String? name, String? password]) async {
|
|
||||||
final bak = await BackupV2.loadFromStore();
|
|
||||||
var result = json.encode(bak.toJson());
|
|
||||||
|
|
||||||
if (password != null && password.isNotEmpty) {
|
|
||||||
result = Cryptor.encrypt(result, password);
|
|
||||||
}
|
|
||||||
|
|
||||||
final path = Paths.doc.joinPath(name ?? Miscs.bakFileName);
|
|
||||||
await File(path).writeAsString(result);
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
factory BackupV2.fromJsonString(String jsonString, [String? password]) {
|
|
||||||
if (Cryptor.isEncrypted(jsonString)) {
|
|
||||||
if (password == null || password.isEmpty) {
|
|
||||||
throw Exception('Backup is encrypted but no password provided');
|
|
||||||
}
|
|
||||||
jsonString = Cryptor.decrypt(jsonString, password);
|
|
||||||
}
|
|
||||||
|
|
||||||
final map = json.decode(jsonString) as Map<String, dynamic>;
|
|
||||||
return BackupV2.fromJson(map);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
// coverage:ignore-file
|
|
||||||
// ignore_for_file: type=lint
|
|
||||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
|
||||||
|
|
||||||
part of 'backup2.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// FreezedGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
// dart format off
|
|
||||||
T _$identity<T>(T value) => value;
|
|
||||||
|
|
||||||
/// @nodoc
|
|
||||||
mixin _$BackupV2 {
|
|
||||||
|
|
||||||
int get version; int get date; Map<String, Object?> get spis; Map<String, Object?> get snippets; Map<String, Object?> get keys; Map<String, Object?> get container; Map<String, Object?> get history; Map<String, Object?> get settings;
|
|
||||||
/// Create a copy of BackupV2
|
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
|
||||||
@pragma('vm:prefer-inline')
|
|
||||||
$BackupV2CopyWith<BackupV2> get copyWith => _$BackupV2CopyWithImpl<BackupV2>(this as BackupV2, _$identity);
|
|
||||||
|
|
||||||
/// Serializes this BackupV2 to a JSON map.
|
|
||||||
Map<String, dynamic> toJson();
|
|
||||||
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) {
|
|
||||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is BackupV2&&(identical(other.version, version) || other.version == version)&&(identical(other.date, date) || other.date == date)&&const DeepCollectionEquality().equals(other.spis, spis)&&const DeepCollectionEquality().equals(other.snippets, snippets)&&const DeepCollectionEquality().equals(other.keys, keys)&&const DeepCollectionEquality().equals(other.container, container)&&const DeepCollectionEquality().equals(other.history, history)&&const DeepCollectionEquality().equals(other.settings, settings));
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
|
||||||
@override
|
|
||||||
int get hashCode => Object.hash(runtimeType,version,date,const DeepCollectionEquality().hash(spis),const DeepCollectionEquality().hash(snippets),const DeepCollectionEquality().hash(keys),const DeepCollectionEquality().hash(container),const DeepCollectionEquality().hash(history),const DeepCollectionEquality().hash(settings));
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() {
|
|
||||||
return 'BackupV2(version: $version, date: $date, spis: $spis, snippets: $snippets, keys: $keys, container: $container, history: $history, settings: $settings)';
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/// @nodoc
|
|
||||||
abstract mixin class $BackupV2CopyWith<$Res> {
|
|
||||||
factory $BackupV2CopyWith(BackupV2 value, $Res Function(BackupV2) _then) = _$BackupV2CopyWithImpl;
|
|
||||||
@useResult
|
|
||||||
$Res call({
|
|
||||||
int version, int date, Map<String, Object?> spis, Map<String, Object?> snippets, Map<String, Object?> keys, Map<String, Object?> container, Map<String, Object?> history, Map<String, Object?> settings
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
/// @nodoc
|
|
||||||
class _$BackupV2CopyWithImpl<$Res>
|
|
||||||
implements $BackupV2CopyWith<$Res> {
|
|
||||||
_$BackupV2CopyWithImpl(this._self, this._then);
|
|
||||||
|
|
||||||
final BackupV2 _self;
|
|
||||||
final $Res Function(BackupV2) _then;
|
|
||||||
|
|
||||||
/// Create a copy of BackupV2
|
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
|
||||||
@pragma('vm:prefer-inline') @override $Res call({Object? version = null,Object? date = null,Object? spis = null,Object? snippets = null,Object? keys = null,Object? container = null,Object? history = null,Object? settings = null,}) {
|
|
||||||
return _then(_self.copyWith(
|
|
||||||
version: null == version ? _self.version : version // ignore: cast_nullable_to_non_nullable
|
|
||||||
as int,date: null == date ? _self.date : date // ignore: cast_nullable_to_non_nullable
|
|
||||||
as int,spis: null == spis ? _self.spis : spis // ignore: cast_nullable_to_non_nullable
|
|
||||||
as Map<String, Object?>,snippets: null == snippets ? _self.snippets : snippets // ignore: cast_nullable_to_non_nullable
|
|
||||||
as Map<String, Object?>,keys: null == keys ? _self.keys : keys // ignore: cast_nullable_to_non_nullable
|
|
||||||
as Map<String, Object?>,container: null == container ? _self.container : container // ignore: cast_nullable_to_non_nullable
|
|
||||||
as Map<String, Object?>,history: null == history ? _self.history : history // ignore: cast_nullable_to_non_nullable
|
|
||||||
as Map<String, Object?>,settings: null == settings ? _self.settings : settings // ignore: cast_nullable_to_non_nullable
|
|
||||||
as Map<String, Object?>,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Adds pattern-matching-related methods to [BackupV2].
|
|
||||||
extension BackupV2Patterns on BackupV2 {
|
|
||||||
/// A variant of `map` that fallback to returning `orElse`.
|
|
||||||
///
|
|
||||||
/// It is equivalent to doing:
|
|
||||||
/// ```dart
|
|
||||||
/// switch (sealedClass) {
|
|
||||||
/// case final Subclass value:
|
|
||||||
/// return ...;
|
|
||||||
/// case _:
|
|
||||||
/// return orElse();
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
|
|
||||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _BackupV2 value)? $default,{required TResult orElse(),}){
|
|
||||||
final _that = this;
|
|
||||||
switch (_that) {
|
|
||||||
case _BackupV2() when $default != null:
|
|
||||||
return $default(_that);case _:
|
|
||||||
return orElse();
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// A `switch`-like method, using callbacks.
|
|
||||||
///
|
|
||||||
/// Callbacks receives the raw object, upcasted.
|
|
||||||
/// It is equivalent to doing:
|
|
||||||
/// ```dart
|
|
||||||
/// switch (sealedClass) {
|
|
||||||
/// case final Subclass value:
|
|
||||||
/// return ...;
|
|
||||||
/// case final Subclass2 value:
|
|
||||||
/// return ...;
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
|
|
||||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _BackupV2 value) $default,){
|
|
||||||
final _that = this;
|
|
||||||
switch (_that) {
|
|
||||||
case _BackupV2():
|
|
||||||
return $default(_that);case _:
|
|
||||||
throw StateError('Unexpected subclass');
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// A variant of `map` that fallback to returning `null`.
|
|
||||||
///
|
|
||||||
/// It is equivalent to doing:
|
|
||||||
/// ```dart
|
|
||||||
/// switch (sealedClass) {
|
|
||||||
/// case final Subclass value:
|
|
||||||
/// return ...;
|
|
||||||
/// case _:
|
|
||||||
/// return null;
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
|
|
||||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _BackupV2 value)? $default,){
|
|
||||||
final _that = this;
|
|
||||||
switch (_that) {
|
|
||||||
case _BackupV2() when $default != null:
|
|
||||||
return $default(_that);case _:
|
|
||||||
return null;
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// A variant of `when` that fallback to an `orElse` callback.
|
|
||||||
///
|
|
||||||
/// It is equivalent to doing:
|
|
||||||
/// ```dart
|
|
||||||
/// switch (sealedClass) {
|
|
||||||
/// case Subclass(:final field):
|
|
||||||
/// return ...;
|
|
||||||
/// case _:
|
|
||||||
/// return orElse();
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
|
|
||||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( int version, int date, Map<String, Object?> spis, Map<String, Object?> snippets, Map<String, Object?> keys, Map<String, Object?> container, Map<String, Object?> history, Map<String, Object?> settings)? $default,{required TResult orElse(),}) {final _that = this;
|
|
||||||
switch (_that) {
|
|
||||||
case _BackupV2() when $default != null:
|
|
||||||
return $default(_that.version,_that.date,_that.spis,_that.snippets,_that.keys,_that.container,_that.history,_that.settings);case _:
|
|
||||||
return orElse();
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// A `switch`-like method, using callbacks.
|
|
||||||
///
|
|
||||||
/// As opposed to `map`, this offers destructuring.
|
|
||||||
/// It is equivalent to doing:
|
|
||||||
/// ```dart
|
|
||||||
/// switch (sealedClass) {
|
|
||||||
/// case Subclass(:final field):
|
|
||||||
/// return ...;
|
|
||||||
/// case Subclass2(:final field2):
|
|
||||||
/// return ...;
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
|
|
||||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( int version, int date, Map<String, Object?> spis, Map<String, Object?> snippets, Map<String, Object?> keys, Map<String, Object?> container, Map<String, Object?> history, Map<String, Object?> settings) $default,) {final _that = this;
|
|
||||||
switch (_that) {
|
|
||||||
case _BackupV2():
|
|
||||||
return $default(_that.version,_that.date,_that.spis,_that.snippets,_that.keys,_that.container,_that.history,_that.settings);case _:
|
|
||||||
throw StateError('Unexpected subclass');
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// A variant of `when` that fallback to returning `null`
|
|
||||||
///
|
|
||||||
/// It is equivalent to doing:
|
|
||||||
/// ```dart
|
|
||||||
/// switch (sealedClass) {
|
|
||||||
/// case Subclass(:final field):
|
|
||||||
/// return ...;
|
|
||||||
/// case _:
|
|
||||||
/// return null;
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
|
|
||||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( int version, int date, Map<String, Object?> spis, Map<String, Object?> snippets, Map<String, Object?> keys, Map<String, Object?> container, Map<String, Object?> history, Map<String, Object?> settings)? $default,) {final _that = this;
|
|
||||||
switch (_that) {
|
|
||||||
case _BackupV2() when $default != null:
|
|
||||||
return $default(_that.version,_that.date,_that.spis,_that.snippets,_that.keys,_that.container,_that.history,_that.settings);case _:
|
|
||||||
return null;
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/// @nodoc
|
|
||||||
@JsonSerializable()
|
|
||||||
|
|
||||||
class _BackupV2 extends BackupV2 {
|
|
||||||
const _BackupV2({required this.version, required this.date, required final Map<String, Object?> spis, required final Map<String, Object?> snippets, required final Map<String, Object?> keys, required final Map<String, Object?> container, required final Map<String, Object?> history, required final Map<String, Object?> settings}): _spis = spis,_snippets = snippets,_keys = keys,_container = container,_history = history,_settings = settings,super._();
|
|
||||||
factory _BackupV2.fromJson(Map<String, dynamic> json) => _$BackupV2FromJson(json);
|
|
||||||
|
|
||||||
@override final int version;
|
|
||||||
@override final int date;
|
|
||||||
final Map<String, Object?> _spis;
|
|
||||||
@override Map<String, Object?> get spis {
|
|
||||||
if (_spis is EqualUnmodifiableMapView) return _spis;
|
|
||||||
// ignore: implicit_dynamic_type
|
|
||||||
return EqualUnmodifiableMapView(_spis);
|
|
||||||
}
|
|
||||||
|
|
||||||
final Map<String, Object?> _snippets;
|
|
||||||
@override Map<String, Object?> get snippets {
|
|
||||||
if (_snippets is EqualUnmodifiableMapView) return _snippets;
|
|
||||||
// ignore: implicit_dynamic_type
|
|
||||||
return EqualUnmodifiableMapView(_snippets);
|
|
||||||
}
|
|
||||||
|
|
||||||
final Map<String, Object?> _keys;
|
|
||||||
@override Map<String, Object?> get keys {
|
|
||||||
if (_keys is EqualUnmodifiableMapView) return _keys;
|
|
||||||
// ignore: implicit_dynamic_type
|
|
||||||
return EqualUnmodifiableMapView(_keys);
|
|
||||||
}
|
|
||||||
|
|
||||||
final Map<String, Object?> _container;
|
|
||||||
@override Map<String, Object?> get container {
|
|
||||||
if (_container is EqualUnmodifiableMapView) return _container;
|
|
||||||
// ignore: implicit_dynamic_type
|
|
||||||
return EqualUnmodifiableMapView(_container);
|
|
||||||
}
|
|
||||||
|
|
||||||
final Map<String, Object?> _history;
|
|
||||||
@override Map<String, Object?> get history {
|
|
||||||
if (_history is EqualUnmodifiableMapView) return _history;
|
|
||||||
// ignore: implicit_dynamic_type
|
|
||||||
return EqualUnmodifiableMapView(_history);
|
|
||||||
}
|
|
||||||
|
|
||||||
final Map<String, Object?> _settings;
|
|
||||||
@override Map<String, Object?> get settings {
|
|
||||||
if (_settings is EqualUnmodifiableMapView) return _settings;
|
|
||||||
// ignore: implicit_dynamic_type
|
|
||||||
return EqualUnmodifiableMapView(_settings);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Create a copy of BackupV2
|
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
|
||||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
|
||||||
@pragma('vm:prefer-inline')
|
|
||||||
_$BackupV2CopyWith<_BackupV2> get copyWith => __$BackupV2CopyWithImpl<_BackupV2>(this, _$identity);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
return _$BackupV2ToJson(this, );
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) {
|
|
||||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _BackupV2&&(identical(other.version, version) || other.version == version)&&(identical(other.date, date) || other.date == date)&&const DeepCollectionEquality().equals(other._spis, _spis)&&const DeepCollectionEquality().equals(other._snippets, _snippets)&&const DeepCollectionEquality().equals(other._keys, _keys)&&const DeepCollectionEquality().equals(other._container, _container)&&const DeepCollectionEquality().equals(other._history, _history)&&const DeepCollectionEquality().equals(other._settings, _settings));
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
|
||||||
@override
|
|
||||||
int get hashCode => Object.hash(runtimeType,version,date,const DeepCollectionEquality().hash(_spis),const DeepCollectionEquality().hash(_snippets),const DeepCollectionEquality().hash(_keys),const DeepCollectionEquality().hash(_container),const DeepCollectionEquality().hash(_history),const DeepCollectionEquality().hash(_settings));
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() {
|
|
||||||
return 'BackupV2(version: $version, date: $date, spis: $spis, snippets: $snippets, keys: $keys, container: $container, history: $history, settings: $settings)';
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/// @nodoc
|
|
||||||
abstract mixin class _$BackupV2CopyWith<$Res> implements $BackupV2CopyWith<$Res> {
|
|
||||||
factory _$BackupV2CopyWith(_BackupV2 value, $Res Function(_BackupV2) _then) = __$BackupV2CopyWithImpl;
|
|
||||||
@override @useResult
|
|
||||||
$Res call({
|
|
||||||
int version, int date, Map<String, Object?> spis, Map<String, Object?> snippets, Map<String, Object?> keys, Map<String, Object?> container, Map<String, Object?> history, Map<String, Object?> settings
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
/// @nodoc
|
|
||||||
class __$BackupV2CopyWithImpl<$Res>
|
|
||||||
implements _$BackupV2CopyWith<$Res> {
|
|
||||||
__$BackupV2CopyWithImpl(this._self, this._then);
|
|
||||||
|
|
||||||
final _BackupV2 _self;
|
|
||||||
final $Res Function(_BackupV2) _then;
|
|
||||||
|
|
||||||
/// Create a copy of BackupV2
|
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
|
||||||
@override @pragma('vm:prefer-inline') $Res call({Object? version = null,Object? date = null,Object? spis = null,Object? snippets = null,Object? keys = null,Object? container = null,Object? history = null,Object? settings = null,}) {
|
|
||||||
return _then(_BackupV2(
|
|
||||||
version: null == version ? _self.version : version // ignore: cast_nullable_to_non_nullable
|
|
||||||
as int,date: null == date ? _self.date : date // ignore: cast_nullable_to_non_nullable
|
|
||||||
as int,spis: null == spis ? _self._spis : spis // ignore: cast_nullable_to_non_nullable
|
|
||||||
as Map<String, Object?>,snippets: null == snippets ? _self._snippets : snippets // ignore: cast_nullable_to_non_nullable
|
|
||||||
as Map<String, Object?>,keys: null == keys ? _self._keys : keys // ignore: cast_nullable_to_non_nullable
|
|
||||||
as Map<String, Object?>,container: null == container ? _self._container : container // ignore: cast_nullable_to_non_nullable
|
|
||||||
as Map<String, Object?>,history: null == history ? _self._history : history // ignore: cast_nullable_to_non_nullable
|
|
||||||
as Map<String, Object?>,settings: null == settings ? _self._settings : settings // ignore: cast_nullable_to_non_nullable
|
|
||||||
as Map<String, Object?>,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// dart format on
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'backup2.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// JsonSerializableGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
_BackupV2 _$BackupV2FromJson(Map<String, dynamic> json) => _BackupV2(
|
|
||||||
version: (json['version'] as num).toInt(),
|
|
||||||
date: (json['date'] as num).toInt(),
|
|
||||||
spis: json['spis'] as Map<String, dynamic>,
|
|
||||||
snippets: json['snippets'] as Map<String, dynamic>,
|
|
||||||
keys: json['keys'] as Map<String, dynamic>,
|
|
||||||
container: json['container'] as Map<String, dynamic>,
|
|
||||||
history: json['history'] as Map<String, dynamic>,
|
|
||||||
settings: json['settings'] as Map<String, dynamic>,
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, dynamic> _$BackupV2ToJson(_BackupV2 instance) => <String, dynamic>{
|
|
||||||
'version': instance.version,
|
|
||||||
'date': instance.date,
|
|
||||||
'spis': instance.spis,
|
|
||||||
'snippets': instance.snippets,
|
|
||||||
'keys': instance.keys,
|
|
||||||
'container': instance.container,
|
|
||||||
'history': instance.history,
|
|
||||||
'settings': instance.settings,
|
|
||||||
};
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import 'package:fl_lib/fl_lib.dart';
|
|
||||||
import 'package:server_box/data/model/app/bak/backup.dart';
|
|
||||||
import 'package:server_box/data/model/app/bak/backup2.dart';
|
|
||||||
|
|
||||||
abstract final class MergeableUtils {
|
|
||||||
static (Mergeable, String) fromJsonString(String json, [String? password]) {
|
|
||||||
try {
|
|
||||||
final bak = BackupV2.fromJsonString(json, password);
|
|
||||||
return (bak, DateTime.fromMillisecondsSinceEpoch(bak.date).hms());
|
|
||||||
} catch (e) {
|
|
||||||
final bak = Backup.fromJsonString(json);
|
|
||||||
return (bak, bak.date);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,55 @@
|
|||||||
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 { unknown, connect, auth, noPrivateKey, chdir, segements, writeScript, getStatus }
|
enum ErrFrom {
|
||||||
|
unknown,
|
||||||
|
apt,
|
||||||
|
docker,
|
||||||
|
sftp,
|
||||||
|
ssh,
|
||||||
|
status,
|
||||||
|
icloud,
|
||||||
|
webdav,
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class Err<T> {
|
||||||
|
final ErrFrom from;
|
||||||
|
final T type;
|
||||||
|
final String? message;
|
||||||
|
|
||||||
|
String? get solution;
|
||||||
|
|
||||||
|
Err({required this.from, required this.type, this.message});
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SSHErrType {
|
||||||
|
unknown,
|
||||||
|
connect,
|
||||||
|
auth,
|
||||||
|
noPrivateKey,
|
||||||
|
chdir,
|
||||||
|
segements,
|
||||||
|
writeScript,
|
||||||
|
getStatus,
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
class SSHErr extends Err<SSHErrType> {
|
class SSHErr extends Err<SSHErrType> {
|
||||||
const SSHErr({required super.type, super.message});
|
SSHErr({required super.type, super.message}) : super(from: ErrFrom.ssh);
|
||||||
|
|
||||||
@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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'SSHErr<$type>: $message';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ContainerErrType {
|
enum ContainerErrType {
|
||||||
@@ -29,35 +65,69 @@ enum ContainerErrType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ContainerErr extends Err<ContainerErrType> {
|
class ContainerErr extends Err<ContainerErrType> {
|
||||||
const ContainerErr({required super.type, super.message});
|
ContainerErr({required super.type, super.message})
|
||||||
|
: super(from: ErrFrom.docker);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String? get solution => null;
|
String? get solution => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'ContainerErr<$type>: $message';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ICloudErrType { generic, notFound, multipleFiles }
|
enum ICloudErrType {
|
||||||
|
generic,
|
||||||
|
notFound,
|
||||||
|
multipleFiles,
|
||||||
|
}
|
||||||
|
|
||||||
class ICloudErr extends Err<ICloudErrType> {
|
class ICloudErr extends Err<ICloudErrType> {
|
||||||
const ICloudErr({required super.type, super.message});
|
ICloudErr({required super.type, super.message}) : super(from: ErrFrom.icloud);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String? get solution => null;
|
String? get solution => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'ICloudErr<$type>: $message';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum WebdavErrType { generic, notFound }
|
enum WebdavErrType {
|
||||||
|
generic,
|
||||||
|
notFound,
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
class WebdavErr extends Err<WebdavErrType> {
|
class WebdavErr extends Err<WebdavErrType> {
|
||||||
const WebdavErr({required super.type, super.message});
|
WebdavErr({required super.type, super.message}) : super(from: ErrFrom.webdav);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String? get solution => null;
|
String? get solution => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'WebdavErr<$type>: $message';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PveErrType { unknown, net, loginFailed }
|
enum PveErrType {
|
||||||
|
unknown,
|
||||||
|
net,
|
||||||
|
loginFailed,
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
class PveErr extends Err<PveErrType> {
|
class PveErr extends Err<PveErrType> {
|
||||||
const PveErr({required super.type, super.message});
|
PveErr({required super.type, super.message}) : super(from: ErrFrom.status);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String? get solution => null;
|
String? get solution => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'PveErr<$type>: $message';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,36 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hive_flutter/hive_flutter.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/data/res/store.dart';
|
import 'package:server_box/data/res/store.dart';
|
||||||
|
|
||||||
|
part 'server_func.g.dart';
|
||||||
|
|
||||||
|
@HiveType(typeId: 6)
|
||||||
enum ServerFuncBtn {
|
enum ServerFuncBtn {
|
||||||
terminal(),
|
@HiveField(0)
|
||||||
sftp(),
|
terminal._(),
|
||||||
container(),
|
@HiveField(1)
|
||||||
process(),
|
sftp._(),
|
||||||
//pkg(),
|
@HiveField(2)
|
||||||
snippet(),
|
container._(),
|
||||||
iperf(),
|
@HiveField(3)
|
||||||
// pve(),
|
process._(),
|
||||||
systemd(1058);
|
//@HiveField(4)
|
||||||
|
//pkg,
|
||||||
|
@HiveField(5)
|
||||||
|
snippet._(),
|
||||||
|
@HiveField(6)
|
||||||
|
iperf._(),
|
||||||
|
// @HiveField(7)
|
||||||
|
// pve,
|
||||||
|
@HiveField(8)
|
||||||
|
systemd._(1058),
|
||||||
|
;
|
||||||
|
|
||||||
final int? addedVersion;
|
final int? addedVersion;
|
||||||
|
|
||||||
const ServerFuncBtn([this.addedVersion]);
|
const ServerFuncBtn._([this.addedVersion]);
|
||||||
|
|
||||||
static void autoAddNewFuncs(int cur) {
|
static void autoAddNewFuncs(int cur) {
|
||||||
if (cur >= systemd.addedVersion!) {
|
if (cur >= systemd.addedVersion!) {
|
||||||
@@ -40,24 +54,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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||