Compare commits

...

254 Commits

Author SHA1 Message Date
lollipopkit🏳️‍⚧️
0a0928e2f6 fix: treat empty jumpChainIds as no jump 2026-01-17 23:55:41 +08:00
lollipopkit🏳️‍⚧️
61f161d8a6 opt. 2026-01-15 20:52:11 +08:00
lollipopkit🏳️‍⚧️
52c80795f4 opt. 2026-01-15 20:21:28 +08:00
lollipopkit🏳️‍⚧️
09f1ab2cf2 fix 2026-01-15 20:09:33 +08:00
lollipopkit🏳️‍⚧️
2eeb55c1d8 fix 2026-01-15 13:46:53 +08:00
lollipopkit🏳️‍⚧️
6738ac94f8 opt. 2026-01-15 13:15:31 +08:00
lollipopkit🏳️‍⚧️
827d40b8b5 opt. 2026-01-15 13:02:17 +08:00
lollipopkit🏳️‍⚧️
928f2becf1 fix 2026-01-15 12:41:10 +08:00
lollipopkit🏳️‍⚧️
7d30af44d6 fix 2026-01-15 10:10:21 +08:00
lollipopkit🏳️‍⚧️
35349a90eb opt.: deduplicate & merge 2026-01-15 10:10:14 +08:00
lollipopkit🏳️‍⚧️
8be9b9b10b impl: jump logic
Fixes #356
2026-01-15 09:42:34 +08:00
lollipopkit🏳️‍⚧️
c51cf62015 feat: jump server chain
Fixes #356
2026-01-14 22:36:47 +08:00
lollipopkit🏳️‍⚧️
8589b3b4d7 opt.: add a btn to minimize ai dialog (#1004)
* opt.: add a btn to minimize ai dialog
Fixes #1003

* opt.

* opt.
2026-01-14 15:15:33 +08:00
GT610
7693e30cbf opt: Better performance on server refreshing (#999)
* refactor(server): Replace Future.wait with an explicit list of futures to enhance readability

Refactor the nested map and async functions into explicit for loops and future lists to make the code logic clearer

* fix(server): Fixed the auto-refresh logic and concurrency control issues

- Add `_refreshCompleter` to prevent concurrent refreshes
- Fixed the issue where the status was not updated after the automatic refresh timer was canceled
- Remove the invalid check for `duration == 1`

* refactor(server): Optimize the server refresh logic by filtering out servers that do not need to be refreshed in advance

Move the server filtering logic outside the loop and use the `where` method to filter the servers that need to be refreshed, avoiding repeated condition checks within the loop. This improves code readability and reduces redundant condition checks.

* refactor: Optimize server refresh logic to enhance readability

Break down complex conditional checks into clearer steps, separating the logic for server refresh and rate limiter reset. Replace chained calls with explicit loops to make the code easier to maintain and understand.

* refactor(server): Remove `updateFuture` from `ServerState` and use the `_isRefreshing` flag instead

Simplify the server refresh logic, replace Future state tracking with a boolean flag, and avoid unnecessary state updates

* refactor(server_detail): Extract the setting items as local variables to improve performance

Extract the globally set items that are accessed repeatedly as local variables, reduce unnecessary state retrieval operations, and optimize page performance

* refactor: Rename `_displayCpuIndexSetting` to `_displayCpuIndex` for consistency

* refactor(server): Fix the issue of parallel blocking in server refresh

The original code uses Future.wait to wait for all refresh operations to complete, but in fact, there is no need to wait for the results of these operations. Instead, directly calling ignore() to ignore the results can avoid blocking caused by the slowest server

* fix: Adjust the order of logging and default value settings

Ensure to set the default value after recording the invalid duration warning

* refactor(server): Rename _refreshCompleter to _refreshInProgress to enhance readability

Change the variable name from `_refreshCompleter` to `_refreshInProgress`, so that it more accurately reflects the actual purpose of the variable, which is to indicate whether the refresh operation is in progress

* refactor(server): Remove unnecessary refresh progress status management

Simplify the server refresh logic, remove the unused _refreshInProgress state variable and related Completer handling, making the code more concise and straightforward

* chore: Update dependent package versions

Update the following dependent package versions:
- camera_web has been upgraded from 0.3.5 to 0.3.5+3
- ffi has been upgraded from 2.1.4 to 2.1.5
- hive_ce_flutter is upgraded from 2.3.3 to 2.3.4
- watcher is upgraded from 1.1.4 to 1.2.1

* opt.

---------

Co-authored-by: lollipopkit🏳️‍⚧️ <10864310+lollipopkit@users.noreply.github.com>
2026-01-14 13:47:06 +08:00
lollipopkit🏳️‍⚧️
874d28be12 bump: v1291 2026-01-14 13:32:30 +08:00
GT610
06070c29b9 fix(color-picking): Fix color picking failure and card overflow (#998) 2026-01-11 00:21:48 +08:00
GT610
bb0ada12e6 bump: Update Android build tools and Actions version (#997) 2026-01-10 20:03:37 +08:00
GT610
9ceeaf7cc4 feat(local file page): Display server names for server folders (#996)
* feat(local file page): Display server names for server folders

In the local file list, server folders will display their corresponding server names, enhancing the user experience.

* fix(storage page): Use ref.read instead of ref.watch to fetch the server list

Avoid unnecessary watch operations during construction, reducing potential performance overhead
2026-01-08 18:57:02 +08:00
GT610
29a57ad742 fix(container): Modify container execution commands to prioritize bash or ash (#995) 2026-01-08 09:11:23 +08:00
GT610
2c495a44c3 fix(log): Logging System Improvements and Error Handling Enhancements (#994)
* fix: Added logging to exception handling

Added detailed error logging to exception handling across multiple files, including exception information and stack traces, to facilitate troubleshooting.

* refactor(logging): Standardize logging output methods

Replace existing debugPrint and lprint with Loggers.app.warning to enhance logging consistency and maintainability.

* refactor: Remove redundant debug log prints

Clean up unnecessary log print statements in debug code

* feat(i18n): Added internationalization support for the logging feature
2026-01-07 15:09:22 +08:00
GT610
cc300c141a refactor(sftp): Replace hard-coded path separators with Pfs.seperator (#993)
Unify the use of Pfs.seperator for handling file path separators to enhance cross-platform compatibility.
2026-01-06 23:50:11 +08:00
GT610
26efb8e185 fix: Add input validation and bounds checking to parsing methods (#990)
* fix: Resolved boundary condition issues in string processing

Addressed null and length checks during string splitting across multiple model classes to prevent potential null pointer exceptions and array out-of-bounds errors

* fix: Throw exceptions instead of silently returning when package manager output formats are invalid

Modified the _pacman, _opkg, and _apk parsing methods to throw exceptions when input formats are invalid, rather than silently returning, to prevent potential error handling issues.
2026-01-06 23:47:49 +08:00
GT610
06ed38ff45 fix(container): Fix Podman 5.x Network Traffic Statistics Not Displaying (#991)
* fix(container): Added version parameter to accommodate Podman 5.x network statistics format

Modified the parseStats method to accept a version parameter, handling changes in Podman 5.x's network statistics data structure. When the version is 5.x, network traffic data is retrieved from the RxBytes/TxBytes fields of the Network interface.

* fix(container): Fixed Podman version detection logic to correctly retrieve network statistics

Addressed Podman version number parsing issues and improved version comparison logic to support all 4.x and below versions as well as 5.x and above versions

* fix(container): Resolved display formatting issues for network and disk I/O statistics

Handled default values when NetIO and BlockIO are null, and reformatted display strings to distinguish upstream/downstream traffic and read/write operations.

* fix: Why did I mess up the tag order?
2026-01-06 23:44:54 +08:00
GT610
7c35abe30e fix(cpu): Resolved boundary condition issues when calculating CPU utilization (#988)
Added checks for coreIdx out-of-bounds and totalDelta being zero to prevent array out-of-bounds and division-by-zero errors
2026-01-06 12:48:13 +08:00
lxdklp
78ef181d4a feat: support macOS menubar (#976)
* feat: macOS menubar

* feat: Dynamic NavigateMenuItems

* fix: simplify shortcut config

* fix: Simplify the code

* fix: More suitable tab name
2025-12-10 18:05:30 +08:00
lollipopkit🏳️‍⚧️
3f15caeaf2 new: add copy btn for ask ai (#975) 2025-12-07 17:51:07 +08:00
lollipopkit🏳️‍⚧️
6458e736fa fix: tag switcher ui (#974)
Fixes #964
2025-12-07 17:29:12 +08:00
lollipopkit🏳️‍⚧️
99fda8b747 opt. 2025-12-07 17:19:24 +08:00
lxdklp
c5cbb12ac3 feat: Automatic line wrapping of time (#973) 2025-12-07 17:14:45 +08:00
lollipopkit🏳️‍⚧️
038f0d4d77 chore: bump: v1276 2025-12-06 12:03:10 +08:00
lxdklp
141519d952 fix: SFTP err caused by known host key (#970)
Fixes #965
2025-11-25 10:35:14 +08:00
lxdklp
75d1a59e77 fix: Unable to obtain Windows server information (#963)
* fix: FormatException: Unexpected extension byte (at offset 8) error

* fix: PowerShell script error repair, Windows data parsing repair

* fix: Unable to obtain network card information

* fix: Unable to obtain system startup time

* fix conversation as resolved.
2025-11-22 19:17:40 +08:00
lollipopkit🏳️‍⚧️
ca4e65d7a5 chore: flutter 3.38 2025-11-13 15:24:22 +08:00
Korb
ffda27d057 add: fdroid Russian metadata translation (#947)
* Create ru/short_description.txt

* Create ru/full_description.txt
2025-10-23 02:24:04 +08:00
lollipopkit🏳️‍⚧️
c548b4ef48 fix: container parsing (#948) 2025-10-23 02:21:14 +08:00
lollipopkit🏳️‍⚧️
70040c5840 bump: v1270 2025-10-20 09:32:07 +08:00
lollipopkit🏳️‍⚧️
5272324be6 feat: prompt user on host key verification (#943) 2025-10-20 09:31:20 +08:00
lollipopkit🏳️‍⚧️
8cbb48ed67 feat: support windows clipboard shortcuts (#941)
Fixes #902
2025-10-20 00:56:33 +08:00
lollipopkit🏳️‍⚧️
03720fa322 Merge branch 'main' of github.com:lollipopkit/flutter_server_box 2025-10-20 00:35:07 +08:00
lollipopkit🏳️‍⚧️
0b51719070 fix: synthesize hardware backspace repeat (#940) 2025-10-20 00:34:52 +08:00
lollipopkit🏳️‍⚧️
a84231393d opt.: ask ai hint 2025-10-19 23:38:08 +08:00
lollipopkit🏳️‍⚧️
d6c2cafce7 opt.: ssh disconnection helper (#937) 2025-10-19 13:40:17 +08:00
lollipopkit🏳️‍⚧️
729b76177e feat: ask ai (#936)
* feat: ask ai in ssh terminal
Fixes #934

* new(ask_ai): settings

* fix: app hot reload

* new: l10n

* chore: deps.

* opt.
2025-10-18 01:15:43 +08:00
lollipopkit🏳️‍⚧️
860c11d4a8 bump: v1262 2025-10-10 09:18:38 +08:00
lollipopkit🏳️‍⚧️
bd949288ed fix: code editor tool bar (#933) 2025-10-10 09:14:41 +08:00
lollipopkit🏳️‍⚧️
bb3e3b4848 opt.: no Tag Switcher on desktop (#932) 2025-10-08 21:21:23 +08:00
lollipopkit🏳️‍⚧️
3307fca620 fix: cant sort servers order (#930) 2025-10-08 17:35:07 +08:00
lollipopkit🏳️‍⚧️
da8517bcf7 migrate: riverpod 3 2025-10-08 17:03:13 +08:00
lollipopkit🏳️‍⚧️
f68c4a851b feat: discover local ssh server (#921) 2025-09-19 23:29:01 +08:00
lollipopkit🏳️‍⚧️
17db393c12 bump: v1256 2025-09-15 02:35:42 +08:00
lollipopkit🏳️‍⚧️
275581cfa3 fix: notification permission (#914) 2025-09-14 22:34:01 +08:00
lollipopkit🏳️‍⚧️
d7168ea1ff fix: version code err caused by Flutter (#913) 2025-09-14 14:23:17 +08:00
lollipopkit🏳️‍⚧️
fd2bf08f78 bump: v1253 2025-09-09 13:32:17 +08:00
lollipopkit🏳️‍⚧️
98e13c39cf fix: android channel invoke 2025-09-09 13:30:37 +08:00
lollipopkit🏳️‍⚧️
e70abeef04 bump: v1251 2025-09-09 13:14:01 +08:00
lollipopkit🏳️‍⚧️
194774d6fb opt.: system detect logic to avoid creating useless file (#905) 2025-09-09 13:10:40 +08:00
lollipopkit🏳️‍⚧️
640d61bab9 fix: holding Backspace doesnt work on desktop (#903) 2025-09-08 14:06:35 +08:00
lollipopkit🏳️‍⚧️
7f4cf22cc9 fix: rm camera perm on mac 2025-09-08 12:37:30 +08:00
lollipopkit🏳️‍⚧️
05a927753f feat: stop all servers in noti center (#901) 2025-09-06 14:04:53 +08:00
lollipopkit🏳️‍⚧️
0c7b72fb2c bump: v1246 2025-09-05 12:31:33 +08:00
lollipopkit🏳️‍⚧️
a869b97502 fix: server stat l10n 2025-09-05 00:24:18 +08:00
lollipopkit🏳️‍⚧️
eadd343205 readd: home drawer
Fixes #900
2025-09-05 00:12:41 +08:00
lollipopkit🏳️‍⚧️
1bac986fe0 bug: single server providers should be keepalived (#899) 2025-09-04 23:50:00 +08:00
lollipopkit🏳️‍⚧️
a94be6c2c3 fix: macOS appstore rejection (#893) 2025-09-03 22:19:04 +08:00
lollipopkit🏳️‍⚧️
fc8e9b4bb1 bump: v1241 2025-09-03 09:24:33 +08:00
lollipopkit🏳️‍⚧️
ec4b633889 fix: watchOS app cfg (#890) 2025-09-03 01:41:08 +08:00
lollipopkit🏳️‍⚧️
e51804fa70 new: custom tabs (#889) 2025-09-03 01:05:03 +08:00
lollipopkit🏳️‍⚧️
2466341999 feat: server conn statistics (#888) 2025-09-02 19:41:56 +08:00
lollipopkit🏳️‍⚧️
929061213f refactor: docker status parsing (#886) 2025-09-02 13:22:54 +08:00
lollipopkit🏳️‍⚧️
6b52679942 fix: resolve Docker interface blank issue caused by LateInitializationError (#884) 2025-09-02 12:44:05 +08:00
lollipopkit🏳️‍⚧️
efc0315c93 new: CLAUDE.md 2025-09-01 23:32:44 +08:00
lollipopkit🏳️‍⚧️
8e4c2a7cde fix: fallback to df on incompatible system (#880) 2025-09-01 23:32:20 +08:00
lollipopkit🏳️‍⚧️
4ec7f5895e fix: imported servers from ssh config are the same (#882) 2025-09-01 23:06:58 +08:00
lollipopkit🏳️‍⚧️
ee22cdb55f fix: private key can't be selected in edit page (#879) 2025-09-01 13:05:54 +08:00
lollipopkit🏳️‍⚧️
b1b0d9a18f bump: v1231 2025-09-01 01:19:23 +08:00
lollipopkit🏳️‍⚧️
56e67f4725 fix: sync will refresh the entire app (#877) 2025-09-01 01:18:06 +08:00
lollipopkit🏳️‍⚧️
3b7fdf36fb opt. 2025-08-31 23:59:53 +08:00
lollipopkit🏳️‍⚧️
5291d316a2 fix: ensure unique IDs for bulk server import to prevent overwriting (#875) 2025-08-31 21:20:27 +08:00
lollipopkit🏳️‍⚧️
4c369546da fix: replace String.fromCharCodes with utf8.decode for proper Chinese character handling in JSON import (#874) 2025-08-31 20:06:47 +08:00
lollipopkit🏳️‍⚧️
12a243d139 feat: import servers from ~/.ssh/config (#873) 2025-08-31 19:33:29 +08:00
lollipopkit🏳️‍⚧️
a97b3cf43e opt.: bak pwd is optional (#872) 2025-08-31 11:11:47 +08:00
lollipopkit🏳️‍⚧️
53a7c0d8ff migrate: riverpod + freezed (#870) 2025-08-31 00:55:54 +08:00
lollipopkit🏳️‍⚧️
9cb705f8dd fix: parsing hostname (#865) 2025-08-22 09:18:21 +08:00
lollipopkit🏳️‍⚧️
8270674b7d chore: tests 2025-08-22 00:25:26 +08:00
lxdklp
24fd4b782d fix: GBK decoding fallback (#863)
* fix #757

* fix #757

* apply the code recommendations from sourcery ai

* Make sure raw is non-empty data

* Modified the way to judge gbk, fixed the problem that null cannot throw an error
2025-08-21 23:28:06 +08:00
lollipopkit🏳️‍⚧️
fcb3d7e2b3 bump: v1220 2025-08-18 12:21:52 +08:00
lollipopkit🏳️‍⚧️
f5634d6e88 bump: v1218 2025-08-18 12:08:29 +08:00
lollipopkit🏳️‍⚧️
5497ad83e0 opt.: bio auth settings 2025-08-17 17:56:26 +08:00
dsvf
4a7827f41a Delay bio auth (#642) 2025-08-17 14:06:24 +08:00
lollipopkit🏳️‍⚧️
60671fe461 feat: native widget url settings dialog (#856) 2025-08-16 23:07:19 +08:00
lollipopkit🏳️‍⚧️
bc1b6e5a4a feat: GitHub Gist sync (#854) 2025-08-14 23:21:33 +08:00
lollipopkit🏳️‍⚧️
1d553eccd5 rm: claude pr review 2025-08-13 23:33:03 +08:00
lollipopkit🏳️‍⚧️
68734a9e52 fix: disable command menu doesnt work (#852) 2025-08-13 23:32:22 +08:00
lollipopkit🏳️‍⚧️
ed8a1d18b9 opt.: systemd page (#851) 2025-08-13 22:16:55 +08:00
shamnad-sherief
e4a9875620 fix: Systemd shows nothing (#850) 2025-08-13 20:19:28 +08:00
lollipopkit🏳️‍⚧️
6f9aa2ece9 add: Claude Code GitHub Workflow (#849)
* "Claude PR Assistant workflow"

* "Claude Code Review workflow"
2025-08-13 15:23:30 +08:00
lollipopkit🏳️‍⚧️
13e28675af opt.: watchOS & iOS widget (#847) 2025-08-13 01:44:02 +08:00
lollipopkit🏳️‍⚧️
8c0e0f89d5 fix: term opening on Linux (#845) 2025-08-12 23:55:31 +08:00
lollipopkit🏳️‍⚧️
9b01da5a23 feat: term session mgr (#846) 2025-08-12 23:43:42 +08:00
lollipopkit🏳️‍⚧️
584af5423a bump: v1206 2025-08-09 12:45:45 +08:00
lollipopkit🏳️‍⚧️
95f8e571c1 feat: ability to disable monitoring cmds (#840) 2025-08-09 12:37:30 +08:00
lollipopkit🏳️‍⚧️
9c9648656d fix: macOS ssh term unusable (#838) 2025-08-08 18:59:25 +08:00
lollipopkit🏳️‍⚧️
6880bcc192 opt.: m3 layout breakpoints (#837) 2025-08-08 17:12:13 +08:00
lollipopkit🏳️‍⚧️
3a615449e3 feat: Windows compatibility (#836)
* feat: win compatibility

* fix

* fix: uptime parse

* opt.: linux uptime accuracy

* fix: windows temperature fetching

* opt.

* opt.: powershell exec

* refactor: address PR review feedback and improve code quality

### Major Improvements:
- **Refactored Windows status parsing**: Broke down large `_getWindowsStatus` method into 13 smaller, focused helper methods for better maintainability and readability
- **Extracted system detection logic**: Created dedicated `SystemDetector` helper class to separate OS detection concerns from ServerProvider
- **Improved concurrency handling**: Implemented proper synchronization for server updates using Future-based locks to prevent race conditions

### Bug Fixes:
- **Fixed CPU percentage parsing**: Removed incorrect '*100' multiplication in BSD CPU parsing (values were already percentages)
- **Enhanced memory parsing**: Added validation and error handling to BSD memory fallback parsing with proper logging
- **Improved uptime parsing**: Added support for multiple Windows date formats and robust error handling with validation
- **Fixed division by zero**: Added safety checks in Swap.usedPercent getter

### Code Quality Enhancements:
- **Added comprehensive documentation**: Documented Windows CPU counter limitations and approach
- **Strengthened error handling**: Added detailed logging and validation throughout parsing methods
- **Improved robustness**: Enhanced BSD CPU parsing with percentage validation and warnings
- **Better separation of concerns**: Each parsing method now has single responsibility

### Files Changed:
- `lib/data/helper/system_detector.dart` (new): System detection helper
- `lib/data/model/server/cpu.dart`: Fixed percentage parsing and added validation
- `lib/data/model/server/memory.dart`: Enhanced fallback parsing and division-by-zero protection
- `lib/data/model/server/server_status_update_req.dart`: Refactored into 13 focused parsing methods
- `lib/data/provider/server.dart`: Improved synchronization and extracted system detection

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: parse & shell fn struct

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-08 16:56:36 +08:00
lollipopkit🏳️‍⚧️
46a12bc844 bump: v1201 2025-07-28 22:27:56 +08:00
lollipopkit🏳️‍⚧️
8d597294a4 feat: amd gpu (#831) 2025-07-28 22:26:29 +08:00
lollipopkit🏳️‍⚧️
682a6e4f2d feat: custom pwd of bak (#827) 2025-07-25 16:38:28 +08:00
lollipopkit🏳️‍⚧️
8c3302cf0d chore: update script location in Attention notice (#825)
Fixes #824
2025-07-21 16:42:16 +08:00
lollipopkit🏳️‍⚧️
ec4bf3df24 opt.: sftp dl 2025-07-21 16:20:27 +08:00
lollipopkit🏳️‍⚧️
263d4eabb4 feat: store critical data in secure store (#821) 2025-07-17 18:26:34 +08:00
lollipopkit🏳️‍⚧️
c6439673b8 feat: shift key in ssh term (#819) 2025-07-17 18:18:18 +08:00
lollipopkit🏳️‍⚧️
a35d21981b opt.: watch sync mechanism (#817)
* opt.: watch sync mechanism
Fixes #816

* opt.
2025-07-17 16:55:56 +08:00
Tom
dbc873c0c0 feat: enhance server card layout and add logo display functionality (#804) 2025-06-27 18:55:48 +08:00
Integral
e69808a2f6 fix: disable APK signing block to resolve F-Droid build issues (#793)
Thanks for the patch from @linsui.
2025-06-16 01:51:30 +08:00
lollipopkit🏳️‍⚧️
55b3ba63ec opt.: ui 2025-06-12 22:04:03 +08:00
ИEØ_ΙΙØZ
006e66d825 update: app_zh_tw.arb (#790) 2025-06-11 17:07:22 +08:00
lollipopkit🏳️‍⚧️
c556c0f1b5 bump: v1189 2025-06-11 17:02:47 +08:00
lollipopkit🏳️‍⚧️
c42c701ffc bug: incorrect disk smart info (#789) 2025-06-11 16:45:25 +08:00
lollipopkit🏳️‍⚧️
e6db2db320 fix: container not working (#787) 2025-06-11 14:53:43 +08:00
lollipopkit🏳️‍⚧️
66ecb02d9e migrate: freezed v3 2025-06-10 14:27:27 +08:00
lollipopkit🏳️‍⚧️
8e7de604ee fix: linux build 2025-06-09 18:50:01 +08:00
lollipopkit🏳️‍⚧️
6f2a58ce18 bump: v1184 2025-06-09 16:01:39 +08:00
lollipopkit🏳️‍⚧️
066629d7e0 fix: android build 2025-06-08 20:59:58 +08:00
lollipopkit🏳️‍⚧️
4b3953e0d2 readd: serverTabPreferDiskAmount (#780)
Fixes #643
2025-06-08 11:15:54 +08:00
lollipopkit🏳️‍⚧️
b5aec55106 fix android reload when physical keyboard changes (#779) 2025-06-08 10:46:47 +08:00
lollipopkit🏳️‍⚧️
ba686db847 fix: ssh terminal ui 2025-06-07 17:18:42 +08:00
lollipopkit🏳️‍⚧️
4d52023982 opt.: ssh terminal ux (#778) 2025-06-07 17:07:13 +08:00
lollipopkit🏳️‍⚧️
7a71a96442 fix: examples UI of importing (#777)
Fixes #601
2025-06-05 09:22:54 +08:00
lollipopkit🏳️‍⚧️
79c515c903 new: bio_auth -> local_auth (#776)
Fixes #722
2025-06-05 09:07:28 +08:00
lollipopkit🏳️‍⚧️
4701757857 feat: SSH page background (#775) 2025-06-05 08:53:00 +08:00
lollipopkit🏳️‍⚧️
176cb7da03 feat: disk smart info (#773) 2025-06-05 07:31:45 +08:00
lollipopkit🏳️‍⚧️
741a6442e0 fix: batch delete servers (#772) 2025-06-04 19:28:58 +08:00
lollipopkit🏳️‍⚧️
f6d394c71e opt.: custom terminal emulator (#771) 2025-06-04 19:13:31 +08:00
lollipopkit🏳️‍⚧️
7127c960f7 opt.: server detail page columns 2025-06-04 17:29:03 +08:00
lollipopkit🏳️‍⚧️
1084c49a5f opt.: ui 2025-06-04 01:52:27 +08:00
lollipopkit🏳️‍⚧️
bc824691e0 opt.: server card loading UI 2025-06-04 00:47:18 +08:00
lollipopkit🏳️‍⚧️
0c1ada0067 fix: cloud sync (#769) 2025-06-04 00:11:31 +08:00
lollipopkit🏳️‍⚧️
9547d92ac5 migrate: flutter 3.32 2025-05-25 17:05:46 +08:00
lollipopkit🏳️‍⚧️
7e16d2f159 new: parse disk info via lsblk output Fixes #709 (#760) 2025-05-17 00:45:38 +08:00
lollipopkit🏳️‍⚧️
d88e97e699 new: use generated ids for servers (#765)
* new: use generated ids for servers
Fixes #743

* fix: deps.

* fix: migrate related settings

* fix: restore servers from json
2025-05-16 21:50:44 +08:00
lollipopkit🏳️‍⚧️
d29bd1d806 opt.: ssh page sftp path checking (#763) 2025-05-15 21:01:10 +08:00
lollipopkit🏳️‍⚧️
2b2f1ddb60 opt.: handle esc btn in ssh page (#761) 2025-05-15 20:35:45 +08:00
lollipopkit🏳️‍⚧️
4f16d510c8 fix: code editor page popping (#759)
Fixes #713
2025-05-14 18:58:58 +08:00
lollipopkit🏳️‍⚧️
94cded39a6 fix: horizontal ssh virt keys ui (#758)
Fixes #737
2025-05-14 18:21:18 +08:00
lollipopkit🏳️‍⚧️
12082e1235 chore: README 2025-05-14 16:54:28 +08:00
lollipopkit🏳️‍⚧️
28e34e2183 opt.: editor lang parse 2025-05-14 16:19:26 +08:00
lollipopkit🏳️‍⚧️
4d45d01074 feat: searching in editor page (#756)
* feat: searching in editor page
Fixes #734

* opt.: editor searching ui
2025-05-14 05:09:32 +08:00
lollipopkit🏳️‍⚧️
f6b3ec2a62 opt.: editor (#755)
Fixes #753
2025-05-14 04:07:21 +08:00
lollipopkit🏳️‍⚧️
d6cf33fb70 bug: can't select file on macOS (#754)
Fixes #750
2025-05-14 04:04:19 +08:00
lollipopkit🏳️‍⚧️
1eea133b69 opt.: appbar scrolledUnderElevation (#752)
Fixes #751
2025-05-14 04:02:30 +08:00
lollipopkit🏳️‍⚧️
2b46cb6dcc fix: android monochrome icon (#749)
Fixes #732
2025-05-14 03:22:02 +08:00
lollipopkit🏳️‍⚧️
8627ff823f optimization: desktop UI (#747) 2025-05-13 04:57:37 +08:00
lollipopkit🏳️‍⚧️
e520929411 chore: migrate fl_lib 2025-04-28 23:15:54 +08:00
lollipopkit🏳️‍⚧️
8f09085cf3 Merge remote-tracking branch 'origin/lollipopkit/issue727' 2025-04-25 18:33:36 +08:00
lollipopkit🏳️‍⚧️
9e66071cb0 opt. 2025-04-25 18:32:29 +08:00
Noo6
fa90c1ef31 opt: navigation bar (#740) 2025-04-22 11:54:29 +08:00
Noo6
ede238c647 feat: adaptive navigation bar (#739) 2025-04-22 11:19:19 +08:00
lollipopkit🏳️‍⚧️
6e7fee20b8 opt.: routes 2025-04-10 15:28:47 +08:00
lollipopkit🏳️‍⚧️
391e4f6b65 opt.: page struct 2025-04-09 12:15:42 +08:00
lollipopkit🏳️‍⚧️
e185414355 fix: logo url dist null check 2025-04-08 14:49:26 +08:00
lollipopkit🏳️‍⚧️
2a2f348063 migrate: fl_lib 2025-03-24 23:07:52 +08:00
moli765
95ca6bcfc9 reslove issue 717 about logo url and add coreelec support (#718) 2025-03-22 23:19:54 +08:00
lollipopkit🏳️‍⚧️
275041247a migrate: webdav_client_plus (#729)
Fixes #723
2025-03-22 01:27:17 +08:00
lollipopkit🏳️‍⚧️
24d64b835d opt.: app bar
Fixes #727
2025-03-20 20:20:13 +08:00
lollipopkit🏳️‍⚧️
dd5fea09b1 opt.: skip updating home widget on desktop 2025-03-13 16:07:45 +08:00
𝗛𝗼𝗹𝗶
0a404e035e Improve Turkish Language (#721) 2025-03-10 15:21:17 +08:00
Noo6
b5ab5b1cab fix: window title bar might not be displayed (#701) 2025-02-12 13:33:49 +08:00
Noo6
5cb83001c6 opt: windows app icon (#700) 2025-02-12 13:17:54 +08:00
Noo6
20a39f0292 feat: record window position (#692) 2025-02-05 20:59:04 +08:00
Noo6
900686f955 chore: Fns & FnRes (#690) 2025-02-04 22:41:03 +08:00
Calvin lin
a10321e3de Update release.yml
It may work now :)
2025-02-03 16:22:18 +08:00
Calvin lin
0691ab2213 Update release.yml 2025-02-03 16:16:45 +08:00
Calvin lin
ef05203ea3 Update release.yml 2025-02-03 16:08:31 +08:00
Calvin lin
28410707a8 Update release.yml 2025-02-03 16:03:49 +08:00
Calvin lin
06b966caa8 Merge pull request #687 from lollipopkit/fix_action_linux
fix github action build linux
2025-02-03 16:01:36 +08:00
calvin
11b0806083 fix github action build linux 2025-02-03 15:48:19 +08:00
lollipopkit🏳️‍⚧️
749fd4d800 fix: ci 2025-01-29 23:56:27 +08:00
lollipopkit🏳️‍⚧️
bec4a3b314 chore: bump version 2025-01-29 23:44:44 +08:00
lollipopkit🏳️‍⚧️
9e5babec76 opt.: close after saving (#684) 2025-01-29 15:10:50 +08:00
lollipopkit🏳️‍⚧️
dbbb10364b fix: webdav settings (#683) 2025-01-29 13:13:12 +08:00
lollipopkit🏳️‍⚧️
16948c3e0f new: provide .deb & .rpm 2025-01-29 12:56:03 +08:00
lollipopkit🏳️‍⚧️
e39fb23b66 bug: unix perm switcher (#674) 2025-01-14 11:19:47 +08:00
lollipopkit🏳️‍⚧️
4777166dd9 migrate: fl_lib v235 2025-01-13 21:57:12 +08:00
Noo6
0ae0241800 opt: window title bar (#672)
* opt: window title bar

* rm: `VirtualWindowFrame` on `SettingsPage`
2025-01-10 15:19:03 +08:00
lollipopkit🏳️‍⚧️
e7a5f43cc4 fix: disabled android service related (#670)
Fixes #662
2025-01-07 20:36:12 +08:00
lollipopkit🏳️‍⚧️
7f58237589 fix: catch crash of fg service (#669) 2025-01-04 16:22:20 +08:00
lollipopkit🏳️‍⚧️
0bbd0b43b3 opt.: display settings btn in fullscreen mode (#660) 2024-12-15 23:53:31 +08:00
lollipopkit🏳️‍⚧️
aaa1eddeaf opt.: display err if home widget fails (#659) 2024-12-15 23:39:38 +08:00
lollipopkit🏳️‍⚧️
2f6db2961f fix: crash while opening terminal (#658)
Fixes #639
2024-12-14 21:06:37 +08:00
lollipopkit
831efa833b fix: file_picker err 2024-12-14 16:21:25 +08:00
lollipopkit
867fcbfc0d chore: migrate to flutter 3.27 2024-12-14 14:45:04 +08:00
lollipopkit
41886be649 opt.: home top bar 2024-12-14 14:11:26 +08:00
lollipopkit
029b4e0dba chore: README 2024-12-03 00:13:58 +08:00
lollipopkit🏳️‍⚧️
3a3c29764a bug: can't share server via qr_code (#651)
Fixes #650
2024-12-02 22:22:14 +08:00
lollipopkit
4ace4af7da opt.: home ui
- new: top left settings btn
- opt.: top logo
2024-12-02 21:41:17 +08:00
lollipopkit🏳️‍⚧️
ddd32e82d4 opt.: migrate to new fl_lib (#649)
Fixes #648
2024-12-02 21:06:44 +08:00
dsvf
b882baeafa Added Function keys (F1-F12) to SSH virtual keyboard options (#641) 2024-11-24 13:19:20 +08:00
fei1025
046f2c06d0 修复路径在windos下读取不到的问题 (#630)
* 修复路径在windos下读取不到的问题

* opt: `local.dart` fmt

---------

Co-authored-by: Noo6 <72285529+No06@users.noreply.github.com>
2024-11-14 14:42:15 +08:00
Noo6
d706886343 fix: sftp open file on windows (#633) 2024-11-14 14:24:57 +08:00
Noo6
7dda63af8a fix: add file on windows 2024-11-14 11:20:45 +08:00
lollipopkit🏳️‍⚧️
00d303ac36 fix: editing pref store (#618) 2024-10-29 19:37:19 +08:00
lollipopkit🏳️‍⚧️
229983d82e chore: bump version 2024-10-17 14:06:45 +08:00
Mased
4928ca600d [Translation] some Russian fixes (#604) 2024-10-07 21:04:36 +08:00
lollipopkit🏳️‍⚧️
89ec2d94d6 opt.: virt keys loading 2024-10-05 10:18:00 +08:00
lollipopkit🏳️‍⚧️
393c3e6388 Merge branch 'main' of github.com:lollipopkit/flutter_server_box 2024-10-05 10:00:41 +08:00
Gitro
dee458e926 new: material you icon (#599) 2024-10-04 12:19:10 +08:00
lollipopkit🏳️‍⚧️
f89228db40 opt.: ssh page 2024-09-28 17:09:35 +08:00
lollipopkit🏳️‍⚧️
3b6fb6194b opt.: more virtual keys (#596) 2024-09-28 14:40:22 +08:00
lollipopkit🏳️‍⚧️
02444fc2f0 new: ios18 dark app icon (#594)
Fixes #593
2024-09-25 19:16:14 +08:00
lollipopkit🏳️‍⚧️
aef317a140 opt.: dismiss notification if no ssh conn (#592) 2024-09-24 22:01:35 +08:00
lollipopkit🏳️‍⚧️
47aedb2f2e fix: rename file 2024-09-22 16:06:09 +08:00
lollipopkit🏳️‍⚧️
eab06abcaf opt. 2024-09-21 23:12:15 +08:00
lollipopkit🏳️‍⚧️
c062c12a0e opt.: redesigned settings page (#587) 2024-09-21 22:37:42 +08:00
lollipopkit🏳️‍⚧️
d7669c94b8 opt.: spi with same id (#585) 2024-09-21 11:54:56 +08:00
lollipopkit🏳️‍⚧️
50af289574 migrate: fl_lib 2024-09-21 11:01:41 +08:00
lollipopkit🏳️‍⚧️
90b88ed795 opt.: sync immediately after changes (#577) 2024-09-14 17:08:51 +08:00
CakesTwix
d611fdcd50 l10n: Added Ukrainian lang (#574) 2024-09-14 14:48:23 +08:00
lollipopkit🏳️‍⚧️
db9b2dd818 fix: snippet fmt (#570) 2024-09-01 21:07:32 +08:00
lollipopkit🏳️‍⚧️
edb49ead67 opt.: split webdav & other settings (#569) 2024-08-31 21:45:09 +08:00
lollipopkit🏳️‍⚧️
7f0dc656b8 Merge pull request #568 from lollipopkit/lollipopkit/issue564 2024-08-31 19:36:50 +08:00
lollipopkit🏳️‍⚧️
b33d0bbc3e opt.: back btn on scan page
Fixes #564
2024-08-31 19:33:57 +08:00
lollipopkit🏳️‍⚧️
7d0ea8a58b Merge branch 'android_service' 2024-08-31 19:28:04 +08:00
Noo6
c18732d8f3 fix: backup on windows (#563) 2024-08-30 22:44:08 +08:00
Shin
157af0a354 l10n: fixed Japanese translations. (#558) 2024-08-30 11:35:13 +08:00
lollipopkit🏳️‍⚧️
2d9dc044f9 new: custom foreground service on Android (#556) 2024-08-28 16:42:09 +08:00
lollipopkit🏳️‍⚧️
479250c207 init 2024-08-28 13:44:54 +08:00
Noo6
aef7ec911f opt: ssh tab page 2024-08-28 13:18:21 +08:00
lollipopkit🏳️‍⚧️
4f9ee7781f fix: ssh tab page UI (#555) 2024-08-27 17:20:47 +08:00
lollipopkit🏳️‍⚧️
eb83d05c81 fix: ssh alter url (#554) 2024-08-27 15:22:26 +08:00
lollipopkit🏳️‍⚧️
329fd33b69 fix: lang switch (#553) 2024-08-27 14:36:00 +08:00
lollipopkit🏳️‍⚧️
931c5f0bf6 opt.: alterUrl (#550) 2024-08-25 22:52:47 +08:00
lollipopkit🏳️‍⚧️
bcbf1fbc17 fix: fdroid build (#548) 2024-08-25 22:06:55 +08:00
Noo6
3e7315dac6 fix: ssh page displays the CustomAppBar on desktop 2024-08-18 20:04:39 +08:00
Noo6
4cecfdf7a8 opt: ssh card text overflow 2024-08-18 13:57:20 +08:00
Noo6
0346821cf5 opt: windows and linux drag area 2024-08-18 13:27:16 +08:00
lollipopkit🏳️‍⚧️
966a60a82d migrate: appleos 2024-08-17 22:59:32 +08:00
lollipopkit🏳️‍⚧️
76e98c6468 feat: custom shell script install path (#545) 2024-08-17 22:44:35 +08:00
lollipopkit🏳️‍⚧️
d7ae8b75b8 feat: custom net dev (#543) 2024-08-17 21:57:39 +08:00
lollipopkit🏳️‍⚧️
b5329e2692 fix: tags dialog 2024-08-16 21:02:05 +08:00
lollipopkit🏳️‍⚧️
ef297673f3 fix: privatekey update actually creates a new key (#541)
Fixes #540
2024-08-16 21:00:34 +08:00
lollipopkit🏳️‍⚧️
7558b4806d opt.: TagSwitcher related 2024-08-16 19:09:54 +08:00
Noo6
f7ef8a3915 opt: hidden at launch on linux and macos 2024-08-16 16:19:58 +08:00
lollipopkit🏳️‍⚧️
38366a2ef3 refactors (#539) 2024-08-16 01:24:43 +08:00
lollipopkit🏳️‍⚧️
7e5bb54c98 opt.: hide logo if distribution == null (#536) 2024-08-15 18:02:31 +08:00
lollipopkit🏳️‍⚧️
7ce3854392 opt.: use json_serializable 2024-08-15 16:44:13 +08:00
lollipopkit🏳️‍⚧️
195ddd2bcc refactor: SSHClientX.exec 2024-08-15 11:27:22 +08:00
lollipopkit🏳️‍⚧️
267b0b0a69 opt.: sftp home & back (#533) 2024-08-14 19:01:44 +08:00
lollipopkit🏳️‍⚧️
41e3fcb23a feat: systemd management (#532) 2024-08-14 14:29:03 +08:00
lollipopkit🏳️‍⚧️
46d5840276 feat: share server via qrcode (#530) 2024-08-13 20:04:01 +08:00
lollipopkit🏳️‍⚧️
fe566e97ca chore: README 2024-08-11 23:29:11 +08:00
lollipopkit🏳️‍⚧️
ddd1524d63 opt.: macos icon (#527) 2024-08-11 22:40:11 +08:00
lollipopkit🏳️‍⚧️
4d8268c614 fix: ssh tab focus mgmt (#525)
Fixes #522
2024-08-11 22:36:52 +08:00
lollipopkit🏳️‍⚧️
568b97606a opt.: split single list into multiples on desktop (#524) 2024-08-11 20:53:25 +08:00
lollipopkit🏳️‍⚧️
42cc2416a1 chore: README 2024-08-09 11:59:45 +08:00
396 changed files with 58557 additions and 12979 deletions

15
.github/FUNDING.yml vendored
View File

@@ -1,14 +1 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: lollipopkit
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
custom: ['https://afdian.com/a/lollipopkit'] # Replace with up to 4 custom sponsorship URLs
custom: ['https://cdn.lpkt.cn/donate']

View File

@@ -16,18 +16,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 1
- uses: subosito/flutter-action@v2
with:
channel: 'stable' # or: 'beta', 'dev' or 'master'
channel: 'stable'
- name: Install dependencies
run: flutter pub get
# Uncomment this step to verify the use of 'dart format' on each commit.
- name: Verify formatting
run: dart format --output=none .
# Consider passing '--fatal-infos' for slightly stricter analysis.
- name: Analyze project source
run: dart analyze

66
.github/workflows/claude.yml vendored Normal file
View File

@@ -0,0 +1,66 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
github.actor == 'lollipopkit' && (
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
)
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@beta
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
# model: "claude-opus-4-1-20250805"
# Optional: Customize the trigger phrase (default: @claude)
# trigger_phrase: "/claude"
# Optional: Trigger when specific user is assigned to an issue
# assignee_trigger: "claude-bot"
# Optional: Allow Claude to run specific commands
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
# Optional: Add custom instructions for Claude to customize its behavior for your project
# custom_instructions: |
# Follow our coding standards
# Ensure all new code has tests
# Use TypeScript for new files
# Optional: Custom environment variables for Claude
# claude_env: |
# NODE_ENV: test

View File

@@ -1,6 +1,7 @@
name: Flutter Release
on:
workflow_dispatch:
push:
tags:
- "v*"
@@ -8,22 +9,27 @@ on:
permissions:
contents: write
# Set by fl_build
# env:
# APP_NAME: ServerBox
# BUILD_NUMBER: ${{ github.ref_name }}
jobs:
releaseAndroid:
name: Release android
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Flutter
uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version: '3.22.3'
channel: "stable"
flutter-version: "3.38.0"
- 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
@@ -47,12 +53,19 @@ jobs:
releaseLinux:
name: Release linux
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- 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
@@ -69,7 +82,7 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Flutter
uses: subosito/flutter-action@v2
- name: Build
@@ -87,19 +100,15 @@ jobs:
# runs-on: macos-latest
# steps:
# - name: Checkout
# uses: actions/checkout@v4
# uses: actions/checkout@v6
# - 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,mac
# run: dart run fl_build -p ios
# - name: Create Release
# uses: softprops/action-gh-release@v2
# with:
# files: |
# ${{ env.APP_NAME }}_universal_macos.zip
# ${{ env.APP_NAME }}_universal.ipa
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

2
.gitignore vendored
View File

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

95
CLAUDE.md Normal file
View File

@@ -0,0 +1,95 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
### Development
- `flutter run` - Run the app in development mode
- `dart run fl_build -p PLATFORM` - Build the app for specific platform (see fl_build package)
- `dart run build_runner build --delete-conflicting-outputs` - Generate code for models with annotations (json_serializable, freezed, hive, riverpod)
- Every time you change model files, run this command to regenerate code (Hive adapters, Riverpod providers, etc.)
- Generated files include: `*.g.dart`, `*.freezed.dart` files
### Testing
- `flutter test` - Run unit tests
- `flutter test test/battery_test.dart` - Run specific test file
## Architecture
This is a Flutter application for managing Linux servers with the following key architectural components:
### Project Structure
- `lib/core/` - Core utilities, extensions, and routing
- `lib/data/` - Data layer with models, providers, and storage
- `model/` - Data models organized by feature (server, container, ssh, etc.)
- `provider/` - Riverpod providers for state management
- `store/` - Local storage implementations using Hive
- `lib/view/` - UI layer with pages and widgets
- `lib/generated/` - Generated localization files
- `lib/hive/` - Hive adapters for local storage
### Key Technologies
- **State Management**: Riverpod with code generation (riverpod_annotation)
- **Local Storage**: Hive for persistent data with generated adapters
- **SSH/SFTP**: Custom dartssh2 fork for server connections
- **Terminal**: Custom xterm.dart fork for SSH terminal interface
- **Networking**: dio for HTTP requests
- **Charts**: fl_chart for server status visualization
- **Localization**: Flutter's built-in i18n with ARB files
- **Code Generation**: Uses build_runner with json_serializable, freezed, hive_generator, riverpod_generator
### Data Models
- Server management models in `lib/data/model/server/`
- Container/Docker models in `lib/data/model/container/`
- SSH and SFTP models in respective directories
- Most models use freezed for immutability and json_annotation for serialization
### Features
- Server status monitoring (CPU, memory, disk, network)
- SSH terminal with virtual keyboard
- SFTP file browser
- Docker container management
- Process and systemd service management
- Server snippets and custom commands
- Multi-language support (12+ languages)
- Cross-platform support (iOS, Android, macOS, Linux, Windows)
### State Management Pattern
- Uses Riverpod providers for dependency injection and state management
- Uses Freezed for immutable state models
- Providers are organized by feature in `lib/data/provider/`
- State is often persisted using Hive stores in `lib/data/store/`
### Build System
- Uses custom `fl_build` package for cross-platform building
- `make.dart` script handles pre/post build tasks (metadata generation)
- Supports building for multiple platforms with platform-specific configurations
- Many dependencies are custom forks hosted on GitHub (dartssh2, xterm, fl_lib, etc.)
### Important Notes
- **Never run code formatting commands** - The codebase has specific formatting that should not be changed
- **Always run code generation** after modifying models with annotations (freezed, json_serializable, hive, riverpod)
- Generated files (`*.g.dart`, `*.freezed.dart`) should not be manually edited
- AGAIN, NEVER run code formatting commands.
- USE dependency injection via GetIt for services like Stores, Services and etc.
- Generate all l10n files using `flutter gen-l10n` command after modifying ARB files.
- USE `hive_ce` not `hive` package for Hive integration.
- Which no need to config `HiveField` and `HiveType` manually.
- USE widgets and utilities from `fl_lib` package for common functionalities.
- Such as `CustomAppBar`, `context.showRoundDialog`, `Input`, `Btnx.cancelOk`, etc.
- You can use context7 MCP to search `lppcg fl_lib KEYWORD` to find relevant widgets and utilities.
- USE `libL10n` and `l10n` for localization strings.
- `libL10n` is from `fl_lib` package, and `l10n` is from this project.
- Before adding new strings, check if it already exists in `libL10n`.
- Prioritize using strings from `libL10n` to avoid duplication, even if the meaning is not 100% exact, just use the substitution of `libL10n`.
- Split UI into Widget build, Actions, Utils. use `extension on` to achieve this

143
LICENSE
View File

@@ -1,5 +1,5 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
@@ -7,17 +7,15 @@
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
@@ -72,7 +60,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
GNU Affero General Public License for more details.
You should have received a copy of the GNU General Public License
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View File

@@ -2,82 +2,87 @@ English | [简体中文](README_zh.md)
<h2 align="center">Flutter Server Box</h2>
<p align="center">
<img alt="lang" src="https://img.shields.io/badge/lang-dart-pink">
<img alt="license" src="https://img.shields.io/badge/license-GPLv3-pink">
</p>
<div align="center">
<a href="https://cdn.lpkt.cn/donate"><img alt="donate" src="https://img.shields.io/badge/donate-me-pink"></a>
<img alt="lang" src="https://img.shields.io/badge/lang-dart-cyan">
<img alt="license" src="https://img.shields.io/badge/license-AGPLv3-yellow">
<a href="https://deepwiki.com/lollipopkit/flutter_server_box"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
</div>
<p align="center">
A Flutter project which provide charts to display <a href="../../issues/43">Linux</a> server status and tools to manage server.
A Flutter project which provides charts to display Linux, Unix and Windows server status and tools to manage servers.
<br>
Especially thanks to <a href="https://github.com/TerminalStudio/dartssh2">dartssh2</a> & <a href="https://github.com/TerminalStudio/xterm.dart">xterm.dart</a>.
</p>
## 🏙️ Screenshots
## 📥 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!**
- `AppStore` & `CDN` packages are built by myself
- Github releases are built by Github Actions
- Other sources are built by themselves
## 🔖 Feature
- `Status chart` (CPU, Sensors, GPU...), `SSH` Term, `SFTP`, `Docker & Process`...
- 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); Español, Русский язык, Português, 日本語 (Generated by GPT)
## 🏙️ ScreenShots
<table>
<tr>
<td><img width="277px" src="https://cdn.lolli.tech/serverbox/screenshot/1.png"></td>
<td><img width="277px" src="https://cdn.lolli.tech/serverbox/screenshot/2.png"></td>
<td><img width="277px" src="https://cdn.lolli.tech/serverbox/screenshot/3.png"></td>
</tr>
<tr>
<td><img width="277px" src="https://cdn.lolli.tech/serverbox/screenshot/4.png"> </td>
<td><img width="277px" src="https://cdn.lolli.tech/serverbox/screenshot/5.png"></td>
<td><img width="277px" src="https://cdn.lolli.tech/serverbox/screenshot/6.png"></td>
<td><img width="200px" src="https://cdn.lpkt.cn/serverbox/screenshot/1.jpg"></td>
<td><img width="200px" src="https://cdn.lpkt.cn/serverbox/screenshot/2.jpg"></td>
<td><img width="200px" src="https://cdn.lpkt.cn/serverbox/screenshot/3.jpg"></td>
<td><img width="200px" src="https://cdn.lpkt.cn/serverbox/screenshot/4.jpg"></td>
</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) |
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`...
- 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>
- In order to push server status to your portable device without opening ServerBox app (Such as **message push** and **home widget**), you need to install [ServerBoxMonitor](https://github.com/lollipopkit/server_box_monitor) on your servers, and config it correctly. See [wiki](https://github.com/lollipopkit/server_box_monitor/wiki) for more details.
- **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
- [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
`GPL v3 lollipopkit`
`AGPL v3 lollipopkit & all contributors`

View File

@@ -2,84 +2,88 @@
<h2 align="center">Flutter Server Box</h2>
<p align="center">
<img alt="lang" src="https://img.shields.io/badge/lang-dart-pink">
<img alt="license" src="https://img.shields.io/badge/license-GPLv3-pink">
</p>
<div align="center">
<a href="https://cdn.lpkt.cn/donate"><img alt="donate" src="https://img.shields.io/badge/捐赠-我-pink"></a>
<img alt="语言" src="https://img.shields.io/badge/语言-dart-cyan">
<img alt="license" src="https://img.shields.io/badge/证书-AGPLv3-yellow">
<a href="https://deepwiki.com/lollipopkit/flutter_server_box"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
</div>
<p align="center">
使用 Flutter 开发的 <a href="../../issues/43">Linux</a> 服务器工具箱,提供服务器状态图表和管理工具。
使用 Flutter 开发的 Linux, Unix, Windows 服务器工具箱,提供服务器状态图表和管理工具。
<br>
特别感谢 <a href="https://github.com/TerminalStudio/dartssh2">dartssh2</a> & <a href="https://github.com/TerminalStudio/xterm.dart">xterm.dart</a>
</p>
## 🏙️ 截屏
<table>
<tr>
<td><img width="200px" src="https://cdn.lpkt.cn/serverbox/screenshot/1.jpg"></td>
<td><img width="200px" src="https://cdn.lpkt.cn/serverbox/screenshot/2.jpg"></td>
<td><img width="200px" src="https://cdn.lpkt.cn/serverbox/screenshot/3.jpg"></td>
<td><img width="200px" src="https://cdn.lpkt.cn/serverbox/screenshot/4.jpg"></td>
</tr>
</table>
## 📥 安装
平台 | 下载
--- | ---
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)
**请不要从不受信任的来源下载!**
- `AppStore` & `CDN` 的包由我构建
- Github 的包由 Github Actions 构建
- 其他来源由其所有者构建
平台|下载
--|--
iOS / macOS | [AppStore](https://apps.apple.com/app/id1586449703)
Android | [GitHub](https://github.com/lollipopkit/flutter_server_box/releases) / [CDN](https://cdn.lpkt.cn/serverbox/pkg/?sort=time&order=desc&layout=grid) / [F-Droid](https://f-droid.org/packages/tech.lolli.toolbox) / [OpenAPK](https://www.openapk.net/serverbox/tech.lolli.toolbox/)
Linux / Windows | [GitHub](https://github.com/lollipopkit/flutter_server_box/releases) / [CDN](https://cdn.lpkt.cn/serverbox/pkg/?sort=time&order=desc&layout=grid)
请从 **信任** 的来源下载!
## 🔖 特点
- `状态图表`CPU、传感器、GPU 等), `SSH` 终端, `SFTP`, `Docker & 进程` 管理...
- `状态图表`CPU、传感器、GPU 等), `SSH` 终端, `SFTP`, `Docker & 进程 & Systemd` 管理,`S.M.A.R.T`...
- 特殊支持:`生物认证``推送``桌面小部件``watchOS App``跟随系统颜色`...
- 本地化
- English, 简体中文
- Español, Русский язык, Português, 日本語 (Generated by GPT)
- 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);
## 🏙️ 截屏
<table>
<tr>
<td><img width="277px" src="https://cdn.lolli.tech/serverbox/screenshot/1.png"></td>
<td><img width="277px" src="https://cdn.lolli.tech/serverbox/screenshot/2.png"></td>
<td><img width="277px" src="https://cdn.lolli.tech/serverbox/screenshot/3.png"></td>
</tr>
<tr>
<td><img width="277px" src="https://cdn.lolli.tech/serverbox/screenshot/4.png"> </td>
<td><img width="277px" src="https://cdn.lolli.tech/serverbox/screenshot/5.png"></td>
<td><img width="277px" src="https://cdn.lolli.tech/serverbox/screenshot/6.png"></td>
</tr>
</table>
- 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);
- 感谢贡献者们!
## 🆘 帮助
- 吹水、参与开发、了解如何使用QQ群 **762870488**
- 为了可以在不使用 ServerBox app 时获取服务器状态(例如:桌面小部件、推送服务),你需要在你的服务器上安装 [ServerBoxMonitor](https://github.com/lollipopkit/server_box_monitor),并且正确配置,详情可见 [wiki](https://github.com/lollipopkit/server_box_monitor/wiki/%E4%B8%BB%E9%A1%B5)。
- **常见问题**可以在 [app wiki](https://github.com/lollipopkit/flutter_server_box/wiki/主页) 查看。
<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>
- 为了可以在不使用 ServerBox app 时获取服务器状态(例如:桌面小部件、推送服务),你需要在你的服务器上安装 [ServerBoxMonitor](https://github.com/lollipopkit/server_box_monitor),详情见 [wiki](https://github.com/lollipopkit/server_box_monitor/wiki/%E4%B8%BB%E9%A1%B5)。
- **常见问题** 可以在 [app wiki](https://github.com/lollipopkit/flutter_server_box/wiki/主页) 查看。
反馈前须知:
1. 反馈问题请附带 log点击首页右上角并以 bug 模版提交。
2. 反馈问题前请检查是否是 serverbox 的问题。
3. 欢迎所有有效、正面的反馈主观比如你觉得其他UI更好看的反馈不一定会接受
确认了解上述内容后,请在 [问题](https://github.com/lollipopkit/flutter_server_box/issues/new) 中反馈。
## 🧱 贡献
任何正面的贡献都欢迎。
如果我忘记在贡献者列表中添加你的名字,请在你打开的 issue 或 PR 中添加评论让我知道,我会尽快添加。
### 开发
1. 安装 [Flutter](https://flutter.dev/docs/get-started/install)
2. 克隆这个仓库, 运行 `flutter run` 启动应用
3. 运行 `dart run fl_build -p PLATFORM` 构建应用
### 翻译
[指南](https://blog.lolli.tech/faq/) 可在我的博客中找到。
[指南](https://blog.lpkt.cn/faq/) 可在我的博客中找到。
## 💡 我的其它 Apps
- [GPT Box](https://github.com/lollipopkit/flutter_gpt_box) - 支持 OpenAI API 的 第三方全平台客户端。
- [更多](https://github.com/lollipopkit) - 工具 & etc.
## 📝 协议
`GPL v3 lollipopkit`
`AGPL v3 lollipopkit & 所有贡献者`

View File

@@ -11,11 +11,13 @@ 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
@@ -30,14 +32,20 @@ linter:
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
library_private_types_in_public_api: false
library_private_types_in_public_api: true
use_build_context_synchronously: false
depend_on_referenced_packages: false
prefer_final_locals: true
unnecessary_parenthesis: true
implicit_call_tearoffs: true
always_declare_return_types: true
always_use_package_imports: true
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

@@ -53,7 +53,6 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "tech.lolli.toolbox"
// You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
@@ -86,13 +85,20 @@ android {
}
debug {
applicationIdSuffix '.debug'
// No applicationIdSuffix or resValue here
}
profile {
applicationIdSuffix '.debug'
// No applicationIdSuffix or resValue here
}
}
dependenciesInfo {
// Disables dependency metadata when building APKs.
includeInApk = false
// Disables dependency metadata when building Android App Bundles.
includeInBundle = false
}
}
flutter {
@@ -107,7 +113,7 @@ android.applicationVariants.all { variant ->
variant.outputs.each { output ->
def abiVersionCode = project.ext.abiCodes.get(output.getFilter(OutputFile.ABI))
if (abiVersionCode != null) {
output.versionCodeOverride = variant.versionCode * 10 + abiVersionCode
output.versionCodeOverride = variant.versionCode * 100 + abiVersionCode
}
}
}

View File

@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
@@ -10,18 +11,20 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:label="ServerBox"
android:label="@string/app_name"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:allowBackup="true"
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:hasFragileUserData="true"
android:restoreAnyVersion="true">
android:restoreAnyVersion="true"
tools:targetApi="q">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|locale|layoutDirection|fontScale|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
@@ -29,12 +32,12 @@
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
@@ -43,11 +46,15 @@
android:name="flutterEmbedding"
android:value="2" />
<service
android:name="id.flutter.flutter_background_service.BackgroundService"
android:foregroundServiceType="dataSync"
/>
<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"
@@ -67,7 +74,12 @@
android:resource="@xml/home_widget" />
</receiver>
<service android:name=".KeepAliveService"/>
<service
android:name=".ForegroundService"
android:enabled="true"
android:foregroundServiceType="dataSync"
android:exported="false" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility?hl=en and
@@ -76,8 +88,8 @@
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>
</manifest>

View File

@@ -0,0 +1,320 @@
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()
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
}
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? {
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)
}
}
}
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 {
val notificationIntent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE
)
val stopIntent = Intent(this, ForegroundService::class.java).apply { action = ACTION_STOP_FOREGROUND }
val stopPending = PendingIntent.getService(this, 0, stopIntent, PendingIntent.FLAG_IMMUTABLE)
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, chanId)
} else {
@Suppress("DEPRECATION")
Notification.Builder(this)
}
// Use the earliest session's start time for chronometer
val earliestStartTime = sessions.minOfOrNull { it.startWhen } ?: System.currentTimeMillis()
val title = when (count) {
0 -> "Server Box"
1 -> sessions.first().title
else -> "SSH sessions: $count active"
}
val contentText = when (count) {
0 -> "Ready for connections"
1 -> {
val session = sessions.first()
"${session.subtitle} · ${session.status}"
}
else -> "Multiple SSH connections active"
}
// For multiple sessions, show details in expanded view
val style = if (count > 1) {
val inbox = Notification.InboxStyle()
val maxLines = 5
val displayLines = if (lines.size > maxLines) {
lines.take(maxLines) + "...and ${lines.size - maxLines} more"
} else {
lines
}
displayLines.forEach { inbox.addLine(it) }
inbox.setBigContentTitle(title)
inbox
} else {
null
}
val notification = builder
.setContentTitle(title)
.setContentText(contentText)
.setSmallIcon(R.mipmap.ic_launcher)
.setWhen(earliestStartTime)
.setUsesChronometer(true)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setContentIntent(pendingIntent)
.addAction(
Notification.Action.Builder(
Icon.createWithResource(this, android.R.drawable.ic_delete),
"Stop All",
stopPending
).build()
)
if (style != null) {
notification.setStyle(style)
}
return notification.build()
}
private fun handleUpdateSessions(payload: String) {
val nm = getSystemService(NotificationManager::class.java)
if (nm == null) {
logError("NotificationManager null")
return
}
val sessions = mutableListOf<SessionItem>()
try {
val obj = JSONObject(payload)
val arr: JSONArray = obj.optJSONArray("sessions") ?: JSONArray()
for (i in 0 until arr.length()) {
val s = arr.optJSONObject(i) ?: continue
val id = s.optString("id")
val title = s.optString("title")
val sub = s.optString("subtitle")
val whenMs = s.optLong("startTimeMs", System.currentTimeMillis())
val status = s.optString("status", "connected")
if (id.isNotEmpty()) {
sessions.add(SessionItem(id, title, sub, whenMs, status))
}
}
} catch (e: Exception) {
logError("Failed to parse payload", e)
}
// Clear if empty
if (sessions.isEmpty()) {
clearAll()
return
}
// Cancel any existing individual notifications (we only show merged notification now)
val toCancel = postedIds.toSet()
toCancel.forEach { nm.cancel(it) }
postedIds.clear()
notificationIdMap.clear()
// Create merged notification content
val summaryLines = sessions.map { "${it.title}: ${it.status}" }
val mergedNotification = createMergedNotification(sessions.size, summaryLines, sessions)
ensureForeground(mergedNotification)
}
private fun clearAll() {
val nm = getSystemService(NotificationManager::class.java)
nm?.cancel(NOTIFICATION_ID)
postedIds.forEach { id -> nm?.cancel(id) }
postedIds.clear()
isFgStarted = false
}
data class SessionItem(
val id: String,
val title: String,
val subtitle: String,
val startWhen: Long,
val status: String,
)
private fun stopForegroundService() {
try {
if (isFgStarted) {
stopForeground(STOP_FOREGROUND_REMOVE)
isFgStarted = false
}
} catch (e: Exception) {
logError("Error stopping foreground", e)
}
stopSelf()
Log.d("ForegroundService", "ForegroundService stopped")
}
override fun onDestroy() {
super.onDestroy()
Log.d("ForegroundService", "Service onDestroy")
isRunning = false
}
}

View File

@@ -1,18 +0,0 @@
package tech.lolli.toolbox
import android.app.Service
import android.content.Intent
import android.os.IBinder
import org.jetbrains.annotations.Nullable
class KeepAliveService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return START_STICKY
}
@Nullable
override fun onBind(intent: Intent?): IBinder? {
return null
}
}

View File

@@ -1,31 +1,205 @@
package tech.lolli.toolbox
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
MethodChannel(binaryMessenger, "tech.lolli.toolbox/app_retain").apply {
setMethodCallHandler { method, result ->
channel = MethodChannel(binaryMessenger, "tech.lolli.toolbox/main_chan")
channel.setMethodCallHandler { method, result ->
when (method.method) {
"sendToBackground" -> {
moveTaskToBack(true)
result.success(null)
}
"isServiceRunning" -> {
result.success(ForegroundService.isRunning)
}
"startService" -> {
val intent = Intent(this@MainActivity, KeepAliveService::class.java)
startService(intent)
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)
}
}
"stopService" -> {
val serviceIntent = Intent(this@MainActivity, ForegroundService::class.java)
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
}
}
}

View File

@@ -6,109 +6,216 @@ 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.DelicateCoroutinesApi
import kotlinx.coroutines.CoroutineScope
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) {
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 intentUpdate = Intent(context, HomeWidget::class.java)
intentUpdate.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
val ids = intArrayOf(appWidgetId)
intentUpdate.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
var flag = PendingIntent.FLAG_UPDATE_CURRENT
if (Build.VERSION_CODES.O <= Build.VERSION.SDK_INT) {
flag = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
}
val pendingUpdate: PendingIntent = PendingIntent.getBroadcast(
context,
appWidgetId,
intentUpdate,
flag)
views.setOnClickPendingIntent(R.id.widget_container, pendingUpdate)
if (url.isNullOrEmpty()) {
views.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)
// Prevent concurrent updates for the same widget
if (activeUpdates.putIfAbsent(appWidgetId, true) == true) {
Log.d(TAG, "Widget $appWidgetId is already updating, skipping")
return
} else {
views.setViewVisibility(R.id.widget_cpu_label, View.VISIBLE)
views.setViewVisibility(R.id.widget_mem_label, View.VISIBLE)
views.setViewVisibility(R.id.widget_disk_label, View.VISIBLE)
views.setViewVisibility(R.id.widget_net_label, View.VISIBLE)
}
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")
val views = RemoteViews(context.packageName, R.layout.home_widget)
val url = getWidgetUrl(context, appWidgetId)
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
if (url.isNullOrEmpty()) {
Log.w(TAG, "URL not found for widget $appWidgetId")
showErrorState(views, appWidgetManager, appWidgetId, "Please configure the widget URL.")
activeUpdates.remove(appWidgetId)
return
}
setupClickIntent(context, views, appWidgetId)
showLoadingState(views, appWidgetManager, appWidgetId)
CoroutineScope(Dispatchers.IO).launch {
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)
}
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)
} ?: 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
}
val pendingConfigure = PendingIntent.getActivity(context, appWidgetId, intentConfigure, flag)
views.setOnClickPendingIntent(R.id.widget_container, pendingConfigure)
}
private suspend fun fetchServerData(url: String): ServerData? = withContext(Dispatchers.IO) {
var connection: HttpURLConnection? = null
try {
connection = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = NETWORK_TIMEOUT.toInt()
readTimeout = NETWORK_TIMEOUT.toInt()
setRequestProperty("User-Agent", "ServerBox-Widget/1.0")
setRequestProperty("Accept", "application/json")
}
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
throw IOException("HTTP ${connection.responseCode}: ${connection.responseMessage}")
}
val jsonStr = connection.inputStream.bufferedReader().use { it.readText() }
parseServerData(jsonStr)
} finally {
connection?.disconnect()
}
}
private fun parseServerData(jsonStr: String): ServerData? {
return try {
val jsonObject = JSONObject(jsonStr)
val data = jsonObject.getJSONObject("data")
val server = data.optString("name", "Unknown Server")
val cpu = data.optString("cpu", "").takeIf { it.isNotBlank() } ?: "N/A"
val mem = data.optString("mem", "").takeIf { it.isNotBlank() } ?: "N/A"
val disk = data.optString("disk", "").takeIf { it.isNotBlank() } ?: "N/A"
val net = data.optString("net", "").takeIf { it.isNotBlank() } ?: "N/A"
// Return data even if some fields are missing, providing defaults
// Only reject if we can't parse the JSON structure properly
ServerData(server, cpu, mem, disk, net)
} catch (e: JSONException) {
Log.e(TAG, "JSON parsing error: ${e.message}", e)
null
}
}
private fun showLoadingState(views: RemoteViews, appWidgetManager: AppWidgetManager, appWidgetId: Int) {
views.apply {
setTextViewText(R.id.widget_name, "Loading...")
setViewVisibility(R.id.error_message, View.GONE)
setViewVisibility(R.id.widget_content, View.VISIBLE)
setViewVisibility(R.id.widget_cpu_label, View.VISIBLE)
setViewVisibility(R.id.widget_mem_label, View.VISIBLE)
setViewVisibility(R.id.widget_disk_label, View.VISIBLE)
setViewVisibility(R.id.widget_net_label, View.VISIBLE)
setViewVisibility(R.id.widget_progress, View.VISIBLE)
setFloat(R.id.widget_name, "setAlpha", 0.7f)
}
appWidgetManager.updateAppWidget(appWidgetId, views)
}
private fun showSuccessState(views: RemoteViews, appWidgetManager: AppWidgetManager, appWidgetId: Int, data: ServerData) {
views.apply {
setTextViewText(R.id.widget_name, data.name)
setTextViewText(R.id.widget_cpu, data.cpu)
setTextViewText(R.id.widget_mem, data.mem)
setTextViewText(R.id.widget_disk, data.disk)
setTextViewText(R.id.widget_net, data.net)
val timeStr = android.text.format.DateFormat.format("HH:mm", java.util.Date()).toString()
setTextViewText(R.id.widget_time, timeStr)
setViewVisibility(R.id.error_message, View.GONE)
setViewVisibility(R.id.widget_content, View.VISIBLE)
setViewVisibility(R.id.widget_progress, View.GONE)
// Smooth fade-in animation
setFloat(R.id.widget_name, "setAlpha", 1f)
setFloat(R.id.widget_cpu_label, "setAlpha", 1f)
setFloat(R.id.widget_mem_label, "setAlpha", 1f)
setFloat(R.id.widget_disk_label, "setAlpha", 1f)
setFloat(R.id.widget_net_label, "setAlpha", 1f)
setFloat(R.id.widget_time, "setAlpha", 1f)
}
appWidgetManager.updateAppWidget(appWidgetId, views)
}
private fun showErrorState(views: RemoteViews, appWidgetManager: AppWidgetManager, appWidgetId: Int, errorMessage: String) {
views.apply {
setTextViewText(R.id.widget_name, "Error")
setViewVisibility(R.id.error_message, View.VISIBLE)
setTextViewText(R.id.error_message, errorMessage)
setViewVisibility(R.id.widget_content, View.GONE)
setViewVisibility(R.id.widget_progress, View.GONE)
setFloat(R.id.widget_name, "setAlpha", 1f)
setFloat(R.id.error_message, "setAlpha", 1f)
}
appWidgetManager.updateAppWidget(appWidgetId, views)
}
data class ServerData(
val name: String,
val cpu: String,
val mem: String,
val disk: String,
val net: String
)
}

View File

@@ -0,0 +1,82 @@
package tech.lolli.toolbox.widget
import android.app.Activity
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.os.Bundle
import android.util.Patterns
import android.widget.Button
import android.widget.EditText
import tech.lolli.toolbox.R
class WidgetConfigureActivity : Activity() {
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
private lateinit var urlEditText: EditText
private lateinit var saveButton: Button
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.widget_configure)
// 设置结果为取消,以防用户在完成配置前退出
setResult(RESULT_CANCELED)
// 获取 widget ID
val extras = intent.extras
if (extras != null) {
appWidgetId = extras.getInt(
AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID
)
}
// 如果没有有效的 widget ID完成 activity
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
finish()
return
}
// 初始化 UI 元素
urlEditText = findViewById(R.id.url_edit_text)
saveButton = findViewById(R.id.save_button)
// 从 SharedPreferences 加载现有配置
val sp = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)
val existingUrl = sp.getString("widget_$appWidgetId", "")
urlEditText.setText(existingUrl)
// 设置保存按钮点击事件
saveButton.setOnClickListener {
val url = urlEditText.text.toString().trim()
if (url.isEmpty()) {
urlEditText.error = "Please enter a URL"
return@setOnClickListener
}
// 验证 URL 格式
if (!Patterns.WEB_URL.matcher(url).matches()) {
urlEditText.error = "Please enter a valid URL"
return@setOnClickListener
}
// 保存 URL 到 SharedPreferences
val editor = sp.edit()
editor.putString("widget_$appWidgetId", url)
editor.apply()
// 更新 widget 使用 AppWidgetManager
val appWidgetManager = AppWidgetManager.getInstance(this)
val updateIntent = Intent(this, HomeWidget::class.java).apply {
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(appWidgetId))
}
sendBroadcast(updateIntent)
// 设置结果并结束 activity
val resultValue = Intent()
resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
setResult(RESULT_OK, resultValue)
finish()
}
}
}

View File

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

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Widget URL"
android:textSize="18sp"
android:textStyle="bold"
android:layout_marginBottom="16dp"
android:textColor="@android:color/black" />
<EditText
android:id="@+id/url_edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="https://server/status"
android:inputType="textUri"
android:layout_marginBottom="16dp"
android:background="@android:drawable/edit_text"
android:padding="12dp"
android:textColor="@android:color/black"
android:textColorHint="@android:color/darker_gray" />
<Button
android:id="@+id/save_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Save"
android:background="#8b2252"
android:textColor="@android:color/white"
android:padding="12dp" />
</LinearLayout>

View File

@@ -2,4 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 761 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 895 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,23 @@ 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,3 +1,6 @@
org.gradle.jvmargs=-Xmx4G
android.useAndroidX=true
android.enableJetifier=true
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=false
android.nonFinalResIds=false

View File

@@ -2,5 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip
distributionSha256Sum=6001aba9b2204d26fa25a5800bb9382cf3ee01ccb78fe77317b2872336eb2f80
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip

View File

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

6505
coverage/lcov.info Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

View File

@@ -0,0 +1,7 @@
Проект на базе Flutter, предоставляющий диаграммы состояний серверов под Linux, Unix и Windows и инструменты для управления ими.
Особая благодарность dartssh2 и xterm.dart.
* Диаграмма состояния (ЦП, датчики, видеокарта…), SSH Term, SFTP, Docker, пакеты, процессы…
* Платформозависимые: биометрическая аутентификация, push-уведомления, виджет, приложение для watchOS…
* Многоязычная поддержка: English, 简体中文; Deutsch, 繁體中文, Indonesian, Français, Dutch; Español, Русский язык, Português, 日本語

View File

@@ -0,0 +1 @@
Приложение для мониторинга серверов и набор инструментов управления ими

View File

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

View File

@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '12.0'
# platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View File

@@ -1,24 +1,25 @@
PODS:
- device_info_plus (0.0.1):
- app_links (6.4.1):
- Flutter
- camera_avfoundation (0.0.1):
- Flutter
- file_picker (0.0.1):
- Flutter
- Flutter (1.0.0)
- flutter_background_service_ios (0.0.3):
- flutter_native_splash (2.4.3):
- Flutter
- flutter_native_splash (0.0.1):
- flutter_secure_storage (6.0.0):
- Flutter
- icloud_storage (0.0.1):
- Flutter
- local_auth_darwin (0.0.1):
- Flutter
- FlutterMacOS
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- plain_notification_token (0.0.1):
- Flutter
- share_plus (0.0.1):
@@ -34,16 +35,16 @@ PODS:
- Flutter
DEPENDENCIES:
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- app_links (from `.symlinks/plugins/app_links/ios`)
- camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- Flutter (from `Flutter`)
- flutter_background_service_ios (from `.symlinks/plugins/flutter_background_service_ios/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- icloud_storage (from `.symlinks/plugins/icloud_storage/ios`)
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- plain_notification_token (from `.symlinks/plugins/plain_notification_token/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
@@ -52,16 +53,18 @@ DEPENDENCIES:
- watch_connectivity (from `.symlinks/plugins/watch_connectivity/ios`)
EXTERNAL SOURCES:
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
app_links:
:path: ".symlinks/plugins/app_links/ios"
camera_avfoundation:
:path: ".symlinks/plugins/camera_avfoundation/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
Flutter:
:path: Flutter
flutter_background_service_ios:
:path: ".symlinks/plugins/flutter_background_service_ios/ios"
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:
@@ -70,8 +73,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
plain_notification_token:
:path: ".symlinks/plugins/plain_notification_token/ios"
share_plus:
@@ -86,23 +87,23 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/watch_connectivity/ios"
SPEC CHECKSUMS:
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
file_picker: c79185e70b9b45728cde2a8d8da454e0cb43f287
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_background_service_ios: e30e0d3ee69e4cee66272d0c78eacd48c2e94aac
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
icloud_storage: d9ac7a33ced81df08ba7ea1bf3099cc0ee58f60a
local_auth_darwin: 4d56c90c2683319835a61274b57620df9c4520ab
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
plain_notification_token: b36467dc91939a7b6754267c701bbaca14996ee1
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1
watch_connectivity: 715eb484685e05846eab74795348a44bb2809b82
app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a
camera_avfoundation: 5675ca25298b6f81fa0a325188e7df62cc217741
file_picker: fb04e739ae6239a76ce1f571863a196a922c87d4
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
icloud_storage: e55639f0c0d7cb2b0ba9c0b3d5968ccca9cd9aa2
local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
plain_notification_token: 047876b9d80a5b93565ddcc13a487a7e7b906f7d
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
watch_connectivity: 88e5bea25b473e66ef8d3f960954d154ed0356d6
PODFILE CHECKSUM: ec6ef69056f066e8b21a3391082f23b5ad2d37f8
PODFILE CHECKSUM: 5a0fb6438066e44ab2c77bd223668d351b8d8461
COCOAPODS: 1.15.2
COCOAPODS: 1.16.2

View File

@@ -9,6 +9,10 @@
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
4A2DCD6B2E4B127100CF68B7 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */; };
4A2DCD6C2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */; };
4A2DCD6F2E4B128100CF68B7 /* TerminalLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD6D2E4B128100CF68B7 /* TerminalLiveActivity.swift */; };
4A2DCD702E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD6E2E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7538AEC32BB83FAB002AB82A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7538AEC22BB83FAB002AB82A /* PrivacyInfo.xcprivacy */; };
7538AEC52BB83FC8002AB82A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7538AEC42BB83FC8002AB82A /* PrivacyInfo.xcprivacy */; };
@@ -36,6 +40,8 @@
E3AE8AEB2AB601DB000A6459 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AE8AE92AB601DB000A6459 /* Utils.swift */; };
E3AE8AEC2AB601DB000A6459 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AE8AE92AB601DB000A6459 /* Utils.swift */; };
E3DB67ED2A31FE200027B8CB /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E3DB67EB2A31FE200027B8CB /* LaunchScreen.storyboard */; };
F0A1B2C31A2B3C4D5E6F0005 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = F0A1B2C31A2B3C4D5E6F0001 /* Localizable.strings */; };
F0A1B2C31A2B3C4D5E6F1005 /* Localizable.strings (StatusWidget) in Resources */ = {isa = PBXBuildFile; fileRef = F0A1B2C31A2B3C4D5E6F1001 /* Localizable.strings */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -95,6 +101,10 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
278C1EB3935F9285537B0516 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; };
4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLiveActivityAttributes.swift; sourceTree = "<group>"; };
4A2DCD6D2E4B128100CF68B7 /* TerminalLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLiveActivity.swift; sourceTree = "<group>"; };
4A2DCD6E2E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLiveActivityAttributes.swift; sourceTree = "<group>"; };
5A4B3EB10512B2EB8E10213B /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@@ -156,6 +166,26 @@
E3D26BD22B9966EC00D83425 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Main.strings; sourceTree = "<group>"; };
E3D26BD32B9966EC00D83425 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/LaunchScreen.strings; sourceTree = "<group>"; };
E3DB67EC2A31FE200027B8CB /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F0002 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F0003 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F0004 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F0006 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F0007 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F0008 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F0009 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F000A /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F000B /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F000C /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F1002 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F1003 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F1004 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F1006 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F1007 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F1008 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F1009 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F100A /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F100B /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F100C /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -233,6 +263,7 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
F0A1B2C31A2B3C4D5E6F0001 /* Localizable.strings */,
7538AEC22BB83FAB002AB82A /* PrivacyInfo.xcprivacy */,
E398BF6A29BDB34500FE4FD5 /* Runner.entitlements */,
97C146FA1CF9000F007C117D /* Main.storyboard */,
@@ -242,6 +273,8 @@
E39A76AD2AB9A2F70067C641 /* Info-Profile.plist */,
E39A76AC2AB9A2F70067C641 /* Info-Release.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */,
4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
E3AE8AE92AB601DB000A6459 /* Utils.swift */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
@@ -263,8 +296,11 @@
E33A3E3A2A626DCE009744AB /* StatusWidget */ = {
isa = PBXGroup;
children = (
F0A1B2C31A2B3C4D5E6F1001 /* Localizable.strings */,
7538AEC42BB83FC8002AB82A /* PrivacyInfo.xcprivacy */,
E33A3E3B2A626DCE009744AB /* StatusWidgetBundle.swift */,
4A2DCD6D2E4B128100CF68B7 /* TerminalLiveActivity.swift */,
4A2DCD6E2E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift */,
E33A3E3F2A626DCE009744AB /* StatusWidget.swift */,
E37C48ED2B9C30EE00E542D2 /* StatusWidget.intentdefinition */,
E33A3E442A626DD0009744AB /* Info.plist */,
@@ -302,7 +338,6 @@
E33A3E4A2A626DD0009744AB /* Embed Foundation Extensions */,
E39515D52AB5AD64003602C1 /* Embed Watch Content */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
955896919A10AA2BEC131F36 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -413,6 +448,7 @@
E39A76B02AB9A2F70067C641 /* Info-Profile.plist in Resources */,
7538AEC32BB83FAB002AB82A /* PrivacyInfo.xcprivacy in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
F0A1B2C31A2B3C4D5E6F0005 /* Localizable.strings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -421,6 +457,7 @@
buildActionMask = 2147483647;
files = (
7538AEC52BB83FC8002AB82A /* PrivacyInfo.xcprivacy in Resources */,
F0A1B2C31A2B3C4D5E6F1005 /* Localizable.strings (StatusWidget) in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -452,23 +489,6 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
955896919A10AA2BEC131F36 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -534,6 +554,8 @@
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
E37C48EA2B9C30EE00E542D2 /* StatusWidget.intentdefinition in Sources */,
E3AE8AEA2AB601DB000A6459 /* Utils.swift in Sources */,
4A2DCD6B2E4B127100CF68B7 /* LiveActivityManager.swift in Sources */,
4A2DCD6C2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -543,6 +565,8 @@
files = (
E33A3E402A626DCE009744AB /* StatusWidget.swift in Sources */,
E37C48EB2B9C30EE00E542D2 /* StatusWidget.intentdefinition in Sources */,
4A2DCD6F2E4B128100CF68B7 /* TerminalLiveActivity.swift in Sources */,
4A2DCD702E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */,
E33A3E3C2A626DCE009744AB /* StatusWidgetBundle.swift in Sources */,
E3AE8AEB2AB601DB000A6459 /* Utils.swift in Sources */,
);
@@ -628,6 +652,40 @@
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
F0A1B2C31A2B3C4D5E6F0001 /* Localizable.strings */ = {
isa = PBXVariantGroup;
children = (
F0A1B2C31A2B3C4D5E6F0002 /* en */,
F0A1B2C31A2B3C4D5E6F0003 /* zh-Hans */,
F0A1B2C31A2B3C4D5E6F0004 /* zh-Hant */,
F0A1B2C31A2B3C4D5E6F0006 /* fr */,
F0A1B2C31A2B3C4D5E6F0007 /* ru */,
F0A1B2C31A2B3C4D5E6F0008 /* es */,
F0A1B2C31A2B3C4D5E6F0009 /* de */,
F0A1B2C31A2B3C4D5E6F000A /* pt-BR */,
F0A1B2C31A2B3C4D5E6F000B /* id */,
F0A1B2C31A2B3C4D5E6F000C /* ja */,
);
name = Localizable.strings;
sourceTree = "<group>";
};
F0A1B2C31A2B3C4D5E6F1001 /* Localizable.strings */ = {
isa = PBXVariantGroup;
children = (
F0A1B2C31A2B3C4D5E6F1002 /* en */,
F0A1B2C31A2B3C4D5E6F1003 /* zh-Hans */,
F0A1B2C31A2B3C4D5E6F1004 /* zh-Hant */,
F0A1B2C31A2B3C4D5E6F1006 /* fr */,
F0A1B2C31A2B3C4D5E6F1007 /* ru */,
F0A1B2C31A2B3C4D5E6F1008 /* es */,
F0A1B2C31A2B3C4D5E6F1009 /* de */,
F0A1B2C31A2B3C4D5E6F100A /* pt-BR */,
F0A1B2C31A2B3C4D5E6F100B /* id */,
F0A1B2C31A2B3C4D5E6F100C /* ja */,
);
name = Localizable.strings;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
@@ -673,7 +731,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -690,17 +748,17 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 1051;
CURRENT_PROJECT_VERSION = 1291;
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1051;
MARKETING_VERSION = 1.0.1291;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -757,7 +815,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -807,7 +865,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -826,17 +884,17 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 1051;
CURRENT_PROJECT_VERSION = 1291;
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1051;
MARKETING_VERSION = 1.0.1291;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -854,17 +912,17 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 1051;
CURRENT_PROJECT_VERSION = 1291;
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1051;
MARKETING_VERSION = 1.0.1291;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -885,7 +943,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1051;
CURRENT_PROJECT_VERSION = 1291;
DEVELOPMENT_TEAM = BA88US33G6;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
@@ -898,7 +956,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.1051;
MARKETING_VERSION = 1.0.1291;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
@@ -924,7 +982,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1051;
CURRENT_PROJECT_VERSION = 1291;
DEVELOPMENT_TEAM = BA88US33G6;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
@@ -937,7 +995,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.1051;
MARKETING_VERSION = 1.0.1291;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -960,7 +1018,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1051;
CURRENT_PROJECT_VERSION = 1291;
DEVELOPMENT_TEAM = BA88US33G6;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
@@ -973,7 +1031,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.1051;
MARKETING_VERSION = 1.0.1291;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -996,7 +1054,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1051;
CURRENT_PROJECT_VERSION = 1291;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_PREVIEWS = YES;
@@ -1008,7 +1066,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1051;
MARKETING_VERSION = 1.0.1291;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
@@ -1037,7 +1095,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1051;
CURRENT_PROJECT_VERSION = 1291;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_PREVIEWS = YES;
@@ -1049,7 +1107,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1051;
MARKETING_VERSION = 1.0.1291;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
PRODUCT_NAME = ServerBox;
@@ -1075,7 +1133,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1051;
CURRENT_PROJECT_VERSION = 1291;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_PREVIEWS = YES;
@@ -1087,7 +1145,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1051;
MARKETING_VERSION = 1.0.1291;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
PRODUCT_NAME = ServerBox;

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -1 +1,37 @@
{"images":[{"scale":"3x","idiom":"universal","filename":"AppIcon-29.0x29.0@3x.png","size":"29x29","platform":"ios"},{"scale":"2x","idiom":"universal","filename":"AppIcon-29.0x29.0@2x.png","size":"29x29","platform":"ios"},{"scale":"3x","idiom":"universal","filename":"AppIcon-64.0x64.0@3x.png","size":"64x64","platform":"ios"},{"scale":"2x","idiom":"universal","filename":"AppIcon-20.0x20.0@2x.png","size":"20x20","platform":"ios"},{"scale":"2x","idiom":"universal","filename":"AppIcon-60.0x60.0@2x.png","size":"60x60","platform":"ios"},{"scale":"3x","idiom":"universal","filename":"AppIcon-40.0x40.0@3x.png","size":"40x40","platform":"ios"},{"scale":"2x","idiom":"universal","filename":"AppIcon-76.0x76.0@2x.png","size":"76x76","platform":"ios"},{"scale":"3x","idiom":"universal","filename":"AppIcon-38.0x38.0@3x.png","size":"38x38","platform":"ios"},{"scale":"2x","idiom":"universal","filename":"AppIcon-68.0x68.0@2x.png","size":"68x68","platform":"ios"},{"scale":"1x","idiom":"universal","filename":"AppIcon-1024.0x1024.0@1x.png","size":"1024x1024","platform":"ios"},{"scale":"2x","idiom":"universal","filename":"AppIcon-64.0x64.0@2x.png","size":"64x64","platform":"ios"},{"scale":"2x","idiom":"universal","filename":"AppIcon-40.0x40.0@2x.png","size":"40x40","platform":"ios"},{"scale":"2x","idiom":"universal","filename":"AppIcon-83.5x83.5@2x.png","size":"83.5x83.5","platform":"ios"},{"scale":"3x","idiom":"universal","filename":"AppIcon-20.0x20.0@3x.png","size":"20x20","platform":"ios"},{"scale":"2x","idiom":"universal","filename":"AppIcon-38.0x38.0@2x.png","size":"38x38","platform":"ios"},{"scale":"3x","idiom":"universal","filename":"AppIcon-60.0x60.0@3x.png","size":"60x60","platform":"ios"}],"info":{"version":1,"author":"appicon"}}
{
"images" : [
{
"filename" : "Icon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "icon-1024 1.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

View File

@@ -1,76 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>zh</string>
</array>
<key>CFBundleName</key>
<string>ServerBox</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NSBonjourServices</key>
<array>
<string>_dartobservatory._tcp</string>
</array>
<key>NSFaceIDUsageDescription</key>
<string>Required for auth</string>
<key>NSLocalNetworkUsageDescription</key>
<string>ServerBox needs to access your local network to discover and connect to your server.</string>
<key>NSUserActivityTypes</key>
<array>
<string>ConfigurationIntent</string>
</array>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true />
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>zh</string>
</array>
<key>CFBundleName</key>
<string>ServerBox</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false />
<key>LSRequiresIPhoneOS</key>
<true />
<key>LSSupportsOpeningDocumentsInPlace</key>
<true />
<key>NSBonjourServices</key>
<array>
<string>_dartobservatory._tcp</string>
</array>
<key>NSUserActivityTypes</key>
<array>
<string>ConfigurationIntent</string>
</array>
<key>NSSupportsLiveActivities</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true />
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false />
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false />
<key>NSLocalNetworkUsageDescription</key>
<string>Access your local network to discover and connect to your server.</string>
<key>NSFaceIDUsageDescription</key>
<string>Required for auth</string>
<key>NSCameraUsageDescription</key>
<string>Scan QR codes and etc.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Get QR code and etc.</string>
</dict>
</plist>

View File

@@ -17,6 +17,8 @@
<string>en</string>
<string>zh</string>
</array>
<key>NSSupportsLiveActivities</key>
<true/>
<key>CFBundleName</key>
<string>ServerBox</string>
<key>CFBundlePackageType</key>
@@ -64,9 +66,14 @@
<array>
<string>_dartobservatory._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>ServerBox needs to access your local network to discover and connect to your server.</string>
<string>Access your local network to discover and connect to your server.</string>
<key>NSFaceIDUsageDescription</key>
<string>Required for auth</string>
<key>NSCameraUsageDescription</key>
<string>Scan QR codes and etc.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Get QR code and etc.</string>
</dict>
</plist>

View File

@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<true />
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
@@ -17,6 +17,8 @@
<string>en</string>
<string>zh</string>
</array>
<key>NSSupportsLiveActivities</key>
<true/>
<key>CFBundleName</key>
<string>ServerBox</string>
<key>CFBundlePackageType</key>
@@ -28,13 +30,13 @@
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<false />
<key>LSRequiresIPhoneOS</key>
<true/>
<true />
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<true />
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<true />
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
@@ -44,7 +46,7 @@
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<false />
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
@@ -59,8 +61,13 @@
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<false />
<key>NSFaceIDUsageDescription</key>
<string>Required for auth</string>
<key>NSCameraUsageDescription</key>
<string>Scan QR codes and etc.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Get QR code and etc.</string>
</dict>
</plist>

View File

@@ -0,0 +1,95 @@
//
// LiveActivityManager.swift
// Runner
//
// Handles starting/updating/stopping Terminal Live Activities from Flutter via MethodChannel.
//
import Foundation
import ActivityKit
@available(iOS 16.2, *)
class LiveActivityManager {
static var current: Activity<TerminalAttributes>?
struct Payload: Decodable {
let id: String
let title: String
let subtitle: String
let startTimeMs: Int
let status: String
let hasTerminal: Bool?
let connectionCount: Int?
}
private static func parse(_ json: String) -> Payload? {
guard let data = json.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(Payload.self, from: data)
}
static func start(json: String) {
guard #available(iOS 16.2, *) else { return }
guard let p = parse(json) else { return }
let attributes = TerminalAttributes(id: p.id)
let date = Date(timeIntervalSince1970: TimeInterval(p.startTimeMs) / 1000.0)
// Localize multi-connection title/subtitle on iOS side
let isMulti = (p.id == "multi_connections")
let title = isMulti
? String(format: NSLocalizedString("%d connections", comment: "Title for multiple connections"), p.connectionCount ?? 1)
: p.title
let subtitle = isMulti
? NSLocalizedString("Multiple SSH sessions active", comment: "Subtitle for multiple connections")
: p.subtitle
let state = TerminalAttributes.ContentState(
id: p.id,
title: title,
subtitle: subtitle,
status: p.status,
startTime: date,
hasTerminal: p.hasTerminal ?? true,
connectionCount: p.connectionCount ?? 1
)
let content = ActivityContent(state: state, staleDate: nil)
do {
current = try Activity<TerminalAttributes>.request(attributes: attributes, content: content, pushType: nil)
} catch {
// ignore
}
}
static func update(json: String) {
guard #available(iOS 16.2, *) else { return }
guard let p = parse(json) else { return }
let date = Date(timeIntervalSince1970: TimeInterval(p.startTimeMs) / 1000.0)
// Localize multi-connection title/subtitle on iOS side
let isMulti = (p.id == "multi_connections")
let title = isMulti
? String(format: NSLocalizedString("%d connections", comment: "Title for multiple connections"), p.connectionCount ?? 1)
: p.title
let subtitle = isMulti
? NSLocalizedString("Multiple SSH sessions active", comment: "Subtitle for multiple connections")
: p.subtitle
let state = TerminalAttributes.ContentState(
id: p.id,
title: title,
subtitle: subtitle,
status: p.status,
startTime: date,
hasTerminal: p.hasTerminal ?? true,
connectionCount: p.connectionCount ?? 1
)
if let activity = current {
Task { await activity.update(ActivityContent(state: state, staleDate: nil)) }
} else {
start(json: json)
}
}
static func stop() {
guard #available(iOS 16.2, *) else { return }
if let activity = current {
Task { await activity.end(dismissalPolicy: .immediate) }
current = nil
}
}
}

View File

@@ -0,0 +1,39 @@
//
// TerminalLiveActivityAttributes.swift
// Runner
//
// Mirror of the ActivityKit attributes used in the extension.
//
import Foundation
import ActivityKit
@available(iOS 16.1, *)
public struct TerminalAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
public var id: String
public var title: String
public var subtitle: String
public var status: String
public var startTime: Date
public var hasTerminal: Bool
public var connectionCount: Int
public init(id: String, title: String, subtitle: String, status: String, startTime: Date, hasTerminal: Bool, connectionCount: Int = 1) {
self.id = id
self.title = title
self.subtitle = subtitle
self.status = status
self.startTime = startTime
self.hasTerminal = hasTerminal
self.connectionCount = connectionCount
}
}
public var id: String
public init(id: String) {
self.id = id
}
}

View File

@@ -0,0 +1,8 @@
"Terminal" = "Terminal";
"Connected" = "Verbunden";
"Connecting" = "Verbindung wird hergestellt";
"Disconnected" = "Getrennt";
"Multiple SSH sessions active" = "Mehrere aktive SSH-Sitzungen";
"1 connection" = "1 Verbindung";
"%d connections" = "%d Verbindungen";

View File

@@ -0,0 +1,8 @@
"Terminal" = "Terminal";
"Connected" = "Connected";
"Connecting" = "Connecting";
"Disconnected" = "Disconnected";
"Multiple SSH sessions active" = "Multiple SSH sessions active";
"1 connection" = "1 connection";
"%d connections" = "%d connections";

View File

@@ -0,0 +1,8 @@
"Terminal" = "Terminal";
"Connected" = "Conectado";
"Connecting" = "Conectando";
"Disconnected" = "Desconectado";
"Multiple SSH sessions active" = "Varias sesiones SSH activas";
"1 connection" = "1 conexión";
"%d connections" = "%d conexiones";

View File

@@ -0,0 +1,8 @@
"Terminal" = "Terminal";
"Connected" = "Connecté";
"Connecting" = "Connexion en cours";
"Disconnected" = "Déconnecté";
"Multiple SSH sessions active" = "Plusieurs sessions SSH actives";
"1 connection" = "1 connexion";
"%d connections" = "%d connexions";

View File

@@ -0,0 +1,8 @@
"Terminal" = "Terminal";
"Connected" = "Terhubung";
"Connecting" = "Menghubungkan";
"Disconnected" = "Terputus";
"Multiple SSH sessions active" = "Beberapa sesi SSH aktif";
"1 connection" = "1 koneksi";
"%d connections" = "%d koneksi";

View File

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

View File

@@ -0,0 +1,8 @@
"Terminal" = "Terminal";
"Connected" = "Conectado";
"Connecting" = "Conectando";
"Disconnected" = "Desconectado";
"Multiple SSH sessions active" = "Várias sessões SSH ativas";
"1 connection" = "1 conexão";
"%d connections" = "%d conexões";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,185 @@
//
// TerminalLiveActivity.swift
// StatusWidget
//
// Renders the Live Activity UI for SSH/Terminal sessions.
//
import SwiftUI
import WidgetKit
import ActivityKit
// Helper to map status strings to a color dot (case-insensitive).
@inline(__always)
private func getStatusDotColor(_ status: String) -> Color {
switch status.lowercased() {
case "connected":
return .green
case "connecting":
return .yellow
case "disconnected":
return .red
default:
return .secondary
}
}
// Normalize status for display: capitalize first letter only.
@inline(__always)
private func formatStatus(_ status: String) -> String {
let trimmed = status.trimmingCharacters(in: .whitespacesAndNewlines)
guard let first = trimmed.first else { return status }
let head = String(first).uppercased()
let tail = String(trimmed.dropFirst()).lowercased()
return head + tail
}
// Localize known statuses; fall back to formatted original.
@inline(__always)
private func localizedStatus(_ status: String) -> String {
switch status.lowercased() {
case "connected":
return NSLocalizedString("Connected", comment: "Session connected status")
case "connecting":
return NSLocalizedString("Connecting", comment: "Session connecting status")
case "disconnected":
return NSLocalizedString("Disconnected", comment: "Session disconnected status")
default:
return formatStatus(status)
}
}
@available(iOS 16.1, *)
struct TerminalLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: TerminalAttributes.self) { context in
let state = context.state
HStack(alignment: .center, spacing: 12) {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Text(state.hasTerminal ? NSLocalizedString("Terminal", comment: "Terminal label") : "SSH")
.font(.caption)
.foregroundStyle(.secondary)
if state.connectionCount > 1 {
Text("(\(state.connectionCount))")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Text(state.title)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
Text(state.subtitle)
.font(.subheadline)
.lineLimit(1)
.foregroundStyle(.secondary)
HStack(spacing: 8) {
Circle()
.fill(getStatusDotColor(state.status))
.frame(width: 6, height: 6)
Text(localizedStatus(state.status))
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer(minLength: 8)
Image(systemName: state.hasTerminal ? "terminal" : "bolt.horizontal.circle")
.font(.title3)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
.padding(.vertical, 10)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Text(context.state.hasTerminal ? NSLocalizedString("Terminal", comment: "Terminal label") : "SSH")
.font(.caption2)
.foregroundStyle(.secondary)
if context.state.connectionCount > 1 {
Text("(\(context.state.connectionCount))")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Text(context.state.title)
.font(.subheadline)
.lineLimit(1)
.truncationMode(.tail)
}
.padding(.vertical, 8)
.padding(.horizontal, 8)
}
DynamicIslandExpandedRegion(.trailing) {
VStack(alignment: .trailing, spacing: 6) {
HStack(spacing: 6) {
Circle()
.fill(getStatusDotColor(context.state.status))
.frame(width: 6, height: 6)
Text(localizedStatus(context.state.status))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 8)
.padding(.horizontal, 8)
}
DynamicIslandExpandedRegion(.bottom) {
Text(context.state.subtitle)
.font(.caption)
.lineLimit(1)
.foregroundStyle(.secondary)
}
} compactLeading: {
Image(systemName: context.state.hasTerminal ? "terminal" : "bolt.horizontal.circle")
} compactTrailing: {
EmptyView()
} minimal: {
Image(systemName: context.state.hasTerminal ? "terminal" : "bolt.horizontal.circle")
}
}
}
}
#if DEBUG
@available(iOS 16.2, *)
struct TerminalLiveActivity_Previews: PreviewProvider {
static let attributes = TerminalAttributes(id: "preview")
static let contentState = TerminalAttributes.ContentState(
id: "preview",
title: "root@server-01",
subtitle: "CPU 37% • Mem 1.3G/2.0G",
status: "Connected",
startTime: Date().addingTimeInterval(-1234),
hasTerminal: true,
connectionCount: 2
)
static var previews: some View {
Group {
// /
attributes
.previewContext(contentState, viewKind: .content)
.previewDisplayName("Lock Screen")
// 屿
attributes
.previewContext(contentState, viewKind: .dynamicIsland(.expanded))
.previewDisplayName("Dynamic Island • Expanded")
// 屿
attributes
.previewContext(contentState, viewKind: .dynamicIsland(.compact))
.previewDisplayName("Dynamic Island • Compact")
// 屿
attributes
.previewContext(contentState, viewKind: .dynamicIsland(.minimal))
.previewDisplayName("Dynamic Island • Minimal")
}
}
}
#endif

View File

@@ -0,0 +1,39 @@
//
// TerminalLiveActivityAttributes.swift
// StatusWidget
//
// Defines ActivityKit attributes and content state for SSH/Terminal Live Activities.
//
import Foundation
import ActivityKit
@available(iOS 16.1, *)
public struct TerminalAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
public var id: String
public var title: String
public var subtitle: String
public var status: String
public var startTime: Date
public var hasTerminal: Bool
public var connectionCount: Int
public init(id: String, title: String, subtitle: String, status: String, startTime: Date, hasTerminal: Bool, connectionCount: Int = 1) {
self.id = id
self.title = title
self.subtitle = subtitle
self.status = status
self.startTime = startTime
self.hasTerminal = hasTerminal
self.connectionCount = connectionCount
}
}
public var id: String
public init(id: String) {
self.id = id
}
}

View File

@@ -0,0 +1,8 @@
"Terminal" = "Terminal";
"Connected" = "Verbunden";
"Connecting" = "Verbindung wird hergestellt";
"Disconnected" = "Getrennt";
"Multiple SSH sessions active" = "Mehrere aktive SSH-Sitzungen";
"1 connection" = "1 Verbindung";
"%d connections" = "%d Verbindungen";

View File

@@ -0,0 +1,8 @@
"Terminal" = "Terminal";
"Connected" = "Connected";
"Connecting" = "Connecting";
"Disconnected" = "Disconnected";
"Multiple SSH sessions active" = "Multiple SSH sessions active";
"1 connection" = "1 connection";
"%d connections" = "%d connections";

View File

@@ -0,0 +1,8 @@
"Terminal" = "Terminal";
"Connected" = "Conectado";
"Connecting" = "Conectando";
"Disconnected" = "Desconectado";
"Multiple SSH sessions active" = "Varias sesiones SSH activas";
"1 connection" = "1 conexión";
"%d connections" = "%d conexiones";

View File

@@ -0,0 +1,8 @@
"Terminal" = "Terminal";
"Connected" = "Connecté";
"Connecting" = "Connexion en cours";
"Disconnected" = "Déconnecté";
"Multiple SSH sessions active" = "Plusieurs sessions SSH actives";
"1 connection" = "1 connexion";
"%d connections" = "%d connexions";

View File

@@ -0,0 +1,8 @@
"Terminal" = "Terminal";
"Connected" = "Terhubung";
"Connecting" = "Menghubungkan";
"Disconnected" = "Terputus";
"Multiple SSH sessions active" = "Beberapa sesi SSH aktif";
"1 connection" = "1 koneksi";
"%d connections" = "%d koneksi";

View File

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

View File

@@ -0,0 +1,8 @@
"Terminal" = "Terminal";
"Connected" = "Conectado";
"Connecting" = "Conectando";
"Disconnected" = "Desconectado";
"Multiple SSH sessions active" = "Várias sessões SSH ativas";
"1 connection" = "1 conexão";
"%d connections" = "%d conexões";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,141 @@
//
// WatchStatusWidget.swift
// WatchStatusWidget Extension
//
// Created by AI Assistant
//
import WidgetKit
import SwiftUI
import Foundation
// Simple model, independent from Runner target
struct Status: Hashable {
let name: String
let cpu: String
let mem: String
let disk: String
let net: String
}
struct WatchProvider: TimelineProvider {
func placeholder(in context: Context) -> WatchEntry {
WatchEntry(date: Date(), status: Status(name: "Server", cpu: "32%", mem: "1.3g/1.9g", disk: "7.1g/30g", net: "712k/1.2m"))
}
func getSnapshot(in context: Context, completion: @escaping (WatchEntry) -> Void) {
completion(loadEntry())
}
func getTimeline(in context: Context, completion: @escaping (Timeline<WatchEntry>) -> Void) {
let entry = loadEntry()
let next = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) ?? Date().addingTimeInterval(900)
completion(Timeline(entries: [entry], policy: .after(next)))
}
private func loadEntry() -> WatchEntry {
let appGroupId = "group.com.lollipopkit.toolbox"
guard let defaults = UserDefaults(suiteName: appGroupId) else {
return WatchEntry(date: Date(), status: Status(name: "Server", cpu: "--%", mem: "-", disk: "-", net: "-"))
}
let urls = (defaults.array(forKey: "watch_shared_urls") as? [String]) ?? []
let idx = defaults.integer(forKey: "watch_shared_selected_index")
var status: Status? = nil
if !urls.isEmpty {
let i = min(max(0, idx), urls.count - 1)
let url = urls[i]
// Load status from shared defaults
if let statusMap = defaults.dictionary(forKey: "watch_shared_status_by_url") as? [String: [String: String]],
let statusDict = statusMap[url] {
status = Status(
name: statusDict["name"] ?? "",
cpu: statusDict["cpu"] ?? "",
mem: statusDict["mem"] ?? "",
disk: statusDict["disk"] ?? "",
net: statusDict["net"] ?? ""
)
}
}
return WatchEntry(
date: Date(),
status: status ?? Status(name: "Server", cpu: "--%", mem: "-", disk: "-", net: "-")
)
}
}
struct WatchEntry: TimelineEntry {
let date: Date
let status: Status
}
struct WatchStatusWidgetEntryView: View {
var entry: WatchProvider.Entry
@Environment(\.widgetFamily) var family
var body: some View {
switch family {
case .accessoryCircular:
ZStack {
Circle().stroke(Color.primary.opacity(0.15), lineWidth: 4)
CirclePercent(percent: entry.status.cpu)
Text(entry.status.cpu.replacingOccurrences(of: "%", with: "")).font(.system(size: 10, weight: .bold, design: .monospaced))
}
.padding(2)
case .accessoryRectangular:
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(entry.status.name).font(.system(size: 12, weight: .semibold, design: .monospaced))
Spacer()
}
HStack(spacing: 6) {
Label(entry.status.cpu, systemImage: "cpu").font(.system(size: 11, design: .monospaced))
}
}
case .accessoryInline:
Text("\(entry.status.name) \(entry.status.cpu)")
default:
VStack {
Text(entry.status.name)
Text(entry.status.cpu)
}
}
}
}
struct WatchStatusWidget: Widget {
let kind: String = "WatchStatusWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: WatchProvider()) { entry in
WatchStatusWidgetEntryView(entry: entry)
}
.configurationDisplayName("Server Status")
.description("Shows the selected server status.")
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
}
}
struct WatchStatusWidget_Previews: PreviewProvider {
static var previews: some View {
WatchStatusWidgetEntryView(entry: WatchEntry(date: Date(), status: Status(name: "Server", cpu: "37%", mem: "1.3g/1.9g", disk: "7.1g/30g", net: "712k/1.2m")))
.previewContext(WidgetPreviewContext(family: .accessoryRectangular))
}
}
// Helpers reused from iOS widget with lightweight versions
struct CirclePercent: View {
let percent: String
var body: some View {
let percentD = Double(percent.trimmingCharacters(in: .init(charactersIn: "% "))) ?? 0
let p = max(0, min(100, percentD)) / 100.0
Circle()
.trim(from: 0, to: CGFloat(p))
.stroke(Color.primary, style: StrokeStyle(lineWidth: 4, lineCap: .round))
.rotationEffect(.degrees(-90))
}
}

View File

@@ -0,0 +1,17 @@
//
// WatchStatusWidgetBundle.swift
// WatchStatusWidget Extension
//
// Created by AI Assistant
//
import WidgetKit
import SwiftUI
@main
struct WatchStatusWidgetBundle: WidgetBundle {
var body: some Widget {
WatchStatusWidget()
}
}

View File

@@ -1,4 +1,5 @@
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,69 +1,101 @@
import 'package:dynamic_color/dynamic_color.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:fl_lib/l10n/gen_l10n/lib_l10n.dart';
import 'package:fl_lib/generated/l10n/lib_l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:icons_plus/icons_plus.dart';
import 'package:server_box/core/app_navigator.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/data/res/build_data.dart';
import 'package:server_box/data/res/rebuild.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/view/page/home/home.dart';
import 'package:icons_plus/icons_plus.dart';
import 'package:server_box/generated/l10n/l10n.dart';
import 'package:server_box/view/page/home.dart';
part 'intro.dart';
class MyApp extends StatelessWidget {
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late final Future<List<IntroPageBuilder>> _introFuture = _IntroPage.builders;
@override
Widget build(BuildContext context) {
_setup(context);
return ListenableBuilder(
listenable: RNodes.app,
builder: (context, _) {
if (!Stores.setting.useSystemPrimaryColor.fetch()) {
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 _build(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);
},
);
return _buildDynamicColor(context);
},
);
}
Widget _buildApp(BuildContext ctx,
{required ThemeData light, required ThemeData dark}) {
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 lightSeed = light?.primary;
final darkSeed = dark?.primary;
final lightTheme = ThemeData(
useMaterial3: true,
colorSchemeSeed: lightSeed,
appBarTheme: AppBarTheme(scrolledUnderElevation: 0.0),
);
final darkTheme = ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorSchemeSeed: darkSeed,
appBarTheme: AppBarTheme(scrolledUnderElevation: 0.0),
);
if (context.isDark && dark != null) {
UIs.primaryColor = dark.primary;
UIs.colorSeed = dark.primary;
} else if (!context.isDark && light != null) {
UIs.primaryColor = light.primary;
UIs.colorSeed = light.primary;
} else {
final fallbackColor = Color(Stores.setting.colorSeed.fetch());
UIs.primaryColor = fallbackColor;
UIs.colorSeed = fallbackColor;
}
return _buildApp(context, light: lightTheme, dark: darkTheme);
},
);
}
Widget _buildApp(BuildContext ctx, {required ThemeData light, required ThemeData dark}) {
final tMode = Stores.setting.themeMode.fetch();
// Issue #57
final themeMode = switch (tMode) {
@@ -74,11 +106,11 @@ class MyApp extends StatelessWidget {
final locale = Stores.setting.locale.fetch().toLocale;
return MaterialApp(
key: ValueKey(locale),
navigatorKey: AppNavigator.key,
builder: ResponsivePoints.builder,
locale: locale,
localizationsDelegates: const [
LibLocalizations.delegate,
...AppLocalizations.localizationsDelegates,
],
localizationsDelegates: const [LibLocalizations.delegate, ...AppLocalizations.localizationsDelegates],
supportedLocales: AppLocalizations.supportedLocales,
localeListResolutionCallback: LocaleUtil.resolve,
navigatorObservers: [AppRouteObserver.instance],
@@ -86,18 +118,26 @@ class MyApp extends StatelessWidget {
themeMode: themeMode,
theme: light.fixWindowsFont,
darkTheme: (tMode < 3 ? dark : dark.toAmoled).fixWindowsFont,
home: Builder(
builder: (context) {
home: FutureBuilder<List<IntroPageBuilder>>(
future: _introFuture,
builder: (context, snapshot) {
context.setLibL10n();
final appL10n = AppLocalizations.of(context);
if (appL10n != null) l10n = appL10n;
final intros = _IntroPage.builders;
if (intros.isNotEmpty) {
return _IntroPage(intros);
Widget child;
if (snapshot.connectionState == ConnectionState.waiting) {
child = const Scaffold(body: Center(child: CircularProgressIndicator()));
} else {
final intros = snapshot.data ?? [];
if (intros.isNotEmpty) {
child = _IntroPage(intros);
} else {
child = const HomePage();
}
}
return const HomePage();
return VirtualWindowFrame(title: BuildData.name, child: child);
},
),
);

View File

@@ -0,0 +1,8 @@
import 'package:flutter/widgets.dart';
/// Global navigator access used for cross-cutting flows (e.g. dialogs).
abstract final class AppNavigator {
static final key = GlobalKey<NavigatorState>();
static BuildContext? get context => key.currentContext;
}

108
lib/core/chan.dart Normal file
View File

@@ -0,0 +1,108 @@
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 (e, s) {
Loggers.app.warning('Failed to update Android sessions', e, s);
}
}
/// Query whether the Android foreground service is currently running.
static Future<bool> isServiceRunning() async {
if (!isAndroid) return false;
try {
final res = await _channel.invokeMethod('isServiceRunning');
return res == true;
} catch (e, s) {
Loggers.app.warning('Failed to check if Android service is running', e, s);
return false;
}
}
// iOS Live Activities controls
static Future<void> startLiveActivity(String payload) async {
if (!isIOS) return;
try {
Loggers.app.info('Starting iOS Live Activity: $payload');
await _channel.invokeMethod('startLiveActivity', payload);
} catch (e, s) {
Loggers.app.warning('Failed to start iOS Live Activity', e, s);
}
}
static Future<void> updateLiveActivity(String payload) async {
if (!isIOS) return;
try {
Loggers.app.info('Updating iOS Live Activity: $payload');
await _channel.invokeMethod('updateLiveActivity', payload);
} catch (e, s) {
Loggers.app.warning('Failed to update iOS Live Activity', e, s);
}
}
static Future<void> stopLiveActivity() async {
if (!isIOS) return;
try {
Loggers.app.info('Stopping iOS Live Activity');
await _channel.invokeMethod('stopLiveActivity');
} catch (e, s) {
Loggers.app.warning('Failed to stop iOS Live Activity', e, s);
}
}
/// Register a handler for native -> Flutter callbacks.
/// Currently handles:
/// - `disconnectSession` with argument map {id: string}
/// - `stopAllConnections` with no arguments
static void registerHandler(Future<void> Function(String id) onDisconnect, [VoidCallback? onStopAll]) {
_channel.setMethodCallHandler((call) async {
switch (call.method) {
case 'disconnectSession':
final args = call.arguments;
final id = args is Map ? args['id'] as String? : args as String?;
if (id != null && id.isNotEmpty) {
await onDisconnect(id);
}
return;
case 'stopAllConnections':
onStopAll?.call();
return;
default:
return;
}
});
}
}

View File

@@ -1,14 +0,0 @@
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');
}
}

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