Compare commits

..

1 Commits

Author SHA1 Message Date
Noo6
b56e033773 fix: sftp open file on windows 2024-11-14 14:18:32 +08:00
339 changed files with 11525 additions and 50466 deletions

View File

@@ -17,28 +17,17 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: subosito/flutter-action@v2
with:
channel: 'stable'
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
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-pub-
channel: 'stable' # or: 'beta', 'dev' or 'master'
- name: Install dependencies
run: flutter pub get
# Uncomment this step to verify the use of 'dart format' on each commit.
- name: Verify formatting
run: dart format --output=none .
# Consider passing '--fatal-infos' for slightly stricter analysis.
- name: Analyze project source
run: dart analyze

View File

@@ -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

View File

@@ -1,7 +1,6 @@
name: Flutter Release
on:
workflow_dispatch:
push:
tags:
- "v*"
@@ -9,11 +8,6 @@ on:
permissions:
contents: write
# Set by fl_build
# env:
# APP_NAME: ServerBox
# BUILD_NUMBER: ${{ github.ref_name }}
jobs:
releaseAndroid:
name: Release android
@@ -24,12 +18,12 @@ jobs:
- name: Install Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.35.3"
channel: 'stable'
flutter-version: '3.24.1'
- uses: actions/setup-java@v4
with:
distribution: "zulu"
java-version: "17"
distribution: 'zulu'
java-version: '17'
- name: Fetch secrets
run: |
curl -u ${{ secrets.BASIC_AUTH }} -o android/app/app.key ${{ secrets.URL_PREFIX }}app.key
@@ -59,13 +53,6 @@ jobs:
uses: actions/checkout@v4
- name: Install Flutter
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
run: |
dart run fl_build -p linux
@@ -103,12 +90,16 @@ jobs:
# uses: actions/checkout@v4
# - name: Install Flutter
# uses: subosito/flutter-action@v2
# with:
# channel: 'stable'
# flutter-version: '3.22.2'
# - name: Build
# run: dart run fl_build -p ios
# run: dart run fl_build -p ios,mac
# - name: Create Release
# uses: softprops/action-gh-release@v2
# with:
# files: |
# ${{ env.APP_NAME }}_universal_macos.zip
# ${{ env.APP_NAME }}_universal.ipa
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

2
.gitignore vendored
View File

@@ -46,7 +46,6 @@ app.*.map.json
/android/app/release
/android/app/fjy.androidstudio.key
/android/app/app.key
/release
test.dart
@@ -65,4 +64,3 @@ untranlated.json
.vscode/settings.json
more_build_data.json
trans.txt
android/app/.cxx

View File

@@ -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

View File

@@ -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>
<img alt="lang" src="https://img.shields.io/badge/lang-dart-cyan">
<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>
<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>
Especially thanks to <a href="https://github.com/TerminalStudio/dartssh2">dartssh2</a> & <a href="https://github.com/TerminalStudio/xterm.dart">xterm.dart</a>.
</p>
## 🏙️ Screenshots
## 🏙️ Screenshots
<table>
<tr>
<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>
</table>
## 📥 Installation
|Platform| From|
|--|--|
| iOS / macOS | [AppStore](https://apps.apple.com/app/id1586449703) |
| Android | [GitHub](https://github.com/lollipopkit/flutter_server_box/releases) / [CDN](https://cdn.lpkt.cn/serverbox/pkg/?sort=time&order=desc&layout=grid) / [F-Droid](https://f-droid.org/packages/tech.lolli.toolbox) / [OpenAPK](https://www.openapk.net/serverbox/tech.lolli.toolbox/) |
| Linux / Windows | [GitHub](https://github.com/lollipopkit/flutter_server_box/releases) / [CDN](https://cdn.lpkt.cn/serverbox/pkg/?sort=time&order=desc&layout=grid) |
## 📥 Install
Platform | From
--- | ---
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**!
## 🔖 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`...
- English, 简体中文; Deutsch [@its-tom](https://github.com/its-tom), 繁體中文 [@kalashnikov](https://github.com/kalashnikov), Indonesian [@azkadev](https://github.com/azkadev), Français [@FrancXPT](https://github.com/FrancXPT), Dutch [@QazCetelic](https://github.com/QazCetelic), Türkçe [@mikropsoft](https://github.com/mikropsoft), Українська мова [@CakesTwix](https://github.com/CakesTwix); Español, Русский язык, Português, 日本語 (Generated by GPT)
## 🆘 Help
<div align="center">
<a href="https://qm.qq.com/q/daCGa7eShG"><img alt="qq" src="https://img.shields.io/badge/QQ-Group-pink"></a>
<a href="https://t.me/lpktg"><img alt="donate" src="https://img.shields.io/badge/Telegram-lpktg-green"></a>
<a href="https://discord.gg/SsVNbRhK7w"><img alt="discord" src="https://img.shields.io/badge/Discord-lpkt-purple"></a>
</div>
@@ -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).
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.
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.
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.
If I forgot to add your name to the contributors list, please add a comment in the issue or PR you opened to let me know, I will add it as soon as possible.
### Development
1. Setup [Flutter](https://flutter.dev/docs/get-started/install) environment.
2. Clone this repo, run `flutter run` to start the app.
3. Run `dart run fl_build -p PLATFORM` to build the app.
### Translation
- [Guide](https://blog.lpkt.cn/posts/faq/) can be found in my blog.
- 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.
- [More](https://github.com/lollipopkit) - Tools & etc.
## 📝 License
## 📝 License
`GPL v3 lollipopkit`

View File

@@ -6,17 +6,16 @@
<a href="https://cdn.lpkt.cn/donate"><img alt="donate" src="https://img.shields.io/badge/捐赠-我-pink"></a>
<img alt="语言" src="https://img.shields.io/badge/语言-dart-cyan">
<img alt="license" src="https://img.shields.io/badge/证书-GPLv3-yellow">
<a href="https://deepwiki.com/lollipopkit/flutter_server_box"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
</div>
<p align="center">
使用 Flutter 开发的 Linux, Unix, Windows 服务器工具箱,提供服务器状态图表和管理工具。
使用 Flutter 开发的 <a href="../../issues/43">Linux</a> 服务器工具箱,提供服务器状态图表和管理工具。
<br>
特别感谢 <a href="https://github.com/TerminalStudio/dartssh2">dartssh2</a> & <a href="https://github.com/TerminalStudio/xterm.dart">xterm.dart</a>
</p>
## 🏙️ 截屏
## 🏙️ 截屏
<table>
<tr>
<td><img width="200px" src="https://cdn.lpkt.cn/serverbox/screenshot/1.jpg"></td>
@@ -26,19 +25,20 @@
</tr>
</table>
## 📥 安装
平台|下载
--|--
iOS / macOS | [AppStore](https://apps.apple.com/app/id1586449703)
Android | [GitHub](https://github.com/lollipopkit/flutter_server_box/releases) / [CDN](https://cdn.lpkt.cn/serverbox/pkg/?sort=time&order=desc&layout=grid) / [F-Droid](https://f-droid.org/packages/tech.lolli.toolbox) / [OpenAPK](https://www.openapk.net/serverbox/tech.lolli.toolbox/)
Linux / Windows | [GitHub](https://github.com/lollipopkit/flutter_server_box/releases) / [CDN](https://cdn.lpkt.cn/serverbox/pkg/?sort=time&order=desc&layout=grid)
平台 | 下载
--- | ---
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)
请从 **信任** 的来源下载!
## 🔖 特点
- `状态图表`CPU、传感器、GPU 等), `SSH` 终端, `SFTP`, `Docker & 进程 & Systemd` 管理,`S.M.A.R.T`...
## 🔖 特点
- `状态图表`CPU、传感器、GPU 等), `SSH` 终端, `SFTP`, `Docker & 进程 & Systemd` 管理...
- 特殊支持:`生物认证``推送``桌面小部件``watchOS App``跟随系统颜色`...
- 本地化
- 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);
- 感谢贡献者们!
## 🆘 帮助
<div align="center">
<a href="https://qm.qq.com/q/daCGa7eShG"><img alt="qq" src="https://img.shields.io/badge/QQ-群-pink"></a>
<a href="https://t.me/lpktg"><img alt="donate" src="https://img.shields.io/badge/Telegram-lpktg-green"></a>
<a href="https://discord.gg/SsVNbRhK7w"><img alt="discord" src="https://img.shields.io/badge/Discord-lpkt-purple"></a>
</div>
@@ -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/主页) 查看。
反馈前须知:
1. 反馈问题请附带 log点击首页右上角并以 bug 模版提交。
2. 反馈问题前请检查是否是 serverbox 的问题。
3. 欢迎所有有效、正面的反馈主观比如你觉得其他UI更好看的反馈不一定会接受
## 🧱 贡献
## 🧱 贡献
任何正面的贡献都欢迎。
如果我忘记在贡献者列表中添加你的名字,请在你打开的 issue 或 PR 中添加评论让我知道,我会尽快添加。
### 开发
1. 安装 [Flutter](https://flutter.dev/docs/get-started/install)
2. 克隆这个仓库, 运行 `flutter run` 启动应用
3. 运行 `dart run fl_build -p PLATFORM` 构建应用
### 翻译
[指南](https://blog.lpkt.cn/faq/) 可在我的博客中找到。
[指南](https://blog.lolli.tech/faq/) 可在我的博客中找到。
## 💡 我的其它 Apps
- [GPT Box](https://github.com/lollipopkit/flutter_gpt_box) - 支持 OpenAI API 的 第三方全平台客户端。
- [更多](https://github.com/lollipopkit) - 工具 & etc.
## 📝 协议
## 📝 协议
`GPL v3 lollipopkit`

View File

@@ -11,13 +11,11 @@ include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- "**/*.g.dart"
- '**/*.g.dart'
language:
# strict-casts: true
# strict-inference: true
# strict-raw-types: true
errors:
invalid_annotation_target: ignore
linter:
# The lint rules applied to this project can be customized in the
@@ -43,9 +41,8 @@ linter:
annotate_overrides: true
avoid_empty_else: true
# 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
directives_ordering: true # Enable sorting of imports
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

View File

@@ -85,20 +85,13 @@ android {
}
debug {
// No applicationIdSuffix or resValue here
applicationIdSuffix '.debug'
}
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 {
@@ -113,7 +106,7 @@ android.applicationVariants.all { variant ->
variant.outputs.each { output ->
def abiVersionCode = project.ext.abiCodes.get(output.getFilter(OutputFile.ABI))
if (abiVersionCode != null) {
output.versionCodeOverride = variant.versionCode * 100 + abiVersionCode
output.versionCodeOverride = variant.versionCode * 10 + abiVersionCode
}
}
}

View File

@@ -11,11 +11,10 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:label="@string/app_name"
android:label="ServerBox"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:allowBackup="true"
android:hasFragileUserData="true"
android:restoreAnyVersion="true"
tools:targetApi="q">
@@ -24,7 +23,7 @@
android:exported="true"
android:launchMode="singleTop"
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:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
@@ -46,15 +45,6 @@
android:name="flutterEmbedding"
android:value="2" />
<activity
android:name=".widget.WidgetConfigureActivity"
android:exported="false"
android:theme="@android:style/Theme.Material.Light.Dialog">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<receiver
android:name=".widget.HomeWidget"
android:exported="false"

View File

@@ -2,319 +2,87 @@ package tech.lolli.toolbox
import android.app.*
import android.content.Intent
import android.content.pm.ServiceInfo
import android.graphics.drawable.Icon
import android.os.Build
import android.os.IBinder
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.*
class ForegroundService : Service() {
companion object {
@Volatile
var isRunning: Boolean = false
}
private val chanId = "ForegroundServiceChannel"
private val NOTIFICATION_ID = 1000
private val ACTION_STOP_FOREGROUND = "ACTION_STOP_FOREGROUND"
private val ACTION_UPDATE_SESSIONS = "tech.lolli.toolbox.ACTION_UPDATE_SESSIONS"
private val ACTION_DISCONNECT_SESSION = "tech.lolli.toolbox.ACTION_DISCONNECT_SESSION"
private var isFgStarted = false
private val postedIds = mutableSetOf<Int>()
// Stable mapping from session-id -> notification-id to avoid hash collisions
private val notificationIdMap = mutableMapOf<String, Int>()
private val nextNotificationId = java.util.concurrent.atomic.AtomicInteger(2001)
private fun logError(message: String, error: Throwable? = null) {
Log.e("ForegroundService", message, error)
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() {
super.onCreate()
Log.d("ForegroundService", "Service onCreate")
isRunning = true
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
try {
// Check notification permission for Android 13+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
androidx.core.content.ContextCompat.checkSelfPermission(
this, android.Manifest.permission.POST_NOTIFICATIONS
) != android.content.pm.PackageManager.PERMISSION_GRANTED
) {
Log.w("ForegroundService", "Notification permission denied. Stopping service gracefully.")
// Don't call stopForegroundService() here as we haven't started foreground yet
stopSelf()
when (intent?.action) {
"ACTION_STOP_FOREGROUND" -> {
stopForegroundService()
return START_NOT_STICKY
}
if (intent == null) {
Log.w("ForegroundService", "onStartCommand called with null intent")
// Don't call stopForegroundService() here as we haven't started foreground yet
stopSelf()
return START_NOT_STICKY
else -> {
val notification = createNotification()
startForeground(1, notification)
return START_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
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
val manager = getSystemService(NotificationManager::class.java)
if (manager == null) {
Log.e("ForegroundService", "Failed to get NotificationManager")
return
}
val serviceChannel = NotificationChannel(
chanId,
"ForegroundServiceChannel",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "For foreground service"
}
manager.createNotificationChannel(serviceChannel)
Log.d("ForegroundService", "Notification channel created successfully")
} catch (e: Exception) {
logError("Failed to create notification channel", e)
}
val serviceChannel = NotificationChannel(
chanId,
chanId,
NotificationManager.IMPORTANCE_DEFAULT
)
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(serviceChannel)
}
}
private fun ensureForeground(notification: Notification) {
try {
// Double-check notification permission before starting foreground service
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
androidx.core.content.ContextCompat.checkSelfPermission(
this, android.Manifest.permission.POST_NOTIFICATIONS
) != android.content.pm.PackageManager.PERMISSION_GRANTED
) {
Log.w("ForegroundService", "Cannot start foreground service without notification permission")
stopSelf()
return
}
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 {
private fun createNotification(): Notification {
val notificationIntent = Intent(this, MainActivity::class.java)
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)
.setContentTitle("Server Box")
.setContentText("Open the app")
.setSmallIcon(R.mipmap.ic_launcher)
.setContentIntent(pendingIntent)
.addAction(android.R.drawable.ic_delete, "Stop", deletePendingIntent)
.build()
} else {
@Suppress("DEPRECATION")
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) {
val nm = getSystemService(NotificationManager::class.java)
if (nm == null) {
logError("NotificationManager null")
return
}
val sessions = mutableListOf<SessionItem>()
try {
val obj = JSONObject(payload)
val arr: JSONArray = obj.optJSONArray("sessions") ?: JSONArray()
for (i in 0 until arr.length()) {
val s = arr.optJSONObject(i) ?: continue
val id = s.optString("id")
val title = s.optString("title")
val sub = s.optString("subtitle")
val whenMs = s.optLong("startTimeMs", System.currentTimeMillis())
val status = s.optString("status", "connected")
if (id.isNotEmpty()) {
sessions.add(SessionItem(id, title, sub, whenMs, status))
}
}
} catch (e: Exception) {
logError("Failed to parse payload", e)
}
// Clear if empty
if (sessions.isEmpty()) {
clearAll()
return
}
// Cancel any existing individual notifications (we only show merged notification now)
val toCancel = postedIds.toSet()
toCancel.forEach { nm.cancel(it) }
postedIds.clear()
notificationIdMap.clear()
// Create merged notification content
val summaryLines = sessions.map { "${it.title}: ${it.status}" }
val mergedNotification = createMergedNotification(sessions.size, summaryLines, sessions)
ensureForeground(mergedNotification)
}
private fun clearAll() {
val nm = getSystemService(NotificationManager::class.java)
nm?.cancel(NOTIFICATION_ID)
postedIds.forEach { id -> nm?.cancel(id) }
postedIds.clear()
isFgStarted = false
}
data class SessionItem(
val id: String,
val title: String,
val subtitle: String,
val startWhen: Long,
val status: String,
)
private fun stopForegroundService() {
try {
if (isFgStarted) {
stopForeground(STOP_FOREGROUND_REMOVE)
isFgStarted = false
}
} catch (e: Exception) {
logError("Error stopping foreground", e)
}
fun stopForegroundService() {
stopForeground(true)
stopSelf()
Log.d("ForegroundService", "ForegroundService stopped")
}
override fun onDestroy() {
super.onDestroy()
Log.d("ForegroundService", "Service onDestroy")
isRunning = false
}
}
}

View File

@@ -4,57 +4,31 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.Manifest
import android.content.BroadcastReceiver
import android.content.Context
import android.content.IntentFilter
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import android.appwidget.AppWidgetManager
import tech.lolli.toolbox.widget.HomeWidget
class MainActivity: FlutterFragmentActivity() {
private lateinit var channel: MethodChannel
private val ACTION_UPDATE_SESSIONS = "tech.lolli.toolbox.ACTION_UPDATE_SESSIONS"
private val ACTION_DISCONNECT_SESSION = "tech.lolli.toolbox.ACTION_DISCONNECT_SESSION"
private val ACTION_STOP_ALL_CONNECTIONS = "tech.lolli.toolbox.STOP_ALL_CONNECTIONS"
private var stopAllReceiver: BroadcastReceiver? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
val binaryMessenger = flutterEngine.dartExecutor.binaryMessenger
channel = MethodChannel(binaryMessenger, "tech.lolli.toolbox/main_chan")
channel.setMethodCallHandler { method, result ->
MethodChannel(binaryMessenger, "tech.lolli.toolbox/app_retain").apply {
setMethodCallHandler { method, result ->
when (method.method) {
"sendToBackground" -> {
moveTaskToBack(true)
result.success(null)
}
"isServiceRunning" -> {
result.success(ForegroundService.isRunning)
}
"startService" -> {
try {
reqPerm()
if (!notificationsAllowed()) {
// Don't start foreground service without notification permission on API 33+
result.error("NOTIFICATION_PERMISSION_DENIED", "Notification permission not granted", null)
return@setMethodCallHandler
}
val serviceIntent = Intent(this@MainActivity, ForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent)
} 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)
reqPerm()
val serviceIntent = Intent(this@MainActivity, ForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent)
} else {
startService(serviceIntent)
}
}
"stopService" -> {
@@ -62,144 +36,23 @@ class MainActivity: FlutterFragmentActivity() {
stopService(serviceIntent)
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 -> {
result.notImplemented()
}
}
}
}
// Handle intent if launched via notification action
handleActionIntent(intent)
// Register broadcast receiver for stop all connections
setupStopAllReceiver()
}
private fun reqPerm() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
try {
// Check if we already have the permission to avoid unnecessary prompts
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
// Check if we should show rationale
if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.POST_NOTIFICATIONS)) {
android.util.Log.i("MainActivity", "User previously denied notification permission")
}
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
123,
)
}
} catch (e: Exception) {
// Log error but don't crash
android.util.Log.e("MainActivity", "Failed to request permissions: ${e.message}")
}
}
private fun notificationsAllowed(): Boolean {
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
true
} else {
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleActionIntent(intent)
}
private fun handleActionIntent(intent: Intent?) {
if (intent == null) return
when (intent.action) {
ACTION_DISCONNECT_SESSION -> {
val sessionId = intent.getStringExtra("session_id")
if (sessionId != null && ::channel.isInitialized) {
try {
channel.invokeMethod("disconnectSession", mapOf("id" to sessionId))
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to invoke disconnect: ${e.message}")
}
}
}
}
}
private fun 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
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
123,
)
}
}
}

View File

@@ -6,216 +6,109 @@ import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import android.view.View
import android.widget.RemoteViews
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONObject
import org.json.JSONException
import tech.lolli.toolbox.R
import java.net.URL
import java.net.HttpURLConnection
import java.net.SocketTimeoutException
import java.io.FileNotFoundException
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
class HomeWidget : AppWidgetProvider() {
companion object {
private const val TAG = "HomeWidget"
private const val NETWORK_TIMEOUT = 10_000L // 10 seconds
private const val COROUTINE_TIMEOUT = 15_000L // 15 seconds
private val activeUpdates = ConcurrentHashMap<Int, Boolean>()
}
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
for (appWidgetId in appWidgetIds) {
updateAppWidget(context, appWidgetManager, appWidgetId)
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) {
// Prevent concurrent updates for the same widget
if (activeUpdates.putIfAbsent(appWidgetId, true) == true) {
Log.d(TAG, "Widget $appWidgetId is already updating, skipping")
return
val views = RemoteViews(context.packageName, R.layout.home_widget)
val sp = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)
var url = sp.getString("widget_$appWidgetId", null)
if (url.isNullOrEmpty()) {
url = sp.getString("$appWidgetId", null)
}
if (url.isNullOrEmpty()) {
val gUrl = sp.getString("widget_*", null)
url = gUrl
}
val views = RemoteViews(context.packageName, R.layout.home_widget)
val url = getWidgetUrl(context, appWidgetId)
val intentUpdate = Intent(context, HomeWidget::class.java)
intentUpdate.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
val ids = intArrayOf(appWidgetId)
intentUpdate.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
var flag = PendingIntent.FLAG_UPDATE_CURRENT
if (Build.VERSION_CODES.O <= Build.VERSION.SDK_INT) {
flag = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
}
val pendingUpdate: PendingIntent = PendingIntent.getBroadcast(
context,
appWidgetId,
intentUpdate,
flag)
views.setOnClickPendingIntent(R.id.widget_container, pendingUpdate)
if (url.isNullOrEmpty()) {
Log.w(TAG, "URL not found for widget $appWidgetId")
showErrorState(views, appWidgetManager, appWidgetId, "Please configure the widget URL.")
activeUpdates.remove(appWidgetId)
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")
appWidgetManager.updateAppWidget(appWidgetId, views)
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 {
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)
views.setOnClickPendingIntent(R.id.widget_container, pendingConfigure)
}
GlobalScope.launch(Dispatchers.IO) {
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) {
var connection: HttpURLConnection? = null
try {
connection = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = NETWORK_TIMEOUT.toInt()
readTimeout = NETWORK_TIMEOUT.toInt()
setRequestProperty("User-Agent", "ServerBox-Widget/1.0")
setRequestProperty("Accept", "application/json")
GlobalScope.launch(Dispatchers.Main) main@ {
// mem or disk is empty -> get status failed
// (cpu | net) isEmpty -> data is not ready
if (mem.isEmpty() || disk.isEmpty()) {
return@main
}
views.setTextViewText(R.id.widget_name, server)
views.setTextViewText(R.id.widget_cpu, cpu)
views.setTextViewText(R.id.widget_mem, mem)
views.setTextViewText(R.id.widget_disk, disk)
views.setTextViewText(R.id.widget_net, net)
val timeStr = android.text.format.DateFormat.format("HH:mm", java.util.Date()).toString()
views.setTextViewText(R.id.widget_time, timeStr)
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
)
}

View File

@@ -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()
}
}
}

View File

@@ -10,204 +10,139 @@
<TextView
android:id="@+id/widget_name"
android:layout_width="match_parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/widgetText"
android:textSize="20sp"
android:textSize="23sp"
android:textStyle="bold"
android:maxLines="1"
android:ellipsize="end"
android:alpha="0"
android:animateLayoutChanges="true"
android:fadingEdge="horizontal"
android:singleLine="true"
tools:text="Server Name" />
<!-- Wrap the content in a LinearLayout for easy visibility management -->
<LinearLayout
android:id="@+id/widget_content"
<RelativeLayout
android:id="@+id/widget_container_inner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_below="@id/widget_name"
android:layout_marginTop="8dp">
android:layout_height="match_parent"
android:gravity="center_vertical"
android:paddingTop="13dp">
<RelativeLayout
android:id="@+id/widget_container_inner"
android:layout_width="match_parent"
<LinearLayout
android:id="@+id/widget_cpu_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:animateLayoutChanges="true">
android:paddingBottom="2.7dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<LinearLayout
android:id="@+id/widget_cpu_label"
<ImageView
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_height="wrap_content"
android:layout_marginBottom="4dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:alpha="0"
android:animateLayoutChanges="true">
<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%" />
android:layout_marginStart="11dp"
android:singleLine="true"
android:ellipsize = "marquee"
android:textColor="@color/widgetSummaryText"
android:textSize="12.7sp"
tools:text="CPU" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/widget_mem_label"
<LinearLayout
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_height="wrap_content"
android:layout_marginBottom="4dp"
android:layout_below="@id/widget_cpu_label"
android:gravity="center_vertical"
android:orientation="horizontal"
android:alpha="0"
android:animateLayoutChanges="true">
android:layout_marginStart="11dp"
android:maxLines="1"
android:textColor="@color/widgetSummaryText"
android:textSize="12.7sp"
tools:text="Mem" />
<ImageView
android:layout_width="16dp"
android:layout_height="16dp"
android:src="@drawable/memory_24"
android:layout_gravity="center_vertical"
android:contentDescription="Memory usage" />
</LinearLayout>
<TextView
android:id="@+id/widget_mem"
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="Memory: 4.2GB / 8GB" />
<LinearLayout
android:id="@+id/widget_disk_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingBottom="2.7dp"
android:layout_below="@id/widget_mem_label"
android:gravity="center_vertical"
android:orientation="horizontal">
</LinearLayout>
<ImageView
android:layout_width="17dp"
android:layout_height="17dp"
android:src="@drawable/storage_24">
</ImageView>
<LinearLayout
android:id="@+id/widget_disk_label"
<TextView
android:id="@+id/widget_disk"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:layout_below="@id/widget_mem_label"
android:gravity="center_vertical"
android:orientation="horizontal"
android:alpha="0"
android:animateLayoutChanges="true">
android:layout_marginStart="11dp"
android:maxLines="1"
android:textColor="@color/widgetSummaryText"
android:textSize="12.7sp"
tools:text="Disk" />
<ImageView
android:layout_width="16dp"
android:layout_height="16dp"
android:src="@drawable/storage_24"
android:layout_gravity="center_vertical"
android:contentDescription="Disk usage" />
</LinearLayout>
<TextView
android:id="@+id/widget_disk"
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="Disk: 125GB / 250GB" />
<LinearLayout
android:id="@+id/widget_net_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/widget_disk_label"
android:gravity="center_vertical"
android:orientation="horizontal">
</LinearLayout>
<ImageView
android:layout_width="17dp"
android:layout_height="17dp"
android:src="@drawable/net_24">
</ImageView>
<LinearLayout
android:id="@+id/widget_net_label"
<TextView
android:id="@+id/widget_net"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/widget_disk_label"
android:gravity="center_vertical"
android:orientation="horizontal"
android:alpha="0"
android:animateLayoutChanges="true">
android:layout_marginStart="11dp"
android:maxLines="1"
android:textColor="@color/widgetSummaryText"
android:textSize="12.7sp"
tools:text="Net" />
<ImageView
android:layout_width="16dp"
android:layout_height="16dp"
android:src="@drawable/net_24"
android:layout_gravity="center_vertical"
android:contentDescription="Network usage" />
</LinearLayout>
<TextView
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" />
</RelativeLayout>
<TextView
android:id="@+id/widget_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_alignParentEnd="true"
android:maxLines="1"
android:maxLines="2"
android:textColor="@color/widgetSummaryText"
android:textSize="10sp"
android:alpha="0"
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" />
android:textSize="11sp"
tools:text="UpdateTime" />
</RelativeLayout>

View File

@@ -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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 761 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 411 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 895 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">ServerBox</string>
</resources>

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<exclude domain="sharedpref" path="FlutterSecureStorage"/>
</full-backup-content>

View File

@@ -6,7 +6,6 @@
android:minHeight="110dp"
android:updatePeriodMillis="1800001"
android:initialLayout="@layout/home_widget"
android:configure="tech.lolli.toolbox.widget.WidgetConfigureActivity"
android:resizeMode="none"
android:widgetCategory="home_screen">
</appwidget-provider>

View File

@@ -9,23 +9,6 @@ rootProject.buildDir = '../build'
subprojects {
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 {
project.evaluationDependsOn(':app')
}

View File

@@ -1,6 +1,3 @@
org.gradle.jvmargs=-Xmx4G
android.useAndroidX=true
android.enableJetifier=true
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=false
android.nonFinalResIds=false

View File

@@ -2,4 +2,5 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
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

View File

@@ -19,8 +19,8 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version '8.6.0' apply false
id "org.jetbrains.kotlin.android" version "2.1.21" apply false
id "com.android.application" version "7.4.2" apply false
id "org.jetbrains.kotlin.android" version "1.8.10" apply false
}
include ":app"

File diff suppressed because it is too large Load Diff

View File

@@ -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:

Submodule flutter_server_box.wiki deleted from f440010313

View File

@@ -21,6 +21,6 @@
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
<string>12.0</string>
</dict>
</plist>

View File

@@ -1,5 +1,5 @@
# 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.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View File

@@ -1,14 +1,12 @@
PODS:
- app_links (6.4.1):
- app_links (0.0.2):
- Flutter
- camera_avfoundation (0.0.1):
- Flutter
- file_picker (0.0.1):
- Flutter
- Flutter (1.0.0)
- flutter_native_splash (2.4.3):
- Flutter
- flutter_secure_storage (6.0.0):
- flutter_native_splash (0.0.1):
- Flutter
- icloud_storage (0.0.1):
- Flutter
@@ -33,6 +31,8 @@ PODS:
- Flutter
- watch_connectivity (0.0.1):
- Flutter
- webview_flutter_wkwebview (0.0.1):
- Flutter
DEPENDENCIES:
- app_links (from `.symlinks/plugins/app_links/ios`)
@@ -40,7 +40,6 @@ DEPENDENCIES:
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- Flutter (from `Flutter`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- icloud_storage (from `.symlinks/plugins/icloud_storage/ios`)
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
@@ -51,6 +50,7 @@ DEPENDENCIES:
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
- watch_connectivity (from `.symlinks/plugins/watch_connectivity/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
EXTERNAL SOURCES:
app_links:
@@ -63,8 +63,6 @@ EXTERNAL SOURCES:
:path: Flutter
flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
icloud_storage:
:path: ".symlinks/plugins/icloud_storage/ios"
local_auth_darwin:
@@ -85,25 +83,27 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/wakelock_plus/ios"
watch_connectivity:
:path: ".symlinks/plugins/watch_connectivity/ios"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/ios"
SPEC CHECKSUMS:
app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a
camera_avfoundation: be3be85408cd4126f250386828e9b1dfa40ab436
file_picker: fb04e739ae6239a76ce1f571863a196a922c87d4
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
icloud_storage: e55639f0c0d7cb2b0ba9c0b3d5968ccca9cd9aa2
local_auth_darwin: d2e8c53ef0c4f43c646462e3415432c4dab3ae19
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
plain_notification_token: 047876b9d80a5b93565ddcc13a487a7e7b906f7d
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
watch_connectivity: 88e5bea25b473e66ef8d3f960954d154ed0356d6
app_links: e7a6750a915a9e161c58d91bc610e8cd1d4d0ad0
camera_avfoundation: dd002b0330f4981e1bbcb46ae9b62829237459a4
file_picker: c79185e70b9b45728cde2a8d8da454e0cb43f287
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
icloud_storage: d9ac7a33ced81df08ba7ea1bf3099cc0ee58f60a
local_auth_darwin: 66e40372f1c29f383a314c738c7446e2f7fdadc3
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
plain_notification_token: b36467dc91939a7b6754267c701bbaca14996ee1
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1
watch_connectivity: 715eb484685e05846eab74795348a44bb2809b82
webview_flutter_wkwebview: 2a23822e9039b7b1bc52e5add778e5d89ad488d1
PODFILE CHECKSUM: 5a0fb6438066e44ab2c77bd223668d351b8d8461
PODFILE CHECKSUM: ec6ef69056f066e8b21a3391082f23b5ad2d37f8
COCOAPODS: 1.16.2
COCOAPODS: 1.15.2

View File

@@ -9,10 +9,6 @@
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
4A2DCD6B2E4B127100CF68B7 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */; };
4A2DCD6C2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */; };
4A2DCD6F2E4B128100CF68B7 /* TerminalLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD6D2E4B128100CF68B7 /* TerminalLiveActivity.swift */; };
4A2DCD702E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD6E2E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7538AEC32BB83FAB002AB82A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7538AEC22BB83FAB002AB82A /* PrivacyInfo.xcprivacy */; };
7538AEC52BB83FC8002AB82A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7538AEC42BB83FC8002AB82A /* PrivacyInfo.xcprivacy */; };
@@ -40,8 +36,6 @@
E3AE8AEB2AB601DB000A6459 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AE8AE92AB601DB000A6459 /* Utils.swift */; };
E3AE8AEC2AB601DB000A6459 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AE8AE92AB601DB000A6459 /* Utils.swift */; };
E3DB67ED2A31FE200027B8CB /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E3DB67EB2A31FE200027B8CB /* LaunchScreen.storyboard */; };
F0A1B2C31A2B3C4D5E6F0005 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = F0A1B2C31A2B3C4D5E6F0001 /* Localizable.strings */; };
F0A1B2C31A2B3C4D5E6F1005 /* Localizable.strings (StatusWidget) in Resources */ = {isa = PBXBuildFile; fileRef = F0A1B2C31A2B3C4D5E6F1001 /* Localizable.strings */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -101,10 +95,6 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
278C1EB3935F9285537B0516 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; };
4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLiveActivityAttributes.swift; sourceTree = "<group>"; };
4A2DCD6D2E4B128100CF68B7 /* TerminalLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLiveActivity.swift; sourceTree = "<group>"; };
4A2DCD6E2E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLiveActivityAttributes.swift; sourceTree = "<group>"; };
5A4B3EB10512B2EB8E10213B /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@@ -166,26 +156,6 @@
E3D26BD22B9966EC00D83425 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Main.strings; sourceTree = "<group>"; };
E3D26BD32B9966EC00D83425 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/LaunchScreen.strings; sourceTree = "<group>"; };
E3DB67EC2A31FE200027B8CB /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F0002 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F0003 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F0004 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F0006 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F0007 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F0008 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F0009 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F000A /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F000B /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F000C /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F1002 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F1003 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F1004 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F1006 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F1007 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F1008 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F1009 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F100A /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F100B /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F100C /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -263,7 +233,6 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
F0A1B2C31A2B3C4D5E6F0001 /* Localizable.strings */,
7538AEC22BB83FAB002AB82A /* PrivacyInfo.xcprivacy */,
E398BF6A29BDB34500FE4FD5 /* Runner.entitlements */,
97C146FA1CF9000F007C117D /* Main.storyboard */,
@@ -273,8 +242,6 @@
E39A76AD2AB9A2F70067C641 /* Info-Profile.plist */,
E39A76AC2AB9A2F70067C641 /* Info-Release.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */,
4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
E3AE8AE92AB601DB000A6459 /* Utils.swift */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
@@ -296,11 +263,8 @@
E33A3E3A2A626DCE009744AB /* StatusWidget */ = {
isa = PBXGroup;
children = (
F0A1B2C31A2B3C4D5E6F1001 /* Localizable.strings */,
7538AEC42BB83FC8002AB82A /* PrivacyInfo.xcprivacy */,
E33A3E3B2A626DCE009744AB /* StatusWidgetBundle.swift */,
4A2DCD6D2E4B128100CF68B7 /* TerminalLiveActivity.swift */,
4A2DCD6E2E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift */,
E33A3E3F2A626DCE009744AB /* StatusWidget.swift */,
E37C48ED2B9C30EE00E542D2 /* StatusWidget.intentdefinition */,
E33A3E442A626DD0009744AB /* Info.plist */,
@@ -448,7 +412,6 @@
E39A76B02AB9A2F70067C641 /* Info-Profile.plist in Resources */,
7538AEC32BB83FAB002AB82A /* PrivacyInfo.xcprivacy in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
F0A1B2C31A2B3C4D5E6F0005 /* Localizable.strings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -457,7 +420,6 @@
buildActionMask = 2147483647;
files = (
7538AEC52BB83FC8002AB82A /* PrivacyInfo.xcprivacy in Resources */,
F0A1B2C31A2B3C4D5E6F1005 /* Localizable.strings (StatusWidget) in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -554,8 +516,6 @@
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
E37C48EA2B9C30EE00E542D2 /* StatusWidget.intentdefinition in Sources */,
E3AE8AEA2AB601DB000A6459 /* Utils.swift in Sources */,
4A2DCD6B2E4B127100CF68B7 /* LiveActivityManager.swift in Sources */,
4A2DCD6C2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -565,8 +525,6 @@
files = (
E33A3E402A626DCE009744AB /* StatusWidget.swift in Sources */,
E37C48EB2B9C30EE00E542D2 /* StatusWidget.intentdefinition in Sources */,
4A2DCD6F2E4B128100CF68B7 /* TerminalLiveActivity.swift in Sources */,
4A2DCD702E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */,
E33A3E3C2A626DCE009744AB /* StatusWidgetBundle.swift in Sources */,
E3AE8AEB2AB601DB000A6459 /* Utils.swift in Sources */,
);
@@ -652,40 +610,6 @@
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
F0A1B2C31A2B3C4D5E6F0001 /* Localizable.strings */ = {
isa = PBXVariantGroup;
children = (
F0A1B2C31A2B3C4D5E6F0002 /* en */,
F0A1B2C31A2B3C4D5E6F0003 /* zh-Hans */,
F0A1B2C31A2B3C4D5E6F0004 /* zh-Hant */,
F0A1B2C31A2B3C4D5E6F0006 /* fr */,
F0A1B2C31A2B3C4D5E6F0007 /* ru */,
F0A1B2C31A2B3C4D5E6F0008 /* es */,
F0A1B2C31A2B3C4D5E6F0009 /* de */,
F0A1B2C31A2B3C4D5E6F000A /* pt-BR */,
F0A1B2C31A2B3C4D5E6F000B /* id */,
F0A1B2C31A2B3C4D5E6F000C /* ja */,
);
name = Localizable.strings;
sourceTree = "<group>";
};
F0A1B2C31A2B3C4D5E6F1001 /* Localizable.strings */ = {
isa = PBXVariantGroup;
children = (
F0A1B2C31A2B3C4D5E6F1002 /* en */,
F0A1B2C31A2B3C4D5E6F1003 /* zh-Hans */,
F0A1B2C31A2B3C4D5E6F1004 /* zh-Hant */,
F0A1B2C31A2B3C4D5E6F1006 /* fr */,
F0A1B2C31A2B3C4D5E6F1007 /* ru */,
F0A1B2C31A2B3C4D5E6F1008 /* es */,
F0A1B2C31A2B3C4D5E6F1009 /* de */,
F0A1B2C31A2B3C4D5E6F100A /* pt-BR */,
F0A1B2C31A2B3C4D5E6F100B /* id */,
F0A1B2C31A2B3C4D5E6F100C /* ja */,
);
name = Localizable.strings;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
@@ -731,7 +655,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -748,17 +672,17 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 1256;
CURRENT_PROJECT_VERSION = 1104;
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1256;
MARKETING_VERSION = 1.0.1104;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -815,7 +739,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -865,7 +789,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -884,17 +808,17 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 1256;
CURRENT_PROJECT_VERSION = 1104;
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1256;
MARKETING_VERSION = 1.0.1104;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -912,17 +836,17 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 1256;
CURRENT_PROJECT_VERSION = 1104;
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1256;
MARKETING_VERSION = 1.0.1104;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -943,7 +867,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1256;
CURRENT_PROJECT_VERSION = 1104;
DEVELOPMENT_TEAM = BA88US33G6;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
@@ -956,7 +880,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.1256;
MARKETING_VERSION = 1.0.1104;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
@@ -982,7 +906,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1256;
CURRENT_PROJECT_VERSION = 1104;
DEVELOPMENT_TEAM = BA88US33G6;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
@@ -995,7 +919,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.1256;
MARKETING_VERSION = 1.0.1104;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -1018,7 +942,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1256;
CURRENT_PROJECT_VERSION = 1104;
DEVELOPMENT_TEAM = BA88US33G6;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
@@ -1031,7 +955,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.1256;
MARKETING_VERSION = 1.0.1104;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -1054,7 +978,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1256;
CURRENT_PROJECT_VERSION = 1104;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_PREVIEWS = YES;
@@ -1066,7 +990,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1256;
MARKETING_VERSION = 1.0.1104;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
@@ -1095,7 +1019,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1256;
CURRENT_PROJECT_VERSION = 1104;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_PREVIEWS = YES;
@@ -1107,7 +1031,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1256;
MARKETING_VERSION = 1.0.1104;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
PRODUCT_NAME = ServerBox;
@@ -1133,7 +1057,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1256;
CURRENT_PROJECT_VERSION = 1104;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_PREVIEWS = YES;
@@ -1145,7 +1069,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1256;
MARKETING_VERSION = 1.0.1104;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
PRODUCT_NAME = ServerBox;

View File

@@ -26,7 +26,6 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
@@ -44,13 +43,11 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">

View File

@@ -1,7 +1,6 @@
import UIKit
import WidgetKit
import Flutter
import ActivityKit
@main
@objc class AppDelegate: FlutterAppDelegate {
@@ -12,48 +11,14 @@ import ActivityKit
GeneratedPluginRegistrant.register(with: self)
let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
// Home widget channel (legacy)
let homeWidgetChannel = FlutterMethodChannel(name: "tech.lolli.toolbox/home_widget", binaryMessenger: controller.binaryMessenger)
homeWidgetChannel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
let methodChannel = FlutterMethodChannel(name: "tech.lolli.toolbox/home_widget", binaryMessenger: controller.binaryMessenger)
methodChannel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
if call.method == "update" {
if #available(iOS 14.0, *) {
WidgetCenter.shared.reloadTimelines(ofKind: "StatusWidget")
}
}
})
// Main channel for cross-platform calls (incl. Live Activities)
let mainChannel = FlutterMethodChannel(name: "tech.lolli.toolbox/main_chan", binaryMessenger: controller.binaryMessenger)
mainChannel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
switch call.method {
case "updateHomeWidget":
if #available(iOS 14.0, *) {
WidgetCenter.shared.reloadTimelines(ofKind: "StatusWidget")
}
result(nil)
case "startLiveActivity":
if #available(iOS 16.2, *) {
if let payload = call.arguments as? String {
LiveActivityManager.start(json: payload)
}
}
result(nil)
case "updateLiveActivity":
if #available(iOS 16.2, *) {
if let payload = call.arguments as? String {
LiveActivityManager.update(json: payload)
}
}
result(nil)
case "stopLiveActivity":
if #available(iOS 16.2, *) {
LiveActivityManager.stop()
}
result(nil)
default:
result(FlutterMethodNotImplemented)
}
})
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
@@ -65,11 +30,4 @@ import ActivityKit
}
return true
}
override func applicationWillTerminate(_ application: UIApplication) {
// Stop Live Activity when app is about to terminate
if #available(iOS 16.2, *) {
LiveActivityManager.stop()
}
}
}

View File

@@ -41,8 +41,6 @@
<array>
<string>ConfigurationIntent</string>
</array>
<key>NSSupportsLiveActivities</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true />
<key>UIBackgroundModes</key>
@@ -80,4 +78,4 @@
<key>NSPhotoLibraryUsageDescription</key>
<string>Get QR code and etc.</string>
</dict>
</plist>
</plist>

View File

@@ -17,8 +17,6 @@
<string>en</string>
<string>zh</string>
</array>
<key>NSSupportsLiveActivities</key>
<true/>
<key>CFBundleName</key>
<string>ServerBox</string>
<key>CFBundlePackageType</key>

View File

@@ -17,8 +17,6 @@
<string>en</string>
<string>zh</string>
</array>
<key>NSSupportsLiveActivities</key>
<true/>
<key>CFBundleName</key>
<string>ServerBox</string>
<key>CFBundlePackageType</key>
@@ -70,4 +68,4 @@
<key>NSPhotoLibraryUsageDescription</key>
<string>Get QR code and etc.</string>
</dict>
</plist>
</plist>

View File

@@ -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
}
}
}

View File

@@ -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
}
}

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -1,8 +0,0 @@
"Terminal" = "ターミナル";
"Connected" = "接続済み";
"Connecting" = "接続中";
"Disconnected" = "切断";
"Multiple SSH sessions active" = "複数の SSH セッションがアクティブ";
"1 connection" = "1 件の接続";
"%d connections" = "%d 件の接続";

View File

@@ -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";

View File

@@ -1,8 +0,0 @@
"Terminal" = "Терминал";
"Connected" = "Подключено";
"Connecting" = "Подключение";
"Disconnected" = "Отключено";
"Multiple SSH sessions active" = "Несколько активных сеансов SSH";
"1 connection" = "1 подключение";
"%d connections" = "%d подключений";

View File

@@ -1,8 +0,0 @@
"Terminal" = "终端";
"Connected" = "已连接";
"Connecting" = "连接中";
"Disconnected" = "已断开连接";
"Multiple SSH sessions active" = "多个 SSH 会话正在活动";
"1 connection" = "1 个连接";
"%d connections" = "%d 个连接";

View File

@@ -1,8 +0,0 @@
"Terminal" = "終端機";
"Connected" = "已連線";
"Connecting" = "連線中";
"Disconnected" = "已中斷連線";
"Multiple SSH sessions active" = "多個 SSH 連線運行中";
"1 connection" = "1 個連線";
"%d connections" = "%d 個連線";

View File

@@ -4,15 +4,6 @@
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>IntentsSupportedIntents</key>
<array>
<string>ConfigurationIntent</string>
</array>
<key>NSSupportsLiveActivities</key>
<true/>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>

View File

@@ -15,142 +15,6 @@ let demoStatus = Status(name: "Server", cpu: "31.7%", mem: "1.3g / 1.9g", disk:
let domain = "com.lollipopkit.toolbox"
let bgColor = DynamicColor(dark: UIColor.black, light: UIColor.white)
// Widget-specific constants
enum WidgetConstants {
enum Dimensions {
static let smallGauge: CGFloat = 56
static let mediumGauge: CGFloat = 64
static let largeGauge: CGFloat = 76
static let refreshIconSmall: CGFloat = 12
static let refreshIconLarge: CGFloat = 14
static let cornerRadius: CGFloat = 12
static let shadowRadius: CGFloat = 2
}
enum Thresholds {
static let warningThreshold: Double = 0.6
static let criticalThreshold: Double = 0.85
}
enum Spacing {
static let tight: CGFloat = 4
static let normal: CGFloat = 8
static let loose: CGFloat = 12
static let extraLoose: CGFloat = 16
}
enum Colors {
static let cardBackground = Color(.systemBackground)
static let secondaryText = Color(.secondaryLabel)
static let success = Color(.systemGreen)
static let warning = Color(.systemOrange)
static let critical = Color(.systemRed)
static let accent = Color(.systemBlue)
}
static let appGroupId = "group.com.lollipopkit.toolbox"
}
// Performance optimization: cache parsed values
struct ParseCache {
private static var percentCache: [String: Double] = [:]
private static var usagePercentCache: [String: Double] = [:]
static func parsePercent(_ text: String) -> Double {
if let cached = percentCache[text] { return cached }
let trimmed = text.trimmingCharacters(in: CharacterSet(charactersIn: "% "))
let result = Double(trimmed).map { max(0, min(1, $0 / 100.0)) } ?? 0
percentCache[text] = result
return result
}
static func parseUsagePercent(_ text: String) -> Double {
if let cached = usagePercentCache[text] { return cached }
let parts = text.split(separator: "/").map { String($0).trimmingCharacters(in: .whitespaces) }
guard parts.count == 2 else { return 0 }
let used = PerformanceUtils.parseSizeToBytes(parts[0])
let total = PerformanceUtils.parseSizeToBytes(parts[1])
let result = total <= 0 ? 0 : max(0, min(1, used / total))
usagePercentCache[text] = result
return result
}
static func parseNetworkTotal(_ text: String) -> (totalBytes: Double, displayText: String) {
let parts = text.split(separator: "/").map { String($0).trimmingCharacters(in: .whitespaces) }
guard parts.count == 2 else { return (0, "0 B") }
let upload = PerformanceUtils.parseSizeToBytes(parts[0])
let download = PerformanceUtils.parseSizeToBytes(parts[1])
let total = upload + download
let displayText = PerformanceUtils.formatSize(total)
return (total, displayText)
}
static func parseNetworkPercent(_ text: String) -> Double {
let parts = text.split(separator: "/").map { String($0).trimmingCharacters(in: .whitespaces) }
guard parts.count == 2 else { return 0 }
let upload = PerformanceUtils.parseSizeToBytes(parts[0])
let download = PerformanceUtils.parseSizeToBytes(parts[1])
let total = upload + download
// Return upload percentage of total traffic
return total <= 0 ? 0 : max(0, min(1, upload / total))
}
}
struct PerformanceUtils {
// Precomputed multipliers for performance
private static let sizeMultipliers: [Character: Double] = [
"k": 1024,
"m": pow(1024, 2),
"g": pow(1024, 3),
"t": pow(1024, 4),
"p": pow(1024, 5)
]
static func parseSizeToBytes(_ text: String) -> Double {
let lower = text.lowercased().replacingOccurrences(of: "b", with: "")
let unitChar = lower.trimmingCharacters(in: .whitespaces).last
let numberPart: String
let multiplier: Double
if let u = unitChar, let mult = sizeMultipliers[u] {
multiplier = mult
numberPart = String(lower.dropLast())
} else {
multiplier = 1.0
numberPart = lower
}
let value = Double(numberPart.trimmingCharacters(in: .whitespaces)) ?? 0
return value * multiplier
}
static func percentStr(_ value: Double) -> String {
let pct = max(0, min(1, value)) * 100
let rounded = (pct * 10).rounded() / 10
return rounded.truncatingRemainder(dividingBy: 1) == 0
? String(format: "%.0f%%", rounded)
: String(format: "%.1f%%", rounded)
}
static func thresholdColor(_ value: Double) -> Color {
let v = max(0, min(1, value))
switch v {
case ..<WidgetConstants.Thresholds.warningThreshold: return WidgetConstants.Colors.success
case ..<WidgetConstants.Thresholds.criticalThreshold: return WidgetConstants.Colors.warning
default: return WidgetConstants.Colors.critical
}
}
static func formatSize(_ bytes: Double) -> String {
let units = ["B", "KB", "MB", "GB", "TB"]
var size = bytes
var unitIndex = 0
while size >= 1024 && unitIndex < units.count - 1 {
size /= 1024
unitIndex += 1
}
return String(format: "%.1f %@", size, units[unitIndex])
}
}
struct Provider: IntentTimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), configuration: ConfigurationIntent(), state: .normal(demoStatus))
@@ -165,13 +29,11 @@ struct Provider: IntentTimelineProvider {
var url = configuration.url
let family = context.family
#if os(iOS)
if #available(iOSApplicationExtension 16.0, *) {
if family == .accessoryInline || family == .accessoryRectangular {
url = UserDefaults(suiteName: WidgetConstants.appGroupId)?.string(forKey: "accessory_widget_url")
url = UserDefaults.standard.string(forKey: accessoryKey)
}
}
#endif
let currentDate = Date()
let refreshDate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)!
@@ -249,7 +111,7 @@ struct StatusWidgetEntryView : View {
Button(intent: RefreshIntent()) {
Image(systemName: "arrow.clockwise")
.resizable()
.frame(width: WidgetConstants.Dimensions.refreshIconSmall, height: WidgetConstants.Dimensions.refreshIconSmall * 1.27)
.frame(width: 10, height: 12.7)
}.tint(.gray)
}
}
@@ -261,37 +123,6 @@ struct StatusWidgetEntryView : View {
case .normal(let data):
let sumColor: Color = .primary.opacity(0.7)
switch family {
case .systemMedium:
VStack(alignment: .leading, spacing: WidgetConstants.Spacing.normal) {
// Title + refresh
if #available(iOS 17.0, *) {
HStack {
Text(data.name).font(.system(.title3, design: .monospaced))
Spacer()
Button(intent: RefreshIntent()) {
Image(systemName: "arrow.clockwise")
.resizable()
.frame(width: WidgetConstants.Dimensions.refreshIconSmall, height: WidgetConstants.Dimensions.refreshIconSmall * 1.27)
}.tint(.gray)
}
} else {
Text(data.name).font(.system(.title3, design: .monospaced))
}
Spacer(minLength: WidgetConstants.Spacing.normal)
// Gauges row
HStack(spacing: WidgetConstants.Spacing.tight) {
GaugeTile(label: "CPU", value: ParseCache.parsePercent(data.cpu), display: data.cpu, diameter: WidgetConstants.Dimensions.smallGauge)
GaugeTile(label: "MEM", value: ParseCache.parseUsagePercent(data.mem), display: PerformanceUtils.percentStr(ParseCache.parseUsagePercent(data.mem)), diameter: WidgetConstants.Dimensions.smallGauge)
GaugeTile(label: "DISK", value: ParseCache.parseUsagePercent(data.disk), display: PerformanceUtils.percentStr(ParseCache.parseUsagePercent(data.disk)), diameter: WidgetConstants.Dimensions.smallGauge)
GaugeTile(label: "NET", value: ParseCache.parseNetworkPercent(data.net), display: ParseCache.parseNetworkTotal(data.net).displayText, diameter: WidgetConstants.Dimensions.smallGauge)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 3)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.autoPadding()
.widgetBackground()
#if os(iOS)
case .accessoryRectangular:
VStack(alignment: .leading, spacing: 2) {
HStack {
@@ -311,7 +142,6 @@ struct StatusWidgetEntryView : View {
.widgetBackground()
case .accessoryInline:
Text("\(data.name) \(data.cpu)").widgetBackground()
#endif
default:
VStack(alignment: .leading, spacing: 3.7) {
if #available(iOS 17.0, *) {
@@ -321,7 +151,7 @@ struct StatusWidgetEntryView : View {
Button(intent: RefreshIntent()) {
Image(systemName: "arrow.clockwise")
.resizable()
.frame(width: WidgetConstants.Dimensions.refreshIconSmall, height: WidgetConstants.Dimensions.refreshIconSmall * 1.27)
.frame(width: 10, height: 12.7)
}.tint(.gray)
}
} else {
@@ -332,6 +162,9 @@ struct StatusWidgetEntryView : View {
DetailItem(icon: "memorychip", text: data.mem, color: sumColor)
DetailItem(icon: "externaldrive", text: data.disk, color: sumColor)
DetailItem(icon: "network", text: data.net, color: sumColor)
Spacer()
DetailItem(icon: "clock", text: entry.date.toStr(), color: sumColor)
.padding(.bottom, 3)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.autoPadding()
@@ -344,16 +177,8 @@ struct StatusWidgetEntryView : View {
extension View {
@ViewBuilder
func widgetBackground() -> some View {
// Modern card-style background with subtle effects
let backgroundView = LinearGradient(
gradient: Gradient(colors: [
Color(bgColor.resolve()),
Color(bgColor.resolve()).opacity(0.95)
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
// Set bg to black in Night, white in Day
let backgroundView = Color(bgColor.resolve())
if #available(iOS 17.0, *) {
containerBackground(for: .widget) {
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 {
if #available(iOS 17.0, *) {
return self.padding(.all, WidgetConstants.Spacing.tight)
return self
} 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 {
@@ -397,15 +207,11 @@ struct StatusWidget: Widget {
}
.configurationDisplayName("Status")
.description("Status of your servers.")
#if os(iOS)
if #available(iOSApplicationExtension 16.0, *) {
return cfg.supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular, .accessoryInline])
if #available(iOSApplicationExtension 16.0, *) {
return cfg.supportedFamilies([.systemSmall, .accessoryRectangular, .accessoryInline])
} 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
var body: some View {
HStack(spacing: WidgetConstants.Spacing.normal) {
Image(systemName: icon)
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundColor(color.opacity(0.8))
.frame(width: 12, height: 12)
.background(
Circle()
.fill(color.opacity(0.1))
.frame(width: 20, height: 20)
)
HStack(spacing: 6.7) {
Image(systemName: icon).resizable().foregroundColor(color).frame(width: 11, height: 11, alignment: .center)
Text(text)
.font(.system(size: 12, weight: .medium, design: .rounded))
.font(.system(size: 11, design: .monospaced))
.foregroundColor(color)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.padding(.horizontal, WidgetConstants.Spacing.tight)
.padding(.vertical, 2)
}
}
// Enhanced circular progress indicator
//
struct CirclePercent: View {
// eg: 31.7%
let percent: String
@State private var animatedProgress: Double = 0
var body: some View {
// 31.7% -> 0.317
let percentD = Double(percent.trimmingCharacters(in: .init(charactersIn: "%")))
let progress = (percentD ?? 0) / 100
ZStack {
// Background circle
Circle()
.stroke(Color.primary.opacity(0.15), lineWidth: 2.5)
// Progress circle with gradient
Circle()
.trim(from: 0, to: CGFloat(max(0, min(1, animatedProgress))))
.stroke(
AngularGradient(
gradient: Gradient(colors: [
PerformanceUtils.thresholdColor(progress).opacity(0.7),
PerformanceUtils.thresholdColor(progress)
]),
center: .center
),
style: StrokeStyle(lineWidth: 3, lineCap: .round)
)
.rotationEffect(.degrees(-90))
// Percentage text
Text(percent)
.font(.system(size: 8, weight: .bold, design: .rounded))
.foregroundColor(.primary.opacity(0.8))
}
.frame(width: 24, height: 24)
.onAppear {
withAnimation(.easeOut(duration: 0.8).delay(0.2)) {
animatedProgress = progress
}
}
.onChange(of: progress) { newProgress in
withAnimation(.easeInOut(duration: 0.5)) {
animatedProgress = newProgress
}
}
let double = (percentD ?? 0) / 100
Circle()
.trim(from: 0, to: CGFloat(double))
.stroke(Color.primary, lineWidth: 3)
.animation(.easeInOut(duration: 0.5))
}
}
// 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 {
let dark: UIColor
let light: UIColor

View File

@@ -12,8 +12,5 @@ import SwiftUI
struct StatusWidgetBundle: WidgetBundle {
var body: some Widget {
StatusWidget()
if #available(iOSApplicationExtension 16.1, *) {
TerminalLiveActivity()
}
}
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -1,8 +0,0 @@
"Terminal" = "ターミナル";
"Connected" = "接続済み";
"Connecting" = "接続中";
"Disconnected" = "切断";
"Multiple SSH sessions active" = "複数の SSH セッションがアクティブ";
"1 connection" = "1 件の接続";
"%d connections" = "%d 件の接続";

View File

@@ -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";

View File

@@ -1,8 +0,0 @@
"Terminal" = "Терминал";
"Connected" = "Подключено";
"Connecting" = "Подключение";
"Disconnected" = "Отключено";
"Multiple SSH sessions active" = "Несколько активных сеансов SSH";
"1 connection" = "1 подключение";
"%d connections" = "%d подключений";

View File

@@ -1,8 +0,0 @@
"Terminal" = "终端";
"Connected" = "已连接";
"Connecting" = "连接中";
"Disconnected" = "已断开连接";
"Multiple SSH sessions active" = "多个 SSH 会话正在活动";
"1 connection" = "1 个连接";
"%d connections" = "%d 个连接";

View File

@@ -1,8 +0,0 @@
"Terminal" = "終端機";
"Connected" = "已連線";
"Connecting" = "連線中";
"Disconnected" = "已中斷連線";
"Multiple SSH sessions active" = "多個 SSH 連線運行中";
"1 connection" = "1 個連線";
"%d connections" = "%d 個連線";

View File

@@ -9,62 +9,22 @@ import SwiftUI
struct ContentView: View {
@ObservedObject var _mgr = PhoneConnMgr()
@State private var selection: Int = 0
@State private var refreshAllCounter: Int = 0
var body: some View {
let hasServers = !_mgr.urls.isEmpty
let pagesCount = hasServers ? _mgr.urls.count : 1
TabView(selection: $selection) {
ForEach(0 ..< pagesCount, id:\.self) { index in
let url = hasServers ? _mgr.urls[index] : nil
PageView(
url: url,
state: .loading,
refreshAllCounter: refreshAllCounter,
onRefreshAll: { refreshAllCounter += 1 }
)
.tag(index)
let _count = _mgr.urls.count == 0 ? 1 : _mgr.urls.count
TabView {
ForEach(0 ..< _count, id:\.self) { index in
let url = _count == 1 && _mgr.urls.count == 0 ? nil : _mgr.urls[index]
PageView(url: url, state: .loading)
}
}
.tabViewStyle(PageTabViewStyle())
// URL
.onChange(of: _mgr.urls) { newValue in
let newCount = newValue.count
//
if newCount == 0 {
selection = 0
} else if selection >= newCount {
//
selection = max(0, newCount - 1)
}
}
// Widget 使
.onChange(of: selection) { newIndex in
let appGroupId = "group.com.lollipopkit.toolbox"
if let defaults = UserDefaults(suiteName: appGroupId) {
defaults.set(newIndex, forKey: "watch_shared_selected_index")
}
}
.onAppear {
//
let appGroupId = "group.com.lollipopkit.toolbox"
let saved = UserDefaults(suiteName: appGroupId)?.integer(forKey: "watch_shared_selected_index") ?? 0
if !_mgr.urls.isEmpty {
selection = min(max(0, saved), _mgr.urls.count - 1)
} else {
selection = 0
}
}
}
}
struct PageView: View {
let url: String?
@State var state: ContentState
//
let refreshAllCounter: Int
let onRefreshAll: () -> Void
var body: some View {
if url == nil {
@@ -76,50 +36,35 @@ struct PageView: View {
Spacer()
}
} else {
Group {
switch state {
case .loading:
ProgressView().padding().onAppear {
getStatus(url: url!)
}
case .error(let err):
switch state {
case .loading:
ProgressView().padding().onAppear {
getStatus(url: url!)
}
case .error(let err):
switch err {
case .http(let description):
VStack(alignment: .center) {
Text(description)
HStack(spacing: 10) {
Button(action: {
state = .loading
}){
Image(systemName: "arrow.clockwise")
}.buttonStyle(.plain)
Button(action: {
onRefreshAll()
}){
Image(systemName: "arrow.triangle.2.circlepath")
}.buttonStyle(.plain)
}
Button(action: {
state = .loading
}){
Image(systemName: "arrow.clockwise")
}.buttonStyle(.plain)
}
case .url(_):
Link("View help", destination: helpUrl)
}
case .normal(let status):
VStack(alignment: .leading) {
case .normal(let status):
VStack(alignment: .leading) {
HStack {
Text(status.name).font(.system(.title, design: .monospaced))
Spacer()
HStack(spacing: 10) {
Button(action: {
state = .loading
}){
Image(systemName: "arrow.clockwise")
}.buttonStyle(.plain)
Button(action: {
onRefreshAll()
}){
Image(systemName: "arrow.triangle.2.circlepath")
}.buttonStyle(.plain)
}
Button(action: {
state = .loading
}){
Image(systemName: "arrow.clockwise")
}.buttonStyle(.plain)
}
Spacer()
DetailItem(icon: "cpu", text: status.cpu)
@@ -127,12 +72,6 @@ struct PageView: View {
DetailItem(icon: "externaldrive", text: status.disk)
DetailItem(icon: "network", text: status.net)
}.frame(maxWidth: .infinity, maxHeight: .infinity).padding([.horizontal], 11)
}
}
.onChange(of: refreshAllCounter) { _ in
if let url = url {
getStatus(url: url)
}
}
}
}
@@ -148,32 +87,25 @@ struct PageView: View {
return
}
let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
// UI 线 TabView
func setStateOnMain(_ newState: ContentState) {
DispatchQueue.main.async {
self.state = newState
}
}
if let error = error {
setStateOnMain(.error(.http(error.localizedDescription)))
guard error == nil else {
state = .error(.http(error!.localizedDescription))
return
}
guard let data = data else {
setStateOnMain(.error(.http("empty data")))
state = .error(.http("empty data"))
return
}
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
}
guard let code = jsonAll["code"] as? Int else {
setStateOnMain(.error(.http("code is nil")))
state = .error(.http("code is nil"))
return
}
if (code != 0) {
let msg = jsonAll["msg"] as? String ?? ""
setStateOnMain(.error(.http(msg)))
state = .error(.http(msg))
return
}
@@ -183,35 +115,10 @@ struct PageView: View {
let cpu = json["cpu"] as? String ?? ""
let mem = json["mem"] as? String ?? ""
let net = json["net"] as? String ?? ""
let status = Status(name: name, cpu: cpu, mem: mem, disk: disk, net: net)
setStateOnMain(.normal(status))
// App Group/ Widget 使
let appGroupId = "group.com.lollipopkit.toolbox"
if let defaults = UserDefaults(suiteName: appGroupId) {
var statusMap = (defaults.dictionary(forKey: "watch_shared_status_by_url") as? [String: [String: String]]) ?? [:]
statusMap[url.absoluteString] = [
"name": status.name,
"cpu": status.cpu,
"mem": status.mem,
"disk": status.disk,
"net": status.net
]
defaults.set(statusMap, forKey: "watch_shared_status_by_url")
}
state = .normal(Status(name: name, cpu: cpu, mem: mem, disk: disk, net: net))
}
task.resume()
}
//
@ViewBuilder
var _onRefreshAllHook: some View {
EmptyView()
.onChange(of: refreshAllCounter) { _ in
if let url = url {
getStatus(url: url)
}
}
}
}
struct ContentView_Previews: PreviewProvider {

View File

@@ -14,20 +14,13 @@ class PhoneConnMgr: NSObject, WCSessionDelegate, ObservableObject {
set {
Store.setCtx(newValue)
updateUrls(newValue)
// Notify the view to update, but the [urls] are already published
// so the view will automatically update when [urls] changes.
// DispatchQueue.main.async {
// self.objectWillChange.send()
// }
}
get {
return _ctx
}
}
var userInfo: [String: Any] = [:]
@Published var urls: [String] = []
override init() {
super.init()
if !WCSession.isSupported() {
@@ -36,91 +29,24 @@ class PhoneConnMgr: NSObject, WCSessionDelegate, ObservableObject {
session = WCSession.default
session?.delegate = self
session?.activate()
_ctx = Store.getCtx()
updateUrls(_ctx)
ctx = Store.getCtx()
}
func updateUrls(_ val: [String: Any]) {
if let urls = val["urls"] as? [String] {
DispatchQueue.main.async {
let list = urls.filter { !$0.isEmpty }
self.urls = list
// Save URLs to App Group for widget access
let appGroupId = "group.com.lollipopkit.toolbox"
if let defaults = UserDefaults(suiteName: appGroupId) {
defaults.set(list, forKey: "watch_shared_urls")
}
}
self.urls = urls.filter { !$0.isEmpty }
}
}
func session(
_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?
) {
// Request latest data when the session is activated
if activationState == .activated {
requestLatestData()
}
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
}
// Receive realtime msgs
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
DispatchQueue.main.async {
self.ctx = message
}
}
// Receive UserInfo
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
DispatchQueue.main.async {
self.ctx = userInfo
}
}
// Receive Application Context
func session(
_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]
) {
DispatchQueue.main.async {
self.ctx = applicationContext
}
}
private func requestLatestData(timeout: TimeInterval = 5.0, maxRetries: Int = 1) {
guard let session = session, session.isReachable else { return }
var didReceiveResponse = false
var retries = 0
func sendRequest() {
session.sendMessage(["action": "requestData"]) { response in
didReceiveResponse = true
DispatchQueue.main.async {
self.ctx = response
}
} errorHandler: { error in
print("Request data failed: \(error)")
// Optionally, handle error UI here
}
// Timeout handling
DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { [weak self] in
guard let self = self else { return }
if !didReceiveResponse {
if retries < maxRetries {
retries += 1
print("No response, retrying requestLatestData (\(retries))...")
sendRequest()
} else {
print("Request data timed out after \(retries + 1) attempts.")
// Optionally, update UI to indicate timeout
}
}
}
}
sendRequest()
// implement session:didReceiveApplicationContext:
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) {
ctx = applicationContext
}
}

View File

@@ -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))
}
}

View File

@@ -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()
}
}

View File

@@ -1,5 +1,4 @@
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: l10n.dart
output-dir: lib/generated/l10n
untranslated-messages-file: untranlated.json

View File

@@ -1,13 +1,14 @@
import 'package:dynamic_color/dynamic_color.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: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/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/generated/l10n/l10n.dart';
import 'package:server_box/view/page/home.dart';
import 'package:server_box/view/page/home/home.dart';
import 'package:icons_plus/icons_plus.dart';
part 'intro.dart';
@@ -21,52 +22,48 @@ class MyApp extends StatelessWidget {
listenable: RNodes.app,
builder: (context, _) {
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 _buildDynamicColor(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;
} else if (!context.isDark && light != null) {
UIs.primaryColor = light.primary;
}
return _buildApp(context, light: lightTheme, dark: darkTheme);
},
);
},
);
}
Widget _build(BuildContext context) {
final colorSeed = Color(Stores.setting.colorSeed.fetch());
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;
} else if (!context.isDark && light != null) {
UIs.primaryColor = light.primary;
}
return _buildApp(context, light: lightTheme, dark: darkTheme);
},
);
}
Widget _buildApp(BuildContext ctx, {required ThemeData light, required ThemeData dark}) {
Widget _buildApp(BuildContext ctx,
{required ThemeData light, required ThemeData dark}) {
final tMode = Stores.setting.themeMode.fetch();
// Issue #57
final themeMode = switch (tMode) {
@@ -78,9 +75,11 @@ class MyApp extends StatelessWidget {
return MaterialApp(
key: ValueKey(locale),
builder: ResponsivePoints.builder,
locale: locale,
localizationsDelegates: const [LibLocalizations.delegate, ...AppLocalizations.localizationsDelegates],
localizationsDelegates: const [
LibLocalizations.delegate,
...AppLocalizations.localizationsDelegates,
],
supportedLocales: AppLocalizations.supportedLocales,
localeListResolutionCallback: LocaleUtil.resolve,
navigatorObservers: [AppRouteObserver.instance],
@@ -88,27 +87,21 @@ class MyApp extends StatelessWidget {
themeMode: themeMode,
theme: light.fixWindowsFont,
darkTheme: (tMode < 3 ? dark : dark.toAmoled).fixWindowsFont,
home: FutureBuilder<List<IntroPageBuilder>>(
future: _IntroPage.builders,
builder: (context, snapshot) {
context.setLibL10n();
final appL10n = AppLocalizations.of(context);
if (appL10n != null) l10n = appL10n;
home: VirtualWindowFrame(
child: Builder(
builder: (context) {
context.setLibL10n();
final appL10n = AppLocalizations.of(context);
if (appL10n != null) l10n = appL10n;
Widget child;
if (snapshot.connectionState == ConnectionState.waiting) {
child = const Scaffold(body: Center(child: CircularProgressIndicator()));
} else {
final intros = snapshot.data ?? [];
final intros = _IntroPage.builders;
if (intros.isNotEmpty) {
child = _IntroPage(intros);
} else {
child = const HomePage();
return _IntroPage(intros);
}
}
return VirtualWindowFrame(title: BuildData.name, child: child);
},
return const HomePage();
},
),
),
);
}

View File

@@ -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;
}
});
}
}

View 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');
}
}

View 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');
}
}

View File

@@ -1,9 +1,4 @@
import 'package:flutter/widgets.dart';
import 'package:server_box/generated/l10n/l10n.dart';
import 'package:server_box/generated/l10n/l10n_en.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_gen/gen_l10n/l10n_en.dart';
AppLocalizations l10n = AppLocalizationsEn();
extension LocaleX on BuildContext {
AppLocalizations get l10n => AppLocalizations.of(this)!;
}

View File

@@ -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;
}
}

View File

@@ -12,9 +12,21 @@ extension SftpFileX on SftpFileMode {
UnixPerm toUnixPerm() {
return UnixPerm(
user: UnixPermOp(r: userRead, w: userWrite, x: userExecute),
group: UnixPermOp(r: groupRead, w: groupWrite, x: groupExecute),
other: UnixPermOp(r: otherRead, w: otherWrite, x: otherExecute),
user: RWX(
r: userRead,
w: userWrite,
x: userExecute,
),
group: RWX(
r: groupRead,
w: groupWrite,
x: groupExecute,
),
other: RWX(
r: otherRead,
w: otherWrite,
x: otherExecute,
),
);
}
}

View File

@@ -4,7 +4,6 @@ import 'dart:typed_data';
import 'package:dartssh2/dartssh2.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/widgets.dart';
import 'package:server_box/data/model/server/system.dart';
import 'package:server_box/data/res/misc.dart';
@@ -14,52 +13,6 @@ typedef OnStdin = void Function(SSHSession session);
typedef PwdRequestFunc = Future<String?> Function(String? user);
extension SSHClientX on SSHClient {
/// Create a persistent PowerShell session for Windows commands
Future<(SSHSession, String)> execPowerShell(
OnStdin onStdin, {
SSHPtyConfig? pty,
OnStdout? onStdout,
OnStdout? onStderr,
bool stdout = true,
bool stderr = true,
Map<String, String>? env,
}) async {
final session = await execute(
'powershell -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass',
pty: pty,
environment: env,
);
final result = BytesBuilder(copy: false);
final stdoutDone = Completer<void>();
final stderrDone = Completer<void>();
session.stdout.listen(
(e) {
onStdout?.call(e.string, session);
if (stdout) result.add(e);
},
onDone: stdoutDone.complete,
onError: stderrDone.completeError,
);
session.stderr.listen(
(e) {
onStderr?.call(e.string, session);
if (stderr) result.add(e);
},
onDone: stderrDone.complete,
onError: stderrDone.completeError,
);
onStdin(session);
await stdoutDone.future;
await stderrDone.future;
return (session, result.takeBytes().string);
}
Future<(SSHSession, String)> exec(
OnStdin onStdin, {
String? entry,
@@ -69,14 +22,9 @@ extension SSHClientX on SSHClient {
bool stdout = true,
bool stderr = true,
Map<String, String>? env,
SystemType? systemType,
}) async {
final session = await execute(
entry ??
switch (systemType) {
SystemType.windows => 'powershell -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass',
_ => 'cat | sh',
},
entry ?? 'cat | sh',
pty: pty,
environment: env,
);
@@ -132,9 +80,10 @@ extension SSHClientX on SSHClient {
if (data.contains('[sudo] password for ')) {
isRequestingPwd = true;
final user = Miscs.pwdRequestWithUserReg.firstMatch(data)?.group(1);
final ctx = context ?? WidgetsBinding.instance.focusManager.primaryFocus?.context;
if (ctx == null) return;
final pwd = ctx.mounted ? await ctx.showPwdDialog(title: user, id: id) : null;
if (context == null) return;
final pwd = context.mounted
? await context.showPwdDialog(title: user, id: id)
: null;
if (pwd == null || pwd.isEmpty) {
session.stdin.close();
} else {

View File

@@ -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/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].
final class SpiRequiredArgs {
/// The only required argument for this class.
final Spi spi;
class AppRoutes {
final Widget page;
final String title;
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');
}
}

View File

@@ -1,47 +1,39 @@
import 'dart:io';
import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/data/model/app/bak/backup2.dart';
import 'package:server_box/data/model/app/bak/utils.dart';
import 'package:server_box/data/model/app/backup.dart';
import 'package:server_box/data/store/no_backup.dart';
const bakSync = BakSyncer._();
final icloud = ICloud(containerId: 'iCloud.tech.lolli.serverbox');
final class BakSyncer extends SyncIface {
final class BakSyncer extends SyncIface<Backup> {
const BakSyncer._() : super();
@override
Future<void> saveToFile() async {
final pwd = await SecureStoreProps.bakPwd.read();
await BackupV2.backup(null, pwd?.isEmpty == true ? null : pwd);
}
Future<void> saveToFile() => Backup.backup();
@override
Future<Mergeable> fromFile(String path) async {
Future<Backup> fromFile(String path) async {
final content = await File(path).readAsString();
final pwd = await SecureStoreProps.bakPwd.read();
try {
if (Cryptor.isEncrypted(content)) {
return MergeableUtils.fromJsonString(content, pwd).$1;
}
return MergeableUtils.fromJsonString(content).$1;
} catch (_) {
// Fallback: try without password if detection failed
return MergeableUtils.fromJsonString(content).$1;
}
return Backup.fromJsonString(content);
}
@override
RemoteStorage? get remoteStorage {
final icloudEnabled = PrefProps.icloudSync.get();
Future<RemoteStorage?> get remoteStorage async {
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;
final webdavEnabled = PrefProps.webdavSync.get();
if (webdavEnabled) return Webdav.shared;
final gistEnabled = PrefProps.gistSync.get();
if (gistEnabled) return GistRs.shared;
final webdavEnabled = settings.webdavSync.fetch();
if (webdavEnabled) return webdav;
return null;
}

View File

@@ -6,8 +6,10 @@ class ChainComparator<T> {
ChainComparator.empty() : this._create(null, (a, b) => 0);
ChainComparator.create() : this._create(null, (a, b) => 0);
static ChainComparator<T> comparing<T, F extends Comparable<F>>(F Function(T) extractor) {
return ChainComparator._create(null, (a, b) => extractor(a).compareTo(extractor(b)));
static ChainComparator<T> comparing<T, F extends Comparable<F>>(
F Function(T) extractor) {
return ChainComparator._create(
null, (a, b) => extractor(a).compareTo(extractor(b)));
}
int compare(T a, T b) {
@@ -24,9 +26,8 @@ class ChainComparator<T> {
}
ChainComparator<T> thenCompareBy<F extends Comparable<F>>(
F Function(T) extractor, {
bool reversed = false,
}) {
F Function(T) extractor,
{bool reversed = false}) {
return ChainComparator._create(
this,
reversed
@@ -35,12 +36,18 @@ class ChainComparator<T> {
);
}
ChainComparator<T> thenWithComparator(Comparator<T> comparator, {bool reversed = false}) {
return ChainComparator._create(this, !reversed ? comparator : (a, b) => comparator(b, a));
ChainComparator<T> thenWithComparator(Comparator<T> comparator,
{bool reversed = false}) {
return ChainComparator._create(
this,
!reversed ? comparator : (a, b) => comparator(b, a),
);
}
ChainComparator<T> thenCompareByReversed<F extends Comparable<F>>(F Function(T) extractor) {
return ChainComparator._create(this, (a, b) => -extractor(a).compareTo(extractor(b)));
ChainComparator<T> thenCompareByReversed<F extends Comparable<F>>(
F Function(T) extractor) {
return ChainComparator._create(
this, (a, b) => -extractor(a).compareTo(extractor(b)));
}
ChainComparator<T> thenTrueFirst(bool Function(T) f) {
@@ -51,12 +58,13 @@ class ChainComparator<T> {
}
ChainComparator<T> reversed() {
return ChainComparator._create(null, (a, b) => compare(b, a));
return ChainComparator._create(null, (a, b) => this.compare(b, a));
}
}
class Comparators {
static Comparator<String> compareStringCaseInsensitive({bool uppercaseFirst = false}) {
static Comparator<String> compareStringCaseInsensitive(
{bool uppercaseFirst = false}) {
return (String a, String b) {
final r = a.toLowerCase().compareTo(b.toLowerCase());
if (r != 0) return r;

View File

@@ -4,9 +4,10 @@ import 'package:dartssh2/dartssh2.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/foundation.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/model/server/server_private_info.dart';
/// Must put this func out of any Class.
///
/// Because of this function is called by [compute].
@@ -24,12 +25,19 @@ String decyptPem(List<String> args) {
return sshKey.first.toPem();
}
enum GenSSHClientStatus { socket, key, pwd }
enum GenSSHClientStatus {
socket,
key,
pwd,
}
String getPrivateKey(String id) {
final pki = Stores.key.fetchOne(id);
final pki = Stores.key.get(id);
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;
}
@@ -51,7 +59,7 @@ Future<SSHClient> genClient(
Spi? jumpSpi,
/// Handle keyboard-interactive authentication
SSHUserInfoRequestHandler? onKeyboardInteractive,
FutureOr<List<String>?> Function(SSHUserInfoRequest)? onKeyboardInteractive,
}) async {
onStatus?.call(GenSSHClientStatus.socket);
@@ -66,21 +74,36 @@ Future<SSHClient> genClient(
if (spi.jumpId != null) return Stores.server.box.get(spi.jumpId);
}();
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
try {
return await SSHSocket.connect(spi.ip, spi.port, timeout: timeout);
return await SSHSocket.connect(
spi.ip,
spi.port,
timeout: timeout,
);
} catch (e) {
Loggers.app.warning('genClient', e);
if (spi.alterUrl == null) rethrow;
try {
final res = spi.parseAlterUrl();
final res = spi.fromStringUrl();
alterUser = res.$2;
return await SSHSocket.connect(res.$1, res.$3, timeout: timeout);
return await SSHSocket.connect(
res.$1,
res.$3,
timeout: timeout,
);
} catch (e) {
Loggers.app.warning('genClient alterUrl', e);
rethrow;

View File

@@ -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;
}

View File

@@ -2,13 +2,18 @@ import 'dart:async';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/provider/app.dart';
abstract final class KeybordInteractive {
static FutureOr<List<String>?> defaultHandle(Spi spi, {BuildContext? ctx}) async {
static FutureOr<List<String>?> defaultHandle(
Spi spi, {
BuildContext? ctx,
}) async {
try {
final res = await (ctx ?? WidgetsBinding.instance.focusManager.primaryFocus?.context)?.showPwdDialog(
title: libL10n.pwd,
final res = await (ctx ?? AppProvider.ctx)?.showPwdDialog(
title: l10n.pwd,
id: spi.id,
label: spi.id,
);

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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/snippet.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';
part 'backup.g.dart';
@@ -45,24 +46,19 @@ class Backup implements Mergeable {
Map<String, dynamic> toJson() => _$BackupToJson(this);
static Future<Backup> loadFromStore() async {
final lastModTime = Stores.lastModTime;
return Backup(
version: backupFormatVersion,
date: DateTime.now().toString().split('.').firstOrNull ?? '',
spis: Stores.server.fetch(),
snippets: Stores.snippet.fetch(),
keys: Stores.key.fetch(),
container: Stores.container.getAllMap(),
lastModTime: lastModTime,
history: Stores.history.getAllMap(),
settings: Stores.setting.getAllMap(),
);
}
Backup.loadFromStore()
: version = backupFormatVersion,
date = DateTime.now().toString().split('.').firstOrNull ?? '',
spis = Stores.server.fetch(),
snippets = Stores.snippet.fetch(),
keys = Stores.key.fetch(),
container = Stores.container.box.toJson(),
lastModTime = Stores.lastModTime,
history = Stores.history.box.toJson(),
settings = Stores.setting.box.toJson();
static Future<String> backup([String? name]) async {
final bak = await Backup.loadFromStore();
final result = _diyEncrypt(json.encode(bak.toJson()));
final result = _diyEncrypt(json.encode(Backup.loadFromStore().toJson()));
final path = Paths.doc.joinPath(name ?? Miscs.bakFileName);
await File(path).writeAsString(result);
return path;
@@ -70,7 +66,7 @@ class Backup implements Mergeable {
@override
Future<void> merge({bool force = false}) async {
final curTime = Stores.lastModTime;
final curTime = Stores.lastModTime ?? 0;
final bakTime = lastModTime ?? 0;
final shouldRestore = force || curTime < bakTime;
if (!shouldRestore) {
@@ -213,10 +209,13 @@ class Backup implements Mergeable {
_logger.info('Restore success');
}
factory Backup.fromJsonString(String raw) => Backup.fromJson(json.decode(_diyDecrypt(raw)));
factory Backup.fromJsonString(String raw) =>
Backup.fromJson(json.decode(_diyDecrypt(raw)));
}
String _diyEncrypt(String raw) => json.encode(raw.codeUnits.map((e) => e * 2 + 1).toList(growable: false));
String _diyEncrypt(String raw) => json.encode(
raw.codeUnits.map((e) => e * 2 + 1).toList(growable: false),
);
String _diyDecrypt(String raw) {
try {

View 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,
};

View File

@@ -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,
};

View File

@@ -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(serversNotifierProvider.notifier).reload();
if (snippetChanged) GlobalRef.gRef?.read(snippetNotifierProvider.notifier).reload();
if (keyChanged) GlobalRef.gRef?.read(privateKeyNotifierProvider.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);
}
}

View File

@@ -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

View File

@@ -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,
};

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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);
}
}
}

View File

@@ -1,19 +1,55 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/core/extension/context/locale.dart';
enum SSHErrType { unknown, connect, auth, noPrivateKey, chdir, segements, writeScript, getStatus }
enum 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> {
const SSHErr({required super.type, super.message});
SSHErr({required super.type, super.message}) : super(from: ErrFrom.ssh);
@override
String? get solution => switch (type) {
SSHErrType.chdir => l10n.needHomeDir,
SSHErrType.auth => l10n.authFailTip,
SSHErrType.writeScript => l10n.writeScriptFailTip,
SSHErrType.noPrivateKey => l10n.noPrivateKeyTip,
_ => null,
};
SSHErrType.chdir => l10n.needHomeDir,
SSHErrType.auth => l10n.authFailTip,
SSHErrType.writeScript => l10n.writeScriptFailTip,
SSHErrType.noPrivateKey => l10n.noPrivateKeyTip,
_ => null,
};
@override
String toString() {
return 'SSHErr<$type>: $message';
}
}
enum ContainerErrType {
@@ -29,35 +65,69 @@ enum ContainerErrType {
}
class ContainerErr extends Err<ContainerErrType> {
const ContainerErr({required super.type, super.message});
ContainerErr({required super.type, super.message})
: super(from: ErrFrom.docker);
@override
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> {
const ICloudErr({required super.type, super.message});
ICloudErr({required super.type, super.message}) : super(from: ErrFrom.icloud);
@override
String? get solution => null;
@override
String toString() {
return 'ICloudErr<$type>: $message';
}
}
enum WebdavErrType { generic, notFound }
enum WebdavErrType {
generic,
notFound,
;
}
class WebdavErr extends Err<WebdavErrType> {
const WebdavErr({required super.type, super.message});
WebdavErr({required super.type, super.message}) : super(from: ErrFrom.webdav);
@override
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> {
const PveErr({required super.type, super.message});
PveErr({required super.type, super.message}) : super(from: ErrFrom.status);
@override
String? get solution => null;
@override
String toString() {
return 'PveErr<$type>: $message';
}
}

View File

@@ -8,7 +8,7 @@ enum ContainerMenu {
restart,
rm,
logs,
terminal
terminal,
//stats,
;
@@ -27,22 +27,22 @@ enum ContainerMenu {
}
IconData get icon => switch (this) {
ContainerMenu.start => Icons.play_arrow,
ContainerMenu.stop => Icons.stop,
ContainerMenu.restart => Icons.restart_alt,
ContainerMenu.rm => Icons.delete,
ContainerMenu.logs => Icons.logo_dev,
ContainerMenu.terminal => Icons.terminal,
// DockerMenuType.stats => Icons.bar_chart,
};
ContainerMenu.start => Icons.play_arrow,
ContainerMenu.stop => Icons.stop,
ContainerMenu.restart => Icons.restart_alt,
ContainerMenu.rm => Icons.delete,
ContainerMenu.logs => Icons.logo_dev,
ContainerMenu.terminal => Icons.terminal,
// DockerMenuType.stats => Icons.bar_chart,
};
String get toStr => switch (this) {
ContainerMenu.start => l10n.start,
ContainerMenu.stop => l10n.stop,
ContainerMenu.restart => l10n.restart,
ContainerMenu.rm => libL10n.delete,
ContainerMenu.logs => libL10n.log,
ContainerMenu.terminal => l10n.terminal,
// DockerMenuType.stats => s.stats,
};
ContainerMenu.start => l10n.start,
ContainerMenu.stop => l10n.stop,
ContainerMenu.restart => l10n.restart,
ContainerMenu.rm => libL10n.delete,
ContainerMenu.logs => libL10n.log,
ContainerMenu.terminal => l10n.terminal,
// DockerMenuType.stats => s.stats,
};
}

View File

@@ -1,22 +1,36 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:icons_plus/icons_plus.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/data/res/store.dart';
part 'server_func.g.dart';
@HiveType(typeId: 6)
enum ServerFuncBtn {
terminal(),
sftp(),
container(),
process(),
//pkg(),
snippet(),
iperf(),
// pve(),
systemd(1058);
@HiveField(0)
terminal._(),
@HiveField(1)
sftp._(),
@HiveField(2)
container._(),
@HiveField(3)
process._(),
//@HiveField(4)
//pkg,
@HiveField(5)
snippet._(),
@HiveField(6)
iperf._(),
// @HiveField(7)
// pve,
@HiveField(8)
systemd._(1058),
;
final int? addedVersion;
const ServerFuncBtn([this.addedVersion]);
const ServerFuncBtn._([this.addedVersion]);
static void autoAddNewFuncs(int cur) {
if (cur >= systemd.addedVersion!) {
@@ -40,24 +54,24 @@ enum ServerFuncBtn {
].map((e) => e.index).toList();
IconData get icon => switch (this) {
sftp => Icons.insert_drive_file,
snippet => Icons.code,
//pkg => Icons.system_security_update,
container => FontAwesome.docker_brand,
process => Icons.list_alt_outlined,
terminal => Icons.terminal,
iperf => Icons.speed,
systemd => MingCute.plugin_2_fill,
};
sftp => Icons.insert_drive_file,
snippet => Icons.code,
//pkg => Icons.system_security_update,
container => FontAwesome.docker_brand,
process => Icons.list_alt_outlined,
terminal => Icons.terminal,
iperf => Icons.speed,
systemd => MingCute.plugin_2_fill,
};
String get toStr => switch (this) {
sftp => 'SFTP',
snippet => l10n.snippet,
//pkg => l10n.pkg,
container => l10n.container,
process => l10n.process,
terminal => l10n.terminal,
iperf => 'iperf',
systemd => 'Systemd',
};
sftp => 'SFTP',
snippet => l10n.snippet,
//pkg => l10n.pkg,
container => l10n.container,
process => l10n.process,
terminal => l10n.terminal,
iperf => 'iperf',
systemd => 'Systemd',
};
}

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