26 Commits
Author SHA1 Message Date
eventlOwOpandGitHub 5d0a3c2ea9 Update README_ZH.md 2025-08-13 15:36:04 +08:00
eventlOwOpandGitHub dfcd8922e5 Update README.md 2025-08-13 15:34:25 +08:00
eventlOwOpandGitHub f55e880f53 [fix] del app/pubspec.lock, to disable flutter mirror. 2025-04-10 14:28:51 +08:00
eventlOwOp e969746889 Merge branch 'master' of https://github.com/eventlOwOp/zerotier-magisk 2025-04-06 20:30:00 +08:00
eventlOwOp eb00b1ae35 [fix] add jdk 21 2025-04-06 20:29:47 +08:00
eventlOwOpandGitHub 9db350f368 [fix] build-app workflow typo 2025-04-06 20:21:53 +08:00
eventlOwOp bacea863bf [feat] add i18n localization: en/zh; add peers display; ui refresh 2025-04-06 20:20:14 +08:00
eventlOwOp 0405277231 [docs] NDK supported 2025-04-05 18:06:51 +08:00
eventlOwOp 4191d8cbc1 [chore] NDK update r28 2025-04-05 18:00:29 +08:00
eventlOwOp 999ac75324 [fix] typo 2025-04-05 17:59:55 +08:00
eventlOwOp d783b6c413 [fix] NDK pthread_np support 2025-04-05 17:57:55 +08:00
eventlOwOpandGitHub 4ee668dc13 [chore] NDK version update to r27c 2025-04-05 17:42:37 +08:00
eventlOwOpandGitHub b3210882dd explanation for 1.14.2 2025-04-05 17:26:31 +08:00
eventlOwOp e2c0c3a515 update rootless app 2024-07-02 13:43:54 +08:00
eventlOwOp 19e9c26f59 fix redirection for ash 2024-07-02 13:28:51 +08:00
eventlOwOp 9b1059acbd rewrite restart logic 2024-07-02 13:12:35 +08:00
eventlOwOp fa2d60e1b8 update zerotier module to support rootless app 2024-07-02 12:25:20 +08:00
eventlOwOp 7394eee0f0 add flutter build 2024-06-06 13:07:58 +08:00
eventlOwOp 8b70f9c4a0 update gcc; change tag to commit hash 2024-06-06 12:59:38 +08:00
eventlOwOp 83e8ff3c0a update action workflow 2024-06-06 12:48:29 +08:00
eventlOwOpandGitHub bffce9e7fe Update customize.sh 2024-06-06 09:20:31 +08:00
eventlOwOpandGitHub 5fdaf2fc5f Update app ld_library_path 2024-06-06 00:13:24 +08:00
eventlOwOpandGitHub 23afcb31b8 Update ld_library_path 2024-06-06 00:11:35 +08:00
eventlOwOpandGitHub ca7697d5d9 Update service.sh 2024-05-23 15:55:48 +08:00
eventlOwOp 162014041d upload android app 2024-05-19 01:37:15 +08:00
eventlOwOp 53e2fca297 update readme 2024-05-19 01:33:05 +08:00
58 changed files with 4287 additions and 196 deletions
+37
View File
@@ -0,0 +1,37 @@
name: build app
on:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-22.04
steps:
- name: Checkout zerotier-magisk
uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: 'jetbrains'
java-version: '21'
- name: build target apk
run: |
cd app
flutter pub get
flutter build apk
- id: commit
uses: prompt/actions-commit-hash@v3
- name: Upload App release
uses: actions/upload-artifact@v4
with:
name: app-${{ steps.commit.outputs.short }}
path: app/build/app/outputs/flutter-apk/app-release.apk
+20 -26
View File
@@ -16,6 +16,9 @@ jobs:
repository: zerotier/ZeroTierOne
path: ZeroTierOne
- id: commit
uses: prompt/actions-commit-hash@v3
- name: Prepare GCC Toolchain
run: |
sudo apt-get install -qqy gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf g++-aarch64-linux-gnu g++-arm-linux-gnueabihf
@@ -29,7 +32,7 @@ jobs:
- name: Patch ZeroTier for Cross-Compile
run: |
pip3 install toml
python3 build/build_helper.py
python3 build/patch.py
- name: Build for AArch64
run: |
@@ -43,8 +46,12 @@ jobs:
cd ..
cp ZeroTierOne/zerotier-one magisk/zerotier
cd magisk
zip -q -r ../zerotier-magisk-aarch64-gcc.zip .
- name: Upload AArch64
uses: actions/upload-artifact@v4
with:
name: zerotier-magisk-aarch64-gcc-${{ steps.commit.outputs.short }}
path: magisk/
- name: Build for AArch64 with SSO
run: |
@@ -57,8 +64,12 @@ jobs:
cd ..
cp ZeroTierOne/zerotier-one magisk/zerotier
cd magisk
zip -q -r ../zerotier-magisk-aarch64-gcc-SSO.zip .
- name: Upload AArch64 SSO
uses: actions/upload-artifact@v4
with:
name: zerotier-magisk-aarch64-gcc-SSO-${{ steps.commit.outputs.short }}
path: magisk/
- name: Build for Arm
run: |
@@ -73,26 +84,9 @@ jobs:
cd ..
cp ZeroTierOne/zerotier-one magisk/zerotier/
cd magisk
zip -q -r ../zerotier-magisk-arm-gcc.zip .
- name: Get Date
id: date
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Release
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload Arm
uses: actions/upload-artifact@v4
with:
files: |
zerotier-magisk-aarch64-gcc-SSO.zip
zerotier-magisk-aarch64-gcc.zip
zerotier-magisk-arm-gcc.zip
tag_name: SNAPSHOT-GCC-${{ steps.date.outputs.date }}
name: SNAPSHOT-GCC-${{ steps.date.outputs.date }}
draft: false
prerelease: true
# - name: Debugging with tmate
# uses: mxschmitt/action-tmate@v3.18
name: zerotier-magisk-arm-gcc-${{ steps.commit.outputs.short }}
path: magisk/
+15 -24
View File
@@ -5,7 +5,7 @@ on:
env:
api_version: 28 # min. 21
ndk: r26d # android-ndk-$ndk-linux.zip
ndk: r28 # android-ndk-$ndk-linux.zip
jobs:
build:
@@ -26,6 +26,9 @@ jobs:
repository: miniupnp/libnatpmp
path: libnatpmp
- id: commit
uses: prompt/actions-commit-hash@v3
- name: Prepare NDK Toolchain
run: |
wget -qO ndk.zip https://dl.google.com/android/repository/android-ndk-$ndk-linux.zip
@@ -34,7 +37,7 @@ jobs:
- name: Patch ZeroTier for Cross-Compile
run: |
pip3 install toml
python3 build/build_helper.py
python3 build/patch.py
- name: Build for AArch64
run: |
@@ -57,8 +60,12 @@ jobs:
mkdir -p magisk/zerotier/lib
cp ZeroTierOne/zerotier-one magisk/zerotier
cp $NDK_ROOT/sysroot/usr/lib/aarch64-linux-android/libc++_shared.so magisk/zerotier/lib
cd magisk
zip -q -r ../zerotier-magisk-aarch64-ndk.zip .
- name: Upload AArch64
uses: actions/upload-artifact@v4
with:
name: zerotier-magisk-aarch64-ndk-${{ steps.commit.outputs.short }}
path: magisk/
- name: Build for Arm
run: |
@@ -81,25 +88,9 @@ jobs:
cp ZeroTierOne/zerotier-one magisk/zerotier/
cp $NDK_ROOT/sysroot/usr/lib/arm-linux-androideabi/libc++_shared.so magisk/zerotier/lib
cd magisk
zip -q -r ../zerotier-magisk-arm-ndk.zip .
- name: Get Date
id: date
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Release
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload Arm
uses: actions/upload-artifact@v4
with:
files: |
zerotier-magisk-aarch64-ndk.zip
zerotier-magisk-arm-ndk.zip
tag_name: SNAPSHOT-NDK-${{ steps.date.outputs.date }}
name: SNAPSHOT-NDK-${{ steps.date.outputs.date }}
draft: false
prerelease: true
# - name: Debugging with tmate
# uses: mxschmitt/action-tmate@v3.18
name: zerotier-magisk-arm-ndk-${{ steps.commit.outputs.short }}
path: magisk/
+75 -26
View File
@@ -10,7 +10,11 @@
**Run zerotier in the background after booting!**
**No conflicts with other android VPN service!**
**No conflicts with other Android VPN service!**
**Use Android App to control ZeroTier**
**Support for Private Root Servers**
## Requirements
@@ -24,39 +28,84 @@ AArch64 suppots ARMv8-A and above; Arm version supports ARMv7-A (compiling with
## Installation
1. download release from github.
2. install with magisk.
3. modify the config file at `/sdcard/Android/zerotier/network_id.txt` and put your 16-character network id in it.
4. restart and enjoy.
1. Download magisk module zip file from github release; install.
2. Download controller app apk file from github release; install.
3. Reboot your phone.
4. Open controller app; enter your 16-character network id; join.
5. Enjoy 😋
To restart, execute (in root) `sh /data/adb/zerotier/zerotier.sh restart`; An android application to control zerotier (networkid, restart, ...) has been added to the todo list.
## Usage
### Files
### Use Private Root Servers
Replace `/data/adb/zerotier/home/planet` with your own `planet` file.
### Controller App
Does not need root privilege
| Feature | Supported? |
| :----------------- | :--------- |
| status | ✅ |
| start/stop | ✅ |
| join/leave network | ✅ |
| join/leave planet | ❎ |
<div>
<img alt="ZeroTier for Magisk Icon" src="https://github.com/eventlOwOp/zerotier-magisk/blob/master/images/app_home.jpg" width="192" />
<img alt="ZeroTier for Magisk Icon" src="https://github.com/eventlOwOp/zerotier-magisk/blob/master/images/app_network.jpg" width="192" />
</div>
### Command line tools
Use `zerotier.sh` to start/stop or inspect status.
`Usage: zerotier.sh {start|stop|restart|status}`
Use `zerotier-cli / zerotier-idtool` for ZeroTierOne command line operations.
(`zerotier-one` not copied to `/system/bin`)
## Files
```
/data/adb/zerotier/
| - run/
| | - pipe # pipe to service.sh
| | - daemon.log # service.sh log
| ` - zerotier.log # zerotier-one log
| - home/ # zerotier-one home directory
| ` - ...
| - lib/
| ` - libc++_shared.so # NDK dynamic library
| - zerotier.sh # tool to communicate with service.sh
| - zerotier-one # zerotier-one executable
| - zerotier-cli -> zerotier-one
` - zerotier-idtool -> zerotier-one
/data/adb/zerotier
├── home # zerotier-one home directory
│   ├── authtoken.secret # zerotier-one http interface authtoken
│   ├── zerotier-one.pid # zerotier-one pid
│   ├── zerotier-one.port # zerotier-one port
│   └── ...
├── lib # only in NDK compiled module
│   └── libc++_shared.so # NDK dynamic library
├── run
│   ├── daemon.log # service.sh log
│   ├── pipe # named pipe to interact with service.sh
│   ├── zerotier.log # zerotier-one log
│   └── ...
├── zerotier-cli -> zerotier-one # zerotier-one command line interface
├── zerotier-idtool -> zerotier-one # zerotier-one id tool
├── zerotier-one # zerotier-one executable
└── zerotier.sh
```
all the scripts and binaries are placed in `/data/adb/zerotier/`.
all the scripts and binaries are placed in `/data/adb/zerotier/`, and all copied to `/system/bin` (in PATH) to be executed directly (except `zerotier-one`)
`zerotier.sh` writes commands to the pipe at `run/pipe` and `service.sh` read from it.
`Usage: sh zerotier.sh {start|stop|restart|join|leave}`
`zerotier.sh` uses named pipe to communicate with `service.sh`, preventing ZeroTier to start as a subprocess of Shell.
log files are placed in `run`, `daemon.log` for `service.sh` and `zerotier.log` for ZeroTierOne.
### Build binaries yourself
## Build binaries yourself
The binaries are built with Android NDK toolchains on Github Action, see `.github/workflow/build.yml` for detailed information.
refer to `.github/workflow/build-{gcc|ndk}.yml` for detailed information.
## Notes
After 1.14.0, ZeroTierOne has introduce `multi-core concurrent packet processing`, which requires `pthread_setaffinity_np`.
However, for NDK, `pthread_setaffinity_np` won't be available until API level 36, Android 16. (refer to https://android.googlesource.com/platform/bionic/+/master/libc/include/pthread.h)
So in the NDK bulid version, it is replaced by the combination of `pthread_gettid_np` from `<pthread.h>` and `sched_getaffinity` from `<sched.h>`.
## Using your phone as router, want LAN to LAN mapping?
See [ThermalEng/zerotier-magisk](https://github.com/ThermalEng/zerotier-magisk/). Inform me by creating issue. Future function and UI integration if many module users want it.
+78 -26
View File
@@ -8,7 +8,13 @@
</div>
将 ZeroTier 保持后台运行,不与其他 Android VPN 应用程序冲突
**将 ZeroTier 保持后台运行**
**不与其他 Android VPN 应用程序冲突**
**使用 Android App 进行控制**
**支持自建 Planet**
## 运行要求
@@ -22,39 +28,85 @@ AArch64 版本支持 ARMv8-A 及以上;Arm 版本均对 ARMv7-A 进行编译 (
## 安装
1. 从 Release 下载压缩包
2. 导入 magisk 进行安装
3. 将你的 16 位 ZeroTier network id 写入 `/sdcard/Android/zerotier/network_id.txt`
4. 重启
1. 从 Release 下载 magisk 模块压缩包,并安装
2. 从 Release 处下载控制器的 apk 安装包,并安装
3. 重启
4. 打开控制器 App,输入 16 位 network id 并加入
5. 享用 😋
你可以用 `su` 执行 `sh /data/adb/zerotier/zerotier.sh restart` 来重启,或者干脆重启手机,计划写一个 app 来完成
## 使用
### 文件
### 自建 Planet
`/data/adb/zerotier/home/planet` 替换为自己的 planet 文件即可
### 控制器 App
无需 root 授权
| 功能 | 支持状态 |
| :--------------- | :------- |
| 查看运行状态 | ✅ |
| 启动停止重启 | ✅ |
| 加入离开 network | ✅ |
| 加入离开 planet | ❎ |
<div>
<img alt="ZeroTier for Magisk Icon" src="https://github.com/eventlOwOp/zerotier-magisk/blob/master/images/app_home.jpg" width="192" />
<img alt="ZeroTier for Magisk Icon" src="https://github.com/eventlOwOp/zerotier-magisk/blob/master/images/app_network.jpg" width="192" />
</div>
### 命令行
查看运行状态,或者启动重启停止,使用 `zerotier.sh`
`Usage: zerotier.sh {start|stop|restart|status}`
ZeroTierOne 支持的所有命令行操作:使用 `zerotier-cli / zerotier-idtool`
`zerotier-one` 并未导出到 `/system/bin`
## 目录结构
```
/data/adb/zerotier/
| - run/
| | - pipe # pipe to service.sh
| | - daemon.log # service.sh log
| ` - zerotier.log # zerotier-one log
| - home/ # zerotier-one home directory
| ` - ...
| - lib/
| ` - libc++_shared.so # NDK dynamic library
| - zerotier.sh # tool to communicate with service.sh
| - zerotier-one # zerotier-one executable
| - zerotier-cli -> zerotier-one
` - zerotier-idtool -> zerotier-one
/data/adb/zerotier
├── home # zerotier-one home directory
│   ├── authtoken.secret # zerotier-one http interface authtoken
│   ├── zerotier-one.pid # zerotier-one pid
│   ├── zerotier-one.port # zerotier-one port
│   └── ...
├── lib # only in NDK compiled module
│   └── libc++_shared.so # NDK dynamic library
├── run
│   ├── daemon.log # service.sh log
│   ├── pipe # named pipe to interact with service.sh
│   ├── zerotier.log # zerotier-one log
│   └── ...
├── zerotier-cli -> zerotier-one # zerotier-one command line interface
├── zerotier-idtool -> zerotier-one # zerotier-one id tool
├── zerotier-one # zerotier-one executable
└── zerotier.sh
```
ZeroTier 可执行文件和操作的 Shell 脚本放在 `/data/adb/zerotier/`
ZeroTier 可执行文件和操作的 Shell 脚本放在 `/data/adb/zerotier/` 下,同时复制到 `/system/bin`PATH 中)以便于直接执行(除了 `zerotier-one`
`zerotier.sh` 向管道 `run/pipe` 写入, `service.sh` 读取管道,然后进行操作
`zerotier.sh` 通过命名管道与 `service.sh` 交互,防止 ZeroTier 作为 Shell 的子进程运行
`Usage: sh zerotier.sh {start|stop|restart|join|leave}`
日志存放在 `/data/adb/zerotier/run` 下,`service.sh``daemon.log`ZeroTier 为 `zerotier.log`.
log files are placed in `run`, `daemon.log` for `service.sh` and `zerotier.log` for ZeroTierOne.
## 自行编译
### 自行编译
参考 `.github/workflow/build-{gcc|ndk}.yml`
使用 NDK,参考 `.github/workflow/build.yml`
## 注意
1.14.0 后 ZeroTierOne 引入了 `multi-core concurrent packet processing` ,其中使用了 `pthread_setaffinity_np` 实现线程亲和性设置
`pthread_setaffinity_np` 在 NDK 的 API level 36, Android 16 才受支持。 (参考 https://android.googlesource.com/platform/bionic/+/master/libc/include/pthread.h)
所以 NDK 中他被替换成了 `<pthread.h>``pthread_gettid_np``<sched.h>``sched_getaffinity` 的组合,来实现相同的功能
## 手机做路由器,想要 LAN to LAN
参考 [ThermalEng/zerotier-magisk](https://github.com/ThermalEng/zerotier-magisk/)
开 issue 统计一下需求,人多就合并再做 UI
+54
View File
@@ -0,0 +1,54 @@
**/.cxx/
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# Android related
**/android/**/gradle-wrapper.jar
**/android/.gradle
**/android/captures/
**/android/gradlew
**/android/gradlew.bat
**/android/local.properties
**/android/**/GeneratedPluginRegistrant.java
+33
View File
@@ -0,0 +1,33 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "54e66469a933b60ddf175f858f82eaeb97e48c8d"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d
base_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d
- platform: android
create_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d
base_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d
- platform: web
create_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d
base_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+28
View File
@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+13
View File
@@ -0,0 +1,13 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
# Remember to never publicly share your keystore.
# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
key.properties
**/*.keystore
**/*.jks
+67
View File
@@ -0,0 +1,67 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader('UTF-8') { reader ->
localProperties.load(reader)
}
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
}
def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
android {
namespace "com.eventlowop.zerotier_magisk_app"
compileSdk flutter.compileSdkVersion
ndkVersion flutter.ndkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "com.eventlowop.zerotier_magisk_app"
// 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.
minSdkVersion flutter.minSdkVersion
targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig signingConfigs.debug
}
}
}
flutter {
source '../..'
}
dependencies {}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,46 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="@string/appName"
android:name="${applicationName}"
android:icon="@mipmap/launcher_icon">
<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:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
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"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility?hl=en and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
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"/>
</intent>
</queries>
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
@@ -0,0 +1,5 @@
package com.eventlowop.zerotier_magisk_app
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="appName">ZeroTier for Magisk</string>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+19
View File
@@ -0,0 +1,19 @@
allprojects {
repositories {
google()
maven { url 'https://mirrors.tuna.tsinghua.edu.cn/flutter/download.flutter.io' }
mavenCentral()
}
}
rootProject.buildDir = '../build'
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(':app')
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}
File diff suppressed because one or more lines are too long
+6
View File
@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx4G
android.useAndroidX=true
android.enableJetifier=true
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=false
android.nonFinalResIds=false
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}
settings.ext.flutterSdkPath = flutterSdkPath()
includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version '8.10.0-beta01' apply false
id "org.jetbrains.kotlin.android" version "2.1.20" apply false
}
include ":app"
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+7
View File
@@ -0,0 +1,7 @@
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-dir: lib/l10n
output-localization-file: app_localizations.dart
synthetic-package: false
nullable-getter: false
+357
View File
@@ -0,0 +1,357 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:quickalert/quickalert.dart';
import '../services/zerotier_service.dart';
import '../l10n/app_localizations.dart';
class NetworkPage extends StatefulWidget {
final ZerotierService zerotierService;
const NetworkPage({
super.key,
required this.zerotierService,
});
@override
State<NetworkPage> createState() => _NetworkPageState();
}
class _NetworkPageState extends State<NetworkPage> {
final _idInputController = TextEditingController();
bool _isLoading = false;
List<String> _networks = [];
String? _error;
@override
void initState() {
super.initState();
_idInputController.addListener(_onTextChanged);
_fetchNetworkList();
}
void _onTextChanged() {
if (!mounted) return;
setState(() {});
}
void _setLoading(bool loading) {
if (!mounted) return;
setState(() {
_isLoading = loading;
if (loading && _error != null) {
_error = null;
}
});
}
Future<void> _fetchNetworkList() async {
_setLoading(true);
try {
final networks = await widget.zerotierService.loadNetwork();
if (mounted) {
final l10n = AppLocalizations.of(context)!;
setState(() {
if (networks == null) {
// ZeroTier服务不可用
_error = l10n.serviceNotRunning;
_networks = [];
} else {
_networks = List.from(networks);
_error = null;
}
});
}
} catch (e) {
if (mounted) {
// 存储原始错误信息
final rawError = e.toString();
setState(() {
_error = rawError; // 存储原始错误
_networks = [];
});
// 在界面上显示本地化错误信息
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.loadNetworksErrorText(rawError)),
backgroundColor: Colors.red,
),
);
}
} finally {
_setLoading(false);
}
}
Future<void> _performJoinNetwork(String networkId) async {
if (_isLoading) return;
_setLoading(true);
final l10n = AppLocalizations.of(context)!;
try {
final result = await widget.zerotierService.joinNetwork(networkId);
if (mounted) {
if (!widget.zerotierService.runningStatus) {
// ZeroTier服务未运行
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.serviceNotRunning),
backgroundColor: Colors.red,
),
);
} else if (result) {
QuickAlert.show(
context: context,
type: QuickAlertType.success,
text: l10n.joinNetworkSuccessText(networkId),
);
_idInputController.clear();
} else {
// 其他失败
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.joinNetworkErrorText("Unknown error")),
backgroundColor: Colors.red,
),
);
}
await _fetchNetworkList();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.joinNetworkErrorText(e.toString())), backgroundColor: Colors.red),
);
}
} finally {
_setLoading(false);
}
}
Future<void> _performLeaveNetwork(String networkId) async {
if (_isLoading) return;
_setLoading(true);
final l10n = AppLocalizations.of(context)!;
try {
final result = await widget.zerotierService.leaveNetwork(networkId);
if (mounted) {
if (!widget.zerotierService.runningStatus) {
// ZeroTier服务未运行
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.serviceNotRunning),
backgroundColor: Colors.red,
),
);
} else if (result) {
QuickAlert.show(
context: context,
type: QuickAlertType.success,
text: l10n.leaveNetworkSuccessText(networkId),
);
} else {
// 其他失败
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.leaveNetworkErrorText("Unknown error")),
backgroundColor: Colors.red,
),
);
}
await _fetchNetworkList();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.leaveNetworkErrorText(e.toString())), backgroundColor: Colors.red),
);
}
} finally {
_setLoading(false);
}
}
void _showLeaveConfirmation(String networkId) {
final l10n = AppLocalizations.of(context)!;
QuickAlert.show(
context: context,
type: QuickAlertType.confirm,
title: l10n.leaveNetworkConfirmationTitle,
text: l10n.leaveNetworkConfirmationText(networkId),
confirmBtnText: l10n.confirmButton,
cancelBtnText: l10n.cancelButton,
confirmBtnColor: Colors.red,
onConfirmBtnTap: () {
Navigator.pop(context);
_performLeaveNetwork(networkId);
},
onCancelBtnTap: () => Navigator.pop(context),
);
}
@override
void dispose() {
_idInputController.removeListener(_onTextChanged);
_idInputController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [
Expanded(
child: TextField(
controller: _idInputController,
textAlign: TextAlign.center,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow(RegExp('[0-9a-fA-F]'))
],
enabled: !_isLoading,
maxLength: 16,
decoration: InputDecoration(
labelText: l10n.networkIdLabel,
hintText: l10n.networkIdHint,
prefixIcon: const Icon(Icons.lan_outlined),
suffixIcon: _idInputController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: !_isLoading ? () => _idInputController.clear() : null,
tooltip: l10n.clearInputTooltip,
)
: null,
counterText: "",
border: const OutlineInputBorder()
)
)
),
const SizedBox(width: 12),
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
onPressed: _isLoading || _idInputController.text.isEmpty
? null
: () {
final networkId = _idInputController.text;
if (networkId.length != 16) {
QuickAlert.show(
context: context,
type: QuickAlertType.warning,
title: l10n.invalidNetworkIdTitle,
text: l10n.invalidNetworkIdText,
);
return;
}
_performJoinNetwork(networkId);
},
label: Text(l10n.joinButton),
icon: const Icon(Icons.add_link)),
]),
const SizedBox(height: 24),
const Divider(),
ListTile(
title: Text(l10n.networksTitle, style: Theme.of(context).textTheme.titleMedium),
trailing: IconButton(
icon: const Icon(Icons.refresh),
tooltip: l10n.refreshNetworksTooltip,
onPressed: _isLoading ? null : _fetchNetworkList
)
),
const SizedBox(height: 8),
Expanded(
child: _isLoading && _networks.isEmpty
? const Center(child: CircularProgressIndicator.adaptive())
: _error != null
? Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
l10n.loadNetworksErrorText(_error!),
style: TextStyle(color: Theme.of(context).colorScheme.error),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton.icon(
icon: const Icon(Icons.refresh),
label: Text(l10n.refreshButtonLabel ?? 'Retry'),
onPressed: _isLoading ? null : _fetchNetworkList,
)
],
),
)
)
: RefreshIndicator(
onRefresh: _fetchNetworkList,
child: _networks.isEmpty
? LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Container(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
alignment: Alignment.center,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
l10n.noNetworksJoined,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).disabledColor
),
textAlign: TextAlign.center,
),
),
),
),
)
: ListView.builder(
itemCount: _networks.length,
itemBuilder: (BuildContext ctx, int i) {
final networkId = _networks[i];
return Card(
elevation: 1.5,
margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 0),
child: ListTile(
leading: CircleAvatar(
child: Icon(Icons.vpn_key, size: 18, color: Theme.of(context).colorScheme.secondary),
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
),
title: Text(networkId, style: const TextStyle(fontFamily: 'monospace', letterSpacing: 0.8)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: _isLoading ? null : () {
Clipboard.setData(ClipboardData(text: networkId));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.copiedToClipboard(networkId)))
);
},
tooltip: l10n.copyTooltip,
icon: const Icon(Icons.copy, size: 20)
),
IconButton(
onPressed: _isLoading ? null : () => _showLeaveConfirmation(networkId),
tooltip: l10n.leaveTooltip,
icon: Icon(Icons.delete_outline, color: Theme.of(context).colorScheme.error, size: 20)
),
])
)
);
}
),
)
),
],
));
}
}
+250
View File
@@ -0,0 +1,250 @@
import 'package:flutter/material.dart';
import '../services/zerotier_service.dart';
import '../l10n/app_localizations.dart'; // Import localizations
import '../models/peer_info.dart'; // Import the model
// class PeersPage extends StatelessWidget { // Convert to StatefulWidget
class PeersPage extends StatefulWidget {
final ZerotierService zerotierService;
// Remove callback parameter
// final Future<void> Function()? onRefreshPeers;
const PeersPage({
super.key,
required this.zerotierService,
// Remove callback parameter from constructor
// this.onRefreshPeers,
});
@override
// State<PeersPage> createState() => _PeersPageState(); // Add createState
State<PeersPage> createState() => _PeersPageState();
}
// Add State class
class _PeersPageState extends State<PeersPage> {
bool _isLoading = false;
// Use the PeerInfo model for the list
List<PeerInfo> _peers = [];
String? _error;
@override
void initState() {
super.initState();
_fetchPeers(); // Call fetch method on init
}
// Helper to manage loading state
void _setLoading(bool loading) {
if (!mounted) return;
setState(() {
_isLoading = loading;
if (loading) {
_error = null; // Clear error when starting load
}
});
}
// Placeholder method to fetch peers
Future<void> _fetchPeers() async {
_setLoading(true);
try {
// 调用服务方法并获取返回值
final peers = await widget.zerotierService.loadPeers();
if (mounted) {
final l10n = AppLocalizations.of(context)!;
setState(() {
if (peers == null) {
// ZeroTier服务不可用
_error = l10n.serviceNotRunning;
_peers = [];
} else {
_peers = List.from(peers);
_error = null;
}
});
}
} catch (e) {
if (mounted) {
// 存储原始错误信息,不使用 l10n
final rawError = e.toString();
setState(() {
// 在 setState 内部构建错误显示文本
_error = rawError; // 存储原始错误,显示时再格式化
_peers = [];
});
// 在确认 mounted 后使用 l10n 和 context
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.loadPeersErrorText(rawError)),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
_setLoading(false);
}
}
}
// Helper to build list item display
Widget _buildPeerTile(PeerInfo peer, AppLocalizations l10n) {
IconData roleIcon = peer.isPlanet ? Icons.cloud_outlined : Icons.computer_outlined;
Color roleColor = peer.isPlanet ? Colors.blueGrey : Theme.of(context).colorScheme.secondary;
// 设置tunneled状态显示
final tunnelIcon = peer.tunneled ? Icons.settings_ethernet : Icons.wifi;
final tunnelColor = peer.tunneled ? Colors.orange.shade700 : Colors.green.shade600;
final tunnelText = peer.tunneled ? l10n.peerTunneled : l10n.peerDirect;
return Card(
elevation: 1.5,
margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: roleColor.withOpacity(0.1),
child: Icon(roleIcon, size: 20, color: roleColor),
),
title: Row(
children: [
Expanded(
child: Text(
peer.address,
style: const TextStyle(fontFamily: 'monospace', fontSize: 14),
overflow: TextOverflow.ellipsis,
),
),
// 对于非PLANET节点显示版本
if (!peer.isPlanet && peer.version != null) ...[
const SizedBox(width: 8),
Text(
peer.version!,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade700,
fontWeight: FontWeight.normal
),
),
],
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Row(
children: [
Chip(
avatar: Icon(roleIcon, size: 14, color: roleColor),
label: Text(peer.role, style: TextStyle(fontSize: 12, color: roleColor)),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0),
visualDensity: VisualDensity.compact,
backgroundColor: roleColor.withOpacity(0.1),
side: BorderSide.none,
),
const SizedBox(width: 8),
// 添加tunneled状态显示
Chip(
avatar: Icon(tunnelIcon, size: 14, color: tunnelColor),
label: Text(tunnelText, style: TextStyle(fontSize: 12, color: tunnelColor)),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0),
visualDensity: VisualDensity.compact,
backgroundColor: tunnelColor.withOpacity(0.1),
side: BorderSide.none,
),
const SizedBox(width: 8),
if (peer.latency != null) ...[
Icon(Icons.timer_outlined, size: 14, color: Colors.orange.shade700),
const SizedBox(width: 4),
Text('${peer.latency} ms', style: TextStyle(fontSize: 12, color: Colors.orange.shade700)),
],
],
),
if (peer.preferredPath != null) ...[
const SizedBox(height: 4),
Text(
peer.preferredPath!,
style: TextStyle(fontSize: 11, color: Theme.of(context).disabledColor, fontFamily: 'monospace'),
overflow: TextOverflow.ellipsis,
),
]
],
),
dense: true,
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
Widget bodyContent;
if (_isLoading && _peers.isEmpty) {
bodyContent = const Center(child: CircularProgressIndicator.adaptive());
} else if (_error != null) {
bodyContent = Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
l10n.loadPeersErrorText(_error!),
style: TextStyle(color: Theme.of(context).colorScheme.error),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton.icon(
icon: const Icon(Icons.refresh),
label: Text(l10n.refreshButtonLabel),
onPressed: _isLoading ? null : _fetchPeers,
)
],
),
)
);
} else if (!_isLoading && _peers.isEmpty) {
// Show "No peers found" centered, allowing pull-to-refresh
bodyContent = LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Container(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
alignment: Alignment.center,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
l10n.noPeersFound, // Uses existing l10n key
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).disabledColor
),
textAlign: TextAlign.center,
),
),
),
),
);
} else {
// Display the list using ListView.builder
bodyContent = ListView.builder(
physics: const AlwaysScrollableScrollPhysics(), // Ensure scrollable even when few items
itemCount: _peers.length,
itemBuilder: (context, index) {
return _buildPeerTile(_peers[index], l10n);
},
);
}
// Wrap the main content with RefreshIndicator
return RefreshIndicator(
onRefresh: _fetchPeers,
child: bodyContent,
);
}
}
+390
View File
@@ -0,0 +1,390 @@
import 'package:flutter/material.dart';
import '../services/zerotier_service.dart';
import '../l10n/app_localizations.dart';
class StatusPage extends StatefulWidget {
final ZerotierService zerotierService;
const StatusPage({
super.key,
required this.zerotierService,
});
@override
State<StatusPage> createState() => _StatusPageState();
}
class _StatusPageState extends State<StatusPage> {
bool _isLoading = false;
ZerotierStatus? _statusInfo;
bool _isModuleInstalled = true;
@override
void initState() {
super.initState();
// 获取缓存的状态信息,如果有则直接使用
final cachedStatus = widget.zerotierService.statusInfo;
if (cachedStatus != null) {
_statusInfo = cachedStatus;
_isModuleInstalled = true;
}
// 然后异步更新状态
_fetchStatus();
}
Future<void> _fetchStatus() async {
if (!mounted) return;
// 检查是否有缓存的状态
final cachedStatus = widget.zerotierService.statusInfo;
final bool hasCache = cachedStatus != null;
// 如果没有缓存或者当前状态为空,才显示加载中
if (!hasCache || _statusInfo == null) {
setState(() => _isLoading = true);
}
try {
final status = await widget.zerotierService.loadStatus();
if (mounted) {
setState(() {
_statusInfo = status;
_isModuleInstalled = true;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load status: $e')),
);
setState(() => _isLoading = false);
}
}
}
Future<void> _performServiceAction(String command) async {
if (!mounted || _isLoading) return;
setState(() => _isLoading = true);
final l10n = AppLocalizations.of(context)!;
String actionVerb = command;
if (command == 'start') actionVerb = 'Starting';
if (command == 'stop') actionVerb = 'Stopping';
if (command == 'restart') actionVerb = 'Restarting';
try {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$actionVerb ZeroTier...'), duration: const Duration(seconds: 1)),
);
final success = await widget.zerotierService.zerotierCommand(command);
if (!success && mounted) {
setState(() {
_isModuleInstalled = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.moduleNotRunning),
backgroundColor: Colors.red,
duration: const Duration(seconds: 5),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$command failed: $e'), backgroundColor: Colors.red),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
await _fetchStatus();
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cachedStatus = widget.zerotierService.statusInfo;
final bool runningStatus = (_statusInfo != null && _statusInfo!.online) ||
(cachedStatus != null && cachedStatus.online && _statusInfo == null);
final statusColor = runningStatus ? Colors.green.shade400 : Colors.red.shade400;
final l10n = AppLocalizations.of(context)!;
return RefreshIndicator(
onRefresh: _fetchStatus,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 24.0, horizontal: 16.0),
child: Column(
children: [
_buildStatusIcon(runningStatus),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildStatusText(context, runningStatus, theme, l10n),
IconButton(
icon: Icon(Icons.refresh, color: theme.colorScheme.secondary),
onPressed: _isLoading ? null : _fetchStatus,
tooltip: l10n.refreshStatusTooltip,
),
],
),
],
),
),
const SizedBox(height: 20),
_ActionButtons(
l10n: l10n,
startFunction: () => _performServiceAction('start'),
restartFunction: () => _performServiceAction('restart'),
stopFunction: () => _performServiceAction('stop'),
isServiceRunning: runningStatus,
isModuleInstalled: _isModuleInstalled,
),
const SizedBox(height: 20),
if (_isLoading)
const Center(child: Padding(
padding: EdgeInsets.symmetric(vertical: 32.0),
child: CircularProgressIndicator(),
))
else if (_statusInfo != null && runningStatus)
_buildStatusInfo(context, _statusInfo!)
else if (cachedStatus != null && cachedStatus.online)
_buildStatusInfo(context, cachedStatus)
else
Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 32.0, horizontal: 16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildErrorText(context, theme, l10n),
],
),
)
)
],
),
),
);
}
Widget _buildStatusIcon(bool runningStatus) {
IconData iconData;
Color iconColor;
if (!_isModuleInstalled) {
iconData = Icons.error;
iconColor = Colors.orange.shade700;
} else if (runningStatus) {
iconData = Icons.play_circle_fill;
iconColor = Colors.green.shade400;
} else {
iconData = Icons.stop_circle;
iconColor = Colors.red.shade400;
}
return Icon(
iconData,
color: iconColor,
size: 64,
);
}
Widget _buildStatusText(BuildContext context, bool runningStatus, ThemeData theme, AppLocalizations l10n) {
String statusText;
Color textColor;
if (!_isModuleInstalled) {
statusText = l10n.moduleNotRunning;
textColor = Colors.orange.shade700;
} else if (runningStatus) {
statusText = l10n.statusRunning;
textColor = Colors.green.shade400;
} else {
statusText = l10n.statusStopped;
textColor = Colors.red.shade400;
}
if (_isLoading) {
textColor = textColor.withOpacity(0.5);
}
return Text(
statusText,
style: theme.textTheme.headlineSmall?.copyWith(
color: textColor,
fontWeight: FontWeight.bold
),
);
}
Widget _buildErrorText(BuildContext context, ThemeData theme, AppLocalizations l10n) {
String errorText;
Color textColor;
if (!_isModuleInstalled) {
errorText = l10n.moduleNotRunning;
textColor = Colors.orange.shade700;
} else {
errorText = l10n.statusStopped;
textColor = theme.colorScheme.error;
}
return Text(
errorText,
style: theme.textTheme.bodyMedium?.copyWith(
color: textColor
),
textAlign: TextAlign.center,
);
}
Widget _buildStatusInfo(BuildContext context, ZerotierStatus status) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.statusDetailsTitle,
style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)
),
const Divider(height: 20, thickness: 1),
_buildInfoTile(context, Icons.lan_outlined, l10n.nodeAddressLabel, status.address),
_buildInfoTile(context, Icons.info_outline, l10n.softwareVersionLabel, status.version),
_buildInfoTile(context, status.online ? Icons.cloud_done_outlined : Icons.cloud_off_outlined, l10n.onlineStatusLabel, status.online ? l10n.onlineStatusOnline : l10n.onlineStatusOffline),
_buildInfoTile(context, Icons.settings_ethernet, l10n.primaryPortLabel, status.primaryPort.toString()),
const SizedBox(height: 10),
ListTile(
leading: Icon(Icons.list_alt, color: theme.colorScheme.secondary),
title: Text(l10n.listeningAddressesLabel, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500)),
),
Container(
height: 100,
margin: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withOpacity(0.5),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.dividerColor),
),
child: status.listeningOn.isEmpty
? Center(child: Text(l10n.noListeningAddresses, style: theme.textTheme.bodySmall))
: ListView.builder(
shrinkWrap: true,
itemCount: status.listeningOn.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: Text(
status.listeningOn[index],
style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
),
);
},
),
),
],
),
);
}
Widget _buildInfoTile(BuildContext context, IconData icon, String label, String value) {
final theme = Theme.of(context);
return ListTile(
leading: Icon(icon, color: theme.colorScheme.secondary),
title: Text(label, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500)),
subtitle: Text(value, style: theme.textTheme.bodyMedium?.copyWith(fontFamily: 'monospace')),
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
);
}
}
class _ActionButtons extends StatelessWidget {
final AppLocalizations l10n;
final Function()? startFunction;
final Function()? restartFunction;
final Function()? stopFunction;
final bool isServiceRunning;
final bool isModuleInstalled;
const _ActionButtons({
required this.l10n,
required this.startFunction,
required this.restartFunction,
required this.stopFunction,
required this.isServiceRunning,
required this.isModuleInstalled,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
children: <Widget>[
Center(
child: SizedBox(
width: 180,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12)),
onPressed: isServiceRunning || !isModuleInstalled ? null : startFunction,
label: Text(l10n.startButton),
icon: const Icon(Icons.play_arrow),
),
),
),
const SizedBox(height: 16),
Center(
child: SizedBox(
width: 180,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12)),
onPressed: isServiceRunning && isModuleInstalled ? restartFunction : null,
label: Text(l10n.restartButton),
icon: const Icon(Icons.restart_alt),
),
),
),
const SizedBox(height: 16),
Center(
child: SizedBox(
width: 180,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.error,
side: BorderSide(color: theme.colorScheme.error),
padding: const EdgeInsets.symmetric(vertical: 12)
),
onPressed: isServiceRunning && isModuleInstalled ? stopFunction : null,
label: Text(l10n.stopButton),
icon: const Icon(Icons.stop),
),
),
),
],
),
);
}
}
+125
View File
@@ -0,0 +1,125 @@
{
"@@locale": "en",
"statusRunning": "Running",
"statusStopped": "Stopped",
"refreshStatusTooltip": "Refresh Status",
"startButton": "Start",
"restartButton": "Restart",
"stopButton": "Stop",
"statusDetailsTitle": "ZeroTier Details",
"nodeAddressLabel": "Node Address",
"softwareVersionLabel": "Software Version",
"onlineStatusLabel": "Online Status",
"onlineStatusOnline": "Online",
"onlineStatusOffline": "Offline",
"primaryPortLabel": "Primary Port",
"listeningAddressesLabel": "Listening Addresses:",
"noListeningAddresses": "None",
"leaveNetworkConfirmationTitle": "Leave Network?",
"leaveNetworkConfirmationText": "Are you sure you want to leave network {networkId}?",
"@leaveNetworkConfirmationText": {
"placeholders": {
"networkId": {
"type": "String",
"example": "1234567890abcdef"
}
}
},
"confirmButton": "Confirm",
"cancelButton": "Cancel",
"networkIdLabel": "Network ID",
"networkIdHint": "Enter 16-character Network ID",
"invalidNetworkIdTitle": "Invalid Network ID",
"invalidNetworkIdText": "Please enter a 16-character network ID.",
"joinButton": "Join",
"networksTitle": "Networks",
"refreshNetworksTooltip": "Refresh Network List",
"noNetworksJoined": "No networks joined",
"copiedToClipboard": "Copied {networkId} to clipboard!",
"@copiedToClipboard": {
"placeholders": {
"networkId": {
"type": "String",
"example": "1234567890abcdef"
}
}
},
"copyTooltip": "Copy",
"leaveTooltip": "Leave",
"clearInputTooltip": "Clear input",
"peersFeatureComingSoon": "Peers feature coming soon",
"noPeersFound": "No peers found",
"navBarLabelStatus": "Status",
"navBarLabelNetworks": "Networks",
"navBarLabelPeers": "Peers",
"appBarTitleStatus": "Status",
"appBarTitleNetworks": "Networks",
"appBarTitlePeers": "Peers",
"joinNetworkSuccessText": "Successfully joined network {networkId}!",
"@joinNetworkSuccessText": {
"placeholders": {
"networkId": {
"type": "String",
"example": "1234567890abcdef"
}
}
},
"joinNetworkErrorText": "Failed to join network: {error}",
"@joinNetworkErrorText": {
"placeholders": {
"error": {
"type": "String",
"example": "Connection timed out"
}
}
},
"leaveNetworkSuccessText": "Successfully left network {networkId}.",
"@leaveNetworkSuccessText": {
"placeholders": {
"networkId": {
"type": "String",
"example": "1234567890abcdef"
}
}
},
"leaveNetworkErrorText": "Failed to leave network: {error}",
"@leaveNetworkErrorText": {
"placeholders": {
"error": {
"type": "String",
"example": "Permission denied"
}
}
},
"refreshButtonLabel": "Retry",
"loadPeersErrorText": "Failed to load peers: {error}",
"@loadPeersErrorText": {
"placeholders": {
"error": {
"type": "String",
"example": "Network error"
}
}
},
"loadNetworksErrorText": "Failed to load networks: {error}",
"@loadNetworksErrorText": {
"placeholders": {
"error": {
"type": "String",
"example": "Network error"
}
}
},
"serviceNotRunning": "ZeroTier service is not running",
"moduleNotRunning": "ZeroTier Magisk module is not running. Please check if you have installed the ZeroTier Magisk module.",
"peerTunneled": "Relayed",
"peerDirect": "Direct"
}
+429
View File
@@ -0,0 +1,429 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart' as intl;
import 'app_localizations_en.dart';
import 'app_localizations_zh.dart';
// ignore_for_file: type=lint
/// Callers can lookup localized strings with an instance of AppLocalizations
/// returned by `AppLocalizations.of(context)`.
///
/// Applications need to include `AppLocalizations.delegate()` in their app's
/// `localizationDelegates` list, and the locales they support in the app's
/// `supportedLocales` list. For example:
///
/// ```dart
/// import 'l10n/app_localizations.dart';
///
/// return MaterialApp(
/// localizationsDelegates: AppLocalizations.localizationsDelegates,
/// supportedLocales: AppLocalizations.supportedLocales,
/// home: MyApplicationHome(),
/// );
/// ```
///
/// ## Update pubspec.yaml
///
/// Please make sure to update your pubspec.yaml to include the following
/// packages:
///
/// ```yaml
/// dependencies:
/// # Internationalization support.
/// flutter_localizations:
/// sdk: flutter
/// intl: any # Use the pinned version from flutter_localizations
///
/// # Rest of dependencies
/// ```
///
/// ## iOS Applications
///
/// iOS applications define key application metadata, including supported
/// locales, in an Info.plist file that is built into the application bundle.
/// To configure the locales supported by your app, youll need to edit this
/// file.
///
/// First, open your projects ios/Runner.xcworkspace Xcode workspace file.
/// Then, in the Project Navigator, open the Info.plist file under the Runner
/// projects Runner folder.
///
/// Next, select the Information Property List item, select Add Item from the
/// Editor menu, then select Localizations from the pop-up menu.
///
/// Select and expand the newly-created Localizations item then, for each
/// locale your application supports, add a new item and select the locale
/// you wish to add from the pop-up menu in the Value field. This list should
/// be consistent with the languages listed in the AppLocalizations.supportedLocales
/// property.
abstract class AppLocalizations {
AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName;
static AppLocalizations of(BuildContext context) {
return Localizations.of<AppLocalizations>(context, AppLocalizations)!;
}
static const LocalizationsDelegate<AppLocalizations> delegate = _AppLocalizationsDelegate();
/// A list of this localizations delegate along with the default localizations
/// delegates.
///
/// Returns a list of localizations delegates containing this delegate along with
/// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
/// and GlobalWidgetsLocalizations.delegate.
///
/// Additional delegates can be added by appending to this list in
/// MaterialApp. This list does not have to be used at all if a custom list
/// of delegates is preferred or required.
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates = <LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[
Locale('en'),
Locale('zh')
];
/// No description provided for @statusRunning.
///
/// In en, this message translates to:
/// **'Running'**
String get statusRunning;
/// No description provided for @statusStopped.
///
/// In en, this message translates to:
/// **'Stopped'**
String get statusStopped;
/// No description provided for @refreshStatusTooltip.
///
/// In en, this message translates to:
/// **'Refresh Status'**
String get refreshStatusTooltip;
/// No description provided for @startButton.
///
/// In en, this message translates to:
/// **'Start'**
String get startButton;
/// No description provided for @restartButton.
///
/// In en, this message translates to:
/// **'Restart'**
String get restartButton;
/// No description provided for @stopButton.
///
/// In en, this message translates to:
/// **'Stop'**
String get stopButton;
/// No description provided for @statusDetailsTitle.
///
/// In en, this message translates to:
/// **'ZeroTier Details'**
String get statusDetailsTitle;
/// No description provided for @nodeAddressLabel.
///
/// In en, this message translates to:
/// **'Node Address'**
String get nodeAddressLabel;
/// No description provided for @softwareVersionLabel.
///
/// In en, this message translates to:
/// **'Software Version'**
String get softwareVersionLabel;
/// No description provided for @onlineStatusLabel.
///
/// In en, this message translates to:
/// **'Online Status'**
String get onlineStatusLabel;
/// No description provided for @onlineStatusOnline.
///
/// In en, this message translates to:
/// **'Online'**
String get onlineStatusOnline;
/// No description provided for @onlineStatusOffline.
///
/// In en, this message translates to:
/// **'Offline'**
String get onlineStatusOffline;
/// No description provided for @primaryPortLabel.
///
/// In en, this message translates to:
/// **'Primary Port'**
String get primaryPortLabel;
/// No description provided for @listeningAddressesLabel.
///
/// In en, this message translates to:
/// **'Listening Addresses:'**
String get listeningAddressesLabel;
/// No description provided for @noListeningAddresses.
///
/// In en, this message translates to:
/// **'None'**
String get noListeningAddresses;
/// No description provided for @leaveNetworkConfirmationTitle.
///
/// In en, this message translates to:
/// **'Leave Network?'**
String get leaveNetworkConfirmationTitle;
/// No description provided for @leaveNetworkConfirmationText.
///
/// In en, this message translates to:
/// **'Are you sure you want to leave network {networkId}?'**
String leaveNetworkConfirmationText(String networkId);
/// No description provided for @confirmButton.
///
/// In en, this message translates to:
/// **'Confirm'**
String get confirmButton;
/// No description provided for @cancelButton.
///
/// In en, this message translates to:
/// **'Cancel'**
String get cancelButton;
/// No description provided for @networkIdLabel.
///
/// In en, this message translates to:
/// **'Network ID'**
String get networkIdLabel;
/// No description provided for @networkIdHint.
///
/// In en, this message translates to:
/// **'Enter 16-character Network ID'**
String get networkIdHint;
/// No description provided for @invalidNetworkIdTitle.
///
/// In en, this message translates to:
/// **'Invalid Network ID'**
String get invalidNetworkIdTitle;
/// No description provided for @invalidNetworkIdText.
///
/// In en, this message translates to:
/// **'Please enter a 16-character network ID.'**
String get invalidNetworkIdText;
/// No description provided for @joinButton.
///
/// In en, this message translates to:
/// **'Join'**
String get joinButton;
/// No description provided for @networksTitle.
///
/// In en, this message translates to:
/// **'Networks'**
String get networksTitle;
/// No description provided for @refreshNetworksTooltip.
///
/// In en, this message translates to:
/// **'Refresh Network List'**
String get refreshNetworksTooltip;
/// No description provided for @noNetworksJoined.
///
/// In en, this message translates to:
/// **'No networks joined'**
String get noNetworksJoined;
/// No description provided for @copiedToClipboard.
///
/// In en, this message translates to:
/// **'Copied {networkId} to clipboard!'**
String copiedToClipboard(String networkId);
/// No description provided for @copyTooltip.
///
/// In en, this message translates to:
/// **'Copy'**
String get copyTooltip;
/// No description provided for @leaveTooltip.
///
/// In en, this message translates to:
/// **'Leave'**
String get leaveTooltip;
/// No description provided for @clearInputTooltip.
///
/// In en, this message translates to:
/// **'Clear input'**
String get clearInputTooltip;
/// No description provided for @peersFeatureComingSoon.
///
/// In en, this message translates to:
/// **'Peers feature coming soon'**
String get peersFeatureComingSoon;
/// No description provided for @noPeersFound.
///
/// In en, this message translates to:
/// **'No peers found'**
String get noPeersFound;
/// No description provided for @navBarLabelStatus.
///
/// In en, this message translates to:
/// **'Status'**
String get navBarLabelStatus;
/// No description provided for @navBarLabelNetworks.
///
/// In en, this message translates to:
/// **'Networks'**
String get navBarLabelNetworks;
/// No description provided for @navBarLabelPeers.
///
/// In en, this message translates to:
/// **'Peers'**
String get navBarLabelPeers;
/// No description provided for @appBarTitleStatus.
///
/// In en, this message translates to:
/// **'Status'**
String get appBarTitleStatus;
/// No description provided for @appBarTitleNetworks.
///
/// In en, this message translates to:
/// **'Networks'**
String get appBarTitleNetworks;
/// No description provided for @appBarTitlePeers.
///
/// In en, this message translates to:
/// **'Peers'**
String get appBarTitlePeers;
/// No description provided for @joinNetworkSuccessText.
///
/// In en, this message translates to:
/// **'Successfully joined network {networkId}!'**
String joinNetworkSuccessText(String networkId);
/// No description provided for @joinNetworkErrorText.
///
/// In en, this message translates to:
/// **'Failed to join network: {error}'**
String joinNetworkErrorText(String error);
/// No description provided for @leaveNetworkSuccessText.
///
/// In en, this message translates to:
/// **'Successfully left network {networkId}.'**
String leaveNetworkSuccessText(String networkId);
/// No description provided for @leaveNetworkErrorText.
///
/// In en, this message translates to:
/// **'Failed to leave network: {error}'**
String leaveNetworkErrorText(String error);
/// No description provided for @refreshButtonLabel.
///
/// In en, this message translates to:
/// **'Retry'**
String get refreshButtonLabel;
/// No description provided for @loadPeersErrorText.
///
/// In en, this message translates to:
/// **'Failed to load peers: {error}'**
String loadPeersErrorText(String error);
/// No description provided for @loadNetworksErrorText.
///
/// In en, this message translates to:
/// **'Failed to load networks: {error}'**
String loadNetworksErrorText(String error);
/// No description provided for @serviceNotRunning.
///
/// In en, this message translates to:
/// **'ZeroTier service is not running'**
String get serviceNotRunning;
/// No description provided for @moduleNotRunning.
///
/// In en, this message translates to:
/// **'ZeroTier Magisk module is not running. Please check if you have installed the ZeroTier Magisk module.'**
String get moduleNotRunning;
/// No description provided for @peerTunneled.
///
/// In en, this message translates to:
/// **'Relayed'**
String get peerTunneled;
/// No description provided for @peerDirect.
///
/// In en, this message translates to:
/// **'Direct'**
String get peerDirect;
}
class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
const _AppLocalizationsDelegate();
@override
Future<AppLocalizations> load(Locale locale) {
return SynchronousFuture<AppLocalizations>(lookupAppLocalizations(locale));
}
@override
bool isSupported(Locale locale) => <String>['en', 'zh'].contains(locale.languageCode);
@override
bool shouldReload(_AppLocalizationsDelegate old) => false;
}
AppLocalizations lookupAppLocalizations(Locale locale) {
// Lookup logic when only language code is specified.
switch (locale.languageCode) {
case 'en': return AppLocalizationsEn();
case 'zh': return AppLocalizationsZh();
}
throw FlutterError(
'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
'an issue with the localizations generation tool. Please file an issue '
'on GitHub with a reproducible sample app and the gen-l10n configuration '
'that was used.'
);
}
+176
View File
@@ -0,0 +1,176 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for English (`en`).
class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get statusRunning => 'Running';
@override
String get statusStopped => 'Stopped';
@override
String get refreshStatusTooltip => 'Refresh Status';
@override
String get startButton => 'Start';
@override
String get restartButton => 'Restart';
@override
String get stopButton => 'Stop';
@override
String get statusDetailsTitle => 'ZeroTier Details';
@override
String get nodeAddressLabel => 'Node Address';
@override
String get softwareVersionLabel => 'Software Version';
@override
String get onlineStatusLabel => 'Online Status';
@override
String get onlineStatusOnline => 'Online';
@override
String get onlineStatusOffline => 'Offline';
@override
String get primaryPortLabel => 'Primary Port';
@override
String get listeningAddressesLabel => 'Listening Addresses:';
@override
String get noListeningAddresses => 'None';
@override
String get leaveNetworkConfirmationTitle => 'Leave Network?';
@override
String leaveNetworkConfirmationText(String networkId) {
return 'Are you sure you want to leave network $networkId?';
}
@override
String get confirmButton => 'Confirm';
@override
String get cancelButton => 'Cancel';
@override
String get networkIdLabel => 'Network ID';
@override
String get networkIdHint => 'Enter 16-character Network ID';
@override
String get invalidNetworkIdTitle => 'Invalid Network ID';
@override
String get invalidNetworkIdText => 'Please enter a 16-character network ID.';
@override
String get joinButton => 'Join';
@override
String get networksTitle => 'Networks';
@override
String get refreshNetworksTooltip => 'Refresh Network List';
@override
String get noNetworksJoined => 'No networks joined';
@override
String copiedToClipboard(String networkId) {
return 'Copied $networkId to clipboard!';
}
@override
String get copyTooltip => 'Copy';
@override
String get leaveTooltip => 'Leave';
@override
String get clearInputTooltip => 'Clear input';
@override
String get peersFeatureComingSoon => 'Peers feature coming soon';
@override
String get noPeersFound => 'No peers found';
@override
String get navBarLabelStatus => 'Status';
@override
String get navBarLabelNetworks => 'Networks';
@override
String get navBarLabelPeers => 'Peers';
@override
String get appBarTitleStatus => 'Status';
@override
String get appBarTitleNetworks => 'Networks';
@override
String get appBarTitlePeers => 'Peers';
@override
String joinNetworkSuccessText(String networkId) {
return 'Successfully joined network $networkId!';
}
@override
String joinNetworkErrorText(String error) {
return 'Failed to join network: $error';
}
@override
String leaveNetworkSuccessText(String networkId) {
return 'Successfully left network $networkId.';
}
@override
String leaveNetworkErrorText(String error) {
return 'Failed to leave network: $error';
}
@override
String get refreshButtonLabel => 'Retry';
@override
String loadPeersErrorText(String error) {
return 'Failed to load peers: $error';
}
@override
String loadNetworksErrorText(String error) {
return 'Failed to load networks: $error';
}
@override
String get serviceNotRunning => 'ZeroTier service is not running';
@override
String get moduleNotRunning => 'ZeroTier Magisk module is not running. Please check if you have installed the ZeroTier Magisk module.';
@override
String get peerTunneled => 'Relayed';
@override
String get peerDirect => 'Direct';
}
+176
View File
@@ -0,0 +1,176 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for Chinese (`zh`).
class AppLocalizationsZh extends AppLocalizations {
AppLocalizationsZh([String locale = 'zh']) : super(locale);
@override
String get statusRunning => '运行中';
@override
String get statusStopped => '已停止';
@override
String get refreshStatusTooltip => '刷新状态';
@override
String get startButton => '启动';
@override
String get restartButton => '重启';
@override
String get stopButton => '停止';
@override
String get statusDetailsTitle => 'ZeroTier 详细信息';
@override
String get nodeAddressLabel => '节点地址';
@override
String get softwareVersionLabel => '软件版本';
@override
String get onlineStatusLabel => '在线状态';
@override
String get onlineStatusOnline => '在线';
@override
String get onlineStatusOffline => '离线';
@override
String get primaryPortLabel => '主端口';
@override
String get listeningAddressesLabel => '监听地址:';
@override
String get noListeningAddresses => '';
@override
String get leaveNetworkConfirmationTitle => '离开网络?';
@override
String leaveNetworkConfirmationText(String networkId) {
return '您确定要离开网络 $networkId 吗?';
}
@override
String get confirmButton => '确认';
@override
String get cancelButton => '取消';
@override
String get networkIdLabel => '网络 ID';
@override
String get networkIdHint => '输入 16 位网络 ID';
@override
String get invalidNetworkIdTitle => '无效的网络 ID';
@override
String get invalidNetworkIdText => '请输入一个 16 位的网络 ID。';
@override
String get joinButton => '加入';
@override
String get networksTitle => '网络';
@override
String get refreshNetworksTooltip => '刷新网络列表';
@override
String get noNetworksJoined => '未加入任何网络';
@override
String copiedToClipboard(String networkId) {
return '已将 $networkId 复制到剪贴板!';
}
@override
String get copyTooltip => '复制';
@override
String get leaveTooltip => '离开';
@override
String get clearInputTooltip => '清除输入';
@override
String get peersFeatureComingSoon => '节点功能即将推出';
@override
String get noPeersFound => '未找到节点';
@override
String get navBarLabelStatus => '状态';
@override
String get navBarLabelNetworks => '网络';
@override
String get navBarLabelPeers => '节点';
@override
String get appBarTitleStatus => '状态';
@override
String get appBarTitleNetworks => '网络';
@override
String get appBarTitlePeers => '节点';
@override
String joinNetworkSuccessText(String networkId) {
return '成功加入网络 $networkId!';
}
@override
String joinNetworkErrorText(String error) {
return '加入网络失败: $error';
}
@override
String leaveNetworkSuccessText(String networkId) {
return '成功离开网络 $networkId';
}
@override
String leaveNetworkErrorText(String error) {
return '离开网络失败: $error';
}
@override
String get refreshButtonLabel => '重试';
@override
String loadPeersErrorText(String error) {
return '加载节点列表失败: $error';
}
@override
String loadNetworksErrorText(String error) {
return '加载网络列表失败: $error';
}
@override
String get serviceNotRunning => 'ZeroTier服务未运行';
@override
String get moduleNotRunning => 'ZeroTier Magisk模块未运行,请检查您是否安装Zerotier Magisk模块。';
@override
String get peerTunneled => '中继';
@override
String get peerDirect => '直连';
}
+125
View File
@@ -0,0 +1,125 @@
{
"@@locale": "zh",
"statusRunning": "运行中",
"statusStopped": "已停止",
"refreshStatusTooltip": "刷新状态",
"startButton": "启动",
"restartButton": "重启",
"stopButton": "停止",
"statusDetailsTitle": "ZeroTier 详细信息",
"nodeAddressLabel": "节点地址",
"softwareVersionLabel": "软件版本",
"onlineStatusLabel": "在线状态",
"onlineStatusOnline": "在线",
"onlineStatusOffline": "离线",
"primaryPortLabel": "主端口",
"listeningAddressesLabel": "监听地址:",
"noListeningAddresses": "无",
"leaveNetworkConfirmationTitle": "离开网络?",
"leaveNetworkConfirmationText": "您确定要离开网络 {networkId} 吗?",
"@leaveNetworkConfirmationText": {
"placeholders": {
"networkId": {
"type": "String",
"example": "1234567890abcdef"
}
}
},
"confirmButton": "确认",
"cancelButton": "取消",
"networkIdLabel": "网络 ID",
"networkIdHint": "输入 16 位网络 ID",
"invalidNetworkIdTitle": "无效的网络 ID",
"invalidNetworkIdText": "请输入一个 16 位的网络 ID。",
"joinButton": "加入",
"networksTitle": "网络",
"refreshNetworksTooltip": "刷新网络列表",
"noNetworksJoined": "未加入任何网络",
"copiedToClipboard": "已将 {networkId} 复制到剪贴板!",
"@copiedToClipboard": {
"placeholders": {
"networkId": {
"type": "String",
"example": "1234567890abcdef"
}
}
},
"copyTooltip": "复制",
"leaveTooltip": "离开",
"clearInputTooltip": "清除输入",
"peersFeatureComingSoon": "节点功能即将推出",
"noPeersFound": "未找到节点",
"navBarLabelStatus": "状态",
"navBarLabelNetworks": "网络",
"navBarLabelPeers": "节点",
"appBarTitleStatus": "状态",
"appBarTitleNetworks": "网络",
"appBarTitlePeers": "节点",
"joinNetworkSuccessText": "成功加入网络 {networkId}!",
"@joinNetworkSuccessText": {
"placeholders": {
"networkId": {
"type": "String",
"example": "1234567890abcdef"
}
}
},
"joinNetworkErrorText": "加入网络失败: {error}",
"@joinNetworkErrorText": {
"placeholders": {
"error": {
"type": "String",
"example": "连接超时"
}
}
},
"leaveNetworkSuccessText": "成功离开网络 {networkId}。",
"@leaveNetworkSuccessText": {
"placeholders": {
"networkId": {
"type": "String",
"example": "1234567890abcdef"
}
}
},
"leaveNetworkErrorText": "离开网络失败: {error}",
"@leaveNetworkErrorText": {
"placeholders": {
"error": {
"type": "String",
"example": "权限不足"
}
}
},
"refreshButtonLabel": "重试",
"loadPeersErrorText": "加载节点列表失败: {error}",
"@loadPeersErrorText": {
"placeholders": {
"error": {
"type": "String",
"example": "网络错误"
}
}
},
"loadNetworksErrorText": "加载网络列表失败: {error}",
"@loadNetworksErrorText": {
"placeholders": {
"error": {
"type": "String",
"example": "网络错误"
}
}
},
"serviceNotRunning": "ZeroTier服务未运行",
"moduleNotRunning": "ZeroTier Magisk模块未运行,请检查您是否安装Zerotier Magisk模块。",
"peerTunneled": "中继",
"peerDirect": "直连"
}
+48
View File
@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'dart:developer' as developer;
import './screens/home_page.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import './l10n/app_localizations.dart';
void main() {
// Add global error handling
FlutterError.onError = (FlutterErrorDetails details) {
FlutterError.presentError(details);
developer.log('Application error', error: details.toString());
};
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'ZeroTier for Magisk',
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [
Locale('en'),
Locale('zh'),
],
theme: ThemeData(
colorSchemeSeed: Colors.lightBlue,
useMaterial3: true,
brightness: Brightness.light,
),
darkTheme: ThemeData(
colorSchemeSeed: Colors.lightBlue,
useMaterial3: true,
brightness: Brightness.dark,
),
home: const MyHomePage(),
debugShowCheckedModeBanner: false,
);
}
}
+82
View File
@@ -0,0 +1,82 @@
import 'dart:math'; // For min function
class PeerInfo implements Comparable<PeerInfo> {
final String address;
final int latency;
final String? version;
final String role;
final String? preferredPath;
final bool isPlanet;
final bool tunneled; // 是否通过中继
PeerInfo({
required this.address,
required this.latency,
this.version,
required this.role,
this.preferredPath,
required this.tunneled,
}) : isPlanet = (role == 'PLANET');
factory PeerInfo.fromJson(Map<String, dynamic> json) {
String? bestPath;
List<dynamic> paths = json['paths'] as List<dynamic>? ?? [];
// Find the preferred active path
var preferred = paths.firstWhere(
(p) =>
p is Map<String, dynamic> &&
p['active'] == true &&
p['expired'] == false &&
p['preferred'] == true,
orElse: () => null);
if (preferred != null) {
bestPath = preferred['address'] as String?;
} else {
// If no preferred, find the first active path
var firstActive = paths.firstWhere(
(p) =>
p is Map<String, dynamic> &&
p['active'] == true &&
p['expired'] == false,
orElse: () => null);
if (firstActive != null) {
bestPath = firstActive['address'] as String?;
}
}
return PeerInfo(
address: json['address'] as String? ?? 'Unknown',
latency: json['latency'] as int,
version: json['version'] as String?,
role: json['role'] as String? ?? 'UNKNOWN',
preferredPath: bestPath,
tunneled: json['tunneled'] as bool,
);
}
// Comparison logic for sorting: PLANETs first, then by tunneled (直连优先), then by latency
@override
int compareTo(PeerInfo other) {
if (isPlanet != other.isPlanet) {
return isPlanet ? 1 : -1;
}
if (tunneled != other.tunneled) {
return tunneled ? -1 : 1;
}
// 最后按延迟排序(如果有的话)
if (latency != null && other.latency != null) {
return latency!.compareTo(other.latency!);
} else if (latency != null) {
return -1; // 有延迟信息的优先
} else if (other.latency != null) {
return 1;
}
// 如果都没有延迟信息,按地址排序
return address.compareTo(other.address);
}
}
+128
View File
@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import '../services/zerotier_service.dart';
import '../components/status_page.dart';
import '../components/network_page.dart';
import '../components/peers_page.dart';
import '../l10n/app_localizations.dart';
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final ZerotierService _zerotierService = ZerotierService();
int currentPageIndex = 0;
// Remove state related to specific page data/futures
// Future<void>? _networkFuture;
// Future<void>? _peersFuture;
@override
void initState() {
super.initState();
// No initial loading needed here anymore
}
@override
void dispose() {
// Release service resources
_zerotierService.dispose();
super.dispose();
}
// Remove all data loading and action methods
/*
Future<void> _loadStatus() async { ... }
Future<void> _loadNetworkList() async { ... }
Future<void> _loadPeers() async { ... }
Future<void> _startZeroTier() async { ... }
Future<void> _restartZeroTier() async { ... }
Future<void> _stopZeroTier() async { ... }
Future<void> _joinNetwork(String id) async { ... }
Future<void> _leaveNetwork(String id) async { ... }
*/
String _getTitle(AppLocalizations l10n) {
switch (currentPageIndex) {
case 0:
return l10n.appBarTitleStatus;
case 1:
return l10n.appBarTitleNetworks;
case 2:
return l10n.appBarTitlePeers;
default:
return 'ZeroTier';
}
}
Widget _getCurrentPage() {
switch (currentPageIndex) {
case 0:
// Pass only the service
return StatusPage(
zerotierService: _zerotierService,
// Remove callbacks
// onRefreshStatus: _loadStatus,
// startFunction: _startZeroTier,
// restartFunction: _restartZeroTier,
// stopFunction: _stopZeroTier,
);
case 1:
// Pass only the service
return NetworkPage(
zerotierService: _zerotierService,
// Remove callbacks
// onRefreshNetworkList: _loadNetworkList,
// onJoinNetwork: _joinNetwork,
// onLeaveNetwork: _leaveNetwork,
);
case 2:
// Pass only the service
return PeersPage(
zerotierService: _zerotierService,
// Remove callbacks
// onRefreshPeers: _loadPeers,
);
default:
return const Center(child: Text('Page not found'));
}
}
@override
Widget build(BuildContext context) {
// Get AppLocalizations instance
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(_getTitle(l10n))),
bottomNavigationBar: NavigationBar(
onDestinationSelected: (int index) {
setState(() {
currentPageIndex = index;
});
},
selectedIndex: currentPageIndex,
destinations: <Widget>[
NavigationDestination(
selectedIcon: const Icon(Icons.home),
icon: const Icon(Icons.home_outlined),
label: l10n.navBarLabelStatus,
),
NavigationDestination(
selectedIcon: const Icon(Icons.router),
icon: const Icon(Icons.router_outlined),
label: l10n.navBarLabelNetworks,
),
NavigationDestination(
selectedIcon: const Icon(Icons.people_alt),
icon: const Icon(Icons.people_alt_outlined),
label: l10n.navBarLabelPeers,
),
],
),
body: _getCurrentPage(),
);
}
}
+120
View File
@@ -0,0 +1,120 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:developer' as developer;
/// 带有ZeroTier认证的HTTP客户端
class AuthedDioClient {
final String _authToken;
final Dio _dio;
/// 创建带有ZeroTier认证的HTTP客户端
///
/// [authToken] - ZeroTier认证令牌
/// [baseUrl] - API基础URL
AuthedDioClient(this._authToken, String baseUrl) : _dio = Dio(BaseOptions(
baseUrl: 'http://$baseUrl',
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
)) {
// 添加拦截器以处理身份验证
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
// 添加ZeroTier特定认证头
options.headers['X-ZT1-Auth'] = _authToken;
return handler.next(options);
},
));
}
/// 获取底层的Dio实例
Dio get dio => _dio;
/// 发送GET请求
Future<Response> get(String path) {
return _dio.get(path);
}
/// 发送POST请求
Future<Response> post(String path, {dynamic data}) {
return _dio.post(path, data: data);
}
/// 发送PUT请求
Future<Response> put(String path, {dynamic data}) {
return _dio.put(path, data: data);
}
/// 发送DELETE请求
Future<Response> delete(String path) {
return _dio.delete(path);
}
/// 关闭客户端
void close() {
_dio.close();
}
}
/// ZeroTier认证服务,用于管理认证和客户端创建
class AuthService {
static const String API_HOST = 'localhost:9993';
/// 缓存的HTTP客户端实例
AuthedDioClient? _cachedClient;
/// 获取已认证的HTTP客户端
Future<AuthedDioClient> get client async {
// 如果我们已经有缓存的客户端,直接返回
if (_cachedClient != null) {
return _cachedClient!;
}
try {
final authToken = await _loadAuthToken();
_cachedClient = AuthedDioClient(authToken, API_HOST);
return _cachedClient!;
} catch (e) {
developer.log('加载认证令牌失败', error: e.toString());
rethrow;
}
}
/// 清理资源
void dispose() {
if (_cachedClient != null) {
_cachedClient!.close();
_cachedClient = null;
}
}
/// 加载认证令牌
Future<String> _loadAuthToken() async {
try {
final path = (await getApplicationDocumentsDirectory()).path;
final file = File('$path/run/authtoken');
// 检查文件是否存在
if (!await file.exists()) {
throw FileSystemException('认证令牌文件未找到', file.path);
}
final token = await file.readAsString();
return token.trim();
} catch (e) {
developer.log('读取认证令牌失败', error: e.toString());
rethrow;
}
}
}
/// 超时异常类
class TimeoutException implements Exception {
final String message;
TimeoutException(this.message);
@override
String toString() => message;
}
+305
View File
@@ -0,0 +1,305 @@
import 'dart:io';
import 'dart:developer' as developer;
import 'package:path_provider/path_provider.dart';
import 'package:dio/dio.dart';
import 'auth_service.dart';
import '../models/peer_info.dart';
/// ZeroTier服务状态详情
class ZerotierStatus {
final String address;
final String version;
final bool online;
final int primaryPort;
final List<String> listeningOn;
ZerotierStatus({
required this.address,
required this.version,
required this.online,
required this.primaryPort,
required this.listeningOn,
});
/// 从JSON创建状态对象
factory ZerotierStatus.fromJson(Map<String, dynamic> json) {
// 提取监听地址列表
List<String> listeningAddresses = [];
if (json['config'] != null &&
json['config']['settings'] != null &&
json['config']['settings']['listeningOn'] != null) {
listeningAddresses = List<String>.from(json['config']['settings']['listeningOn']);
}
// 提取主端口
int port = 0;
if (json['config'] != null &&
json['config']['settings'] != null &&
json['config']['settings']['primaryPort'] != null) {
port = json['config']['settings']['primaryPort'];
}
return ZerotierStatus(
address: json['address'] ?? '',
version: json['version'] ?? '',
online: json['online'] ?? false,
primaryPort: port,
listeningOn: listeningAddresses,
);
}
}
/// ZeroTier服务,提供所有ZeroTier相关功能
class ZerotierService {
final AuthService _authService = AuthService();
// 内部成员变量,存储最后一次加载的数据
List<String> _networkList = [];
List<PeerInfo> _peersList = [];
ZerotierStatus? _statusInfo;
// 表示ZeroTier服务是否正在运行
bool runningStatus = false;
// 允许外部访问最新数据的getter方法
List<String> get networkList => _networkList;
List<PeerInfo> get peersList => _peersList;
ZerotierStatus? get statusInfo => _statusInfo;
/// 向ZeroTier服务发送命令
Future<bool> zerotierCommand(String command) async {
try {
final path = (await getApplicationDocumentsDirectory()).path;
final file = File('$path/run/pipe');
if (!await file.exists()) {
developer.log('未找到ZeroTier命令管道: ${file.path}');
runningStatus = false;
// 特定错误,表示Magisk模块未运行
throw const FileSystemException('MODULE_NOT_RUNNING');
}
await file.writeAsString(command);
developer.log('已发送ZeroTier命令: $command');
return true;
} on FileSystemException catch (e) {
if (e.message == 'MODULE_NOT_RUNNING') {
developer.log('ZeroTier Magisk模块未运行');
} else {
developer.log('未找到ZeroTier命令管道,服务可能未运行');
}
runningStatus = false;
return false;
} catch (e) {
developer.log('发送ZeroTier命令失败', error: e.toString());
return false;
}
}
/// 加载网络列表
Future<List<String>?> loadNetwork() async {
developer.log('开始加载网络列表');
try {
final client = await _authService.client;
// 使用相对路径,基础URL已在客户端中配置
final resp = await client.get('/network');
if (resp.statusCode != 200) {
throw HttpException('加载网络列表失败: ${resp.statusCode}');
}
final body = resp.data;
_networkList = List<String>.from(body.map((u) => u['id']));
developer.log('成功加载网络列表: ${_networkList.length} 个网络');
return _networkList;
} on DioException catch (e) {
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.connectionError) {
developer.log('无法连接到ZeroTier服务:服务可能未运行');
} else {
developer.log('加载网络列表失败', error: e.toString());
}
_networkList = [];
return null;
} on SocketException {
developer.log('无法连接到ZeroTier服务:服务可能未运行');
_networkList = [];
return null;
} catch (e) {
developer.log('加载网络列表失败', error: e.toString());
_networkList = [];
return null;
}
}
/// 离开指定网络
Future<bool> leaveNetwork(String id) async {
try {
final client = await _authService.client;
// 使用相对路径
final resp = await client.delete('/network/$id');
final success = resp.statusCode == 200;
if (success) {
developer.log('成功离开网络: $id');
} else {
developer.log('离开网络失败: $id, 状态码: ${resp.statusCode}');
}
return success;
} on DioException catch (e) {
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.connectionError) {
developer.log('无法连接到ZeroTier服务:服务可能未运行');
// 更新运行状态
runningStatus = false;
} else {
developer.log('离开网络错误', error: e.toString());
}
return false;
} on SocketException {
developer.log('无法连接到ZeroTier服务:服务可能未运行');
// 更新运行状态
runningStatus = false;
return false;
} catch (e) {
developer.log('离开网络错误', error: e.toString());
return false;
}
}
/// 加入指定网络
Future<bool> joinNetwork(String id) async {
try {
if (id.isEmpty) {
developer.log('无法加入空网络ID');
return false;
}
final client = await _authService.client;
// 使用相对路径
final resp = await client.put('/network/$id');
final success = resp.statusCode == 200;
if (success) {
developer.log('成功加入网络: $id');
} else {
developer.log('加入网络失败: $id, 状态码: ${resp.statusCode}');
}
return success;
} on DioException catch (e) {
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.connectionError) {
developer.log('无法连接到ZeroTier服务:服务可能未运行');
// 更新运行状态
runningStatus = false;
} else {
developer.log('加入网络错误', error: e.toString());
}
return false;
} on SocketException {
developer.log('无法连接到ZeroTier服务:服务可能未运行');
// 更新运行状态
runningStatus = false;
return false;
} catch (e) {
developer.log('加入网络错误', error: e.toString());
return false;
}
}
/// 加载 Peer 列表
Future<List<PeerInfo>?> loadPeers() async {
developer.log('开始加载 Peer 列表');
try {
final client = await _authService.client;
final resp = await client.get('/peer');
if (resp.statusCode != 200) {
throw HttpException('加载 Peer 列表失败: ${resp.statusCode}');
}
final List<dynamic> rawPeers = resp.data;
_peersList = rawPeers
.map((p) => PeerInfo.fromJson(p as Map<String, dynamic>))
.toList();
// Sort the list (PLANETs first, then LEAFs by address)
_peersList.sort();
developer.log('成功加载 Peer 列表: ${_peersList.length} 个 Peers');
return _peersList;
} on DioException catch (e) {
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.connectionError) {
developer.log('无法连接到ZeroTier服务:服务可能未运行');
} else {
developer.log('加载 Peer 列表 Dio 错误', error: e.toString(), stackTrace: e.stackTrace);
}
_peersList = [];
return null;
} on SocketException {
developer.log('无法连接到ZeroTier服务:服务可能未运行');
_peersList = [];
return null;
} catch (e, s) {
developer.log('加载 Peer 列表失败', error: e.toString(), stackTrace: s);
_peersList = [];
return null;
}
}
/// 加载ZeroTier服务状态
Future<ZerotierStatus?> loadStatus() async {
developer.log('检查ZeroTier服务状态');
Map<String, dynamic>? statusData;
try {
// 尝试获取状态信息
final client = await _authService.client;
final response = await client.get('/status');
if (response.statusCode == 200) {
statusData = response.data;
developer.log('已获取ZeroTier状态信息');
} else {
developer.log('获取ZeroTier状态信息失败: ${response.statusCode}');
statusData = null;
}
} on DioException catch (e) {
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.connectionError) {
developer.log('无法连接到ZeroTier服务:服务可能未运行');
} else {
developer.log('获取ZeroTier状态信息错误', error: e.toString());
}
statusData = null;
} on SocketException {
developer.log('无法连接到ZeroTier服务:服务可能未运行');
statusData = null;
} catch (e) {
developer.log('检查ZeroTier状态错误', error: e.toString());
statusData = null;
}
// 根据获取的状态信息更新运行状态和详情
if (statusData != null) {
runningStatus = true;
_statusInfo = ZerotierStatus.fromJson(statusData);
developer.log('ZeroTier服务状态: 运行中');
developer.log('已加载ZeroTier详细状态信息');
return _statusInfo;
} else {
runningStatus = false;
_statusInfo = null;
developer.log('ZeroTier服务状态: 未运行');
return null;
}
}
/// 释放资源
void dispose() {
_authService.dispose();
}
}
+107
View File
@@ -0,0 +1,107 @@
name: zerotier_magisk_app
description: "Controller app for zerotier-magisk"
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: '>=3.3.4 <4.0.0'
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
intl: any
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.6
dio: ^5.4.0
path_provider: ^2.1.3
quickalert: ^1.1.0
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^5.0.0
flutter_launcher_icons: ^0.14.3
launcher_name: ^1.0.2
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
generate: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/assets-and-images/#from-packages
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/custom-fonts/#from-packages
flutter_launcher_icons:
android: "launcher_icon"
ios: false
image_path: "assets/icon.png"
min_sdk_android: 21 # android min sdk min:16, default 21
launcher_name:
default: "ZeroTier for Magisk"
+20 -3
View File
@@ -28,6 +28,25 @@ with open(config_toml_path, 'w') as f:
# -------------------------------------------------------------------------------------------------------
# Patch ZeroTierOne/osdep/LinuxEthernetTap.cpp
linux_tap_path = 'ZeroTierOne/osdep/LinuxEthernetTap.cpp'
linux_tap_match1 = 'int rc = pthread_setaffinity_np(self, sizeof(cpu_set_t), &cpuset);'
linux_tap_replace1 = 'int rc = sched_setaffinity(pthread_gettid_np(self), sizeof(cpu_set_t), &cpuset);'
linux_tap_match2 = '#include <sys/utsname.h>'
linux_tap_replace2 = '#include <sys/utsname.h>\n#include <sched.h>'
with open(linux_tap_path, 'r') as file:
data = file.read()
patch_NDK = data.replace(linux_tap_match1, linux_tap_replace1).replace(linux_tap_match2, linux_tap_replace2)
with open(linux_tap_path, 'w') as file:
file.write(patch_NDK)
# -------------------------------------------------------------------------------------------------------
# Patch make-linux.mk
make_linux_path = 'ZeroTierOne/make-linux.mk'
@@ -68,8 +87,6 @@ with open(make_linux_path, 'r') as file:
osutil_path = 'ZeroTierOne/osdep/OSUtils.cpp'
with open(osutil_path, 'r') as file:
data = file.read().replace(
'/var/lib/zerotier-one', '/data/adb/zerotier/home'
)
data = file.read().replace('/var/lib/zerotier-one', '/data/adb/zerotier/home')
with open(osutil_path, 'w') as file:
file.write(data)
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

+3
View File
@@ -10,6 +10,7 @@ ui_print "- unzip"
unzip -o $ZIPFILE -x 'META-INF/*' -d $MODPATH >&2
ui_print "- create work dir"
rm -rf $ZTPATH/lib $ZTPATH/run
mkdir -p $ZTPATH/run $ZTPATH/home
mv $MODPATH/zerotier/* $ZTPATH
ln -sf $ZTPATH/zerotier-one $ZTPATH/zerotier-cli
@@ -19,10 +20,12 @@ ui_print "- link to system"
mkdir -p $MODPATH$SYSBIN
ln -sf $ZTPATH/zerotier.sh $MODPATH$SYSBIN/zerotier.sh
ln -sf $ZTPATH/zerotier-cli $MODPATH$SYSBIN/zerotier-cli
ln -sf $ZTPATH/zerotier-idtool $MODPATH$SYSBIN/zerotier-idtool
ui_print "- set file permission"
set_perm_recursive $MODPATH 0 0 0755 0644
set_perm_recursive $ZTPATH 0 0 0755 0644
set_perm $MODPATH/handle.sh 0 0 0755
set_perm $ZTPATH/zerotier.sh 0 0 0755
set_perm $ZTPATH/zerotier-one 0 0 0755
+87
View File
@@ -0,0 +1,87 @@
#!/system/bin/sh
MODDIR=${0%/*}
# load variables
. $MODDIR/lib.sh
log_cli() {
echo -e "$1" >> $ZTROOT/run/cli.out
}
log() {
t=`date +"%m-%d %H:%M:%S.%3N"`
echo -e "[$t][$$][L] $@" >> $DAEMON_LOG
log_cli "$@"
}
_stop() {
pid=`pidof zerotier-one`
if [[ $? -ne 0 ]]; then
log "zerotier-one not running"
return
fi
kill -9 $pid
# delete from ip rules
ip rule del from all lookup main pref 1
ip -6 rule del from all lookup main pref 1
if [[ $? -ne 0 ]]; then
log "kill zerotier-one failed"
return
fi
log "stopped zerotier-one"
}
_start() {
if pidof zerotier-one > /dev/null; then
log "zerotier-one already running"
else
__start
log "started zerotier-one"
fi
}
_status() {
# fake systemd lol
read pid < $ZTROOT/home/zerotier-one.pid
if pidof zerotier-one > /dev/null; then
log_cli "\033[32m●\033[0m zerotier-one.service - ZeroTier One - Global Area Networking"
log_cli " Active: \033[32mactive (running)\033[0m"
log_cli " Main PID: $pid (zerotier-one)"
else
log_cli "○ zerotier-one.service - ZeroTier One - Global Area Networking"
log_cli " Active: inactive (dead)"
log_cli " Main PID: $pid (code=exited)"
fi
}
_restart() {
_stop
__start
log "started zerotier-one"
}
# ----------------------------------------------
# call from inotifyd
# ----------------------------------------------
if [[ $# == 2 && "$1" == "w" ]]; then
read cmd < $2
rm -f $ZTROOT/run/cli.out
case "$cmd" in
"start") _start;;
"stop") _stop;;
"restart") _restart;;
"status") _status;;
*) log "unknown command $cmd";;
esac
if [[ -f "$ZTROOT/run/cli.pid" ]]; then
read cpid < $ZTROOT/run/cli.pid
rm -f $ZTROOT/run/cli.pid
kill -SIGUSR1 $cpid
fi
exit 0
fi
+22
View File
@@ -0,0 +1,22 @@
# variables
ZTROOT=/data/adb/zerotier
APPROOT=/data/data/com.eventlowop.zerotier_magisk_app/app_flutter
ZT_LOG=$ZTROOT/run/zerotier.log
DAEMON_LOG=$ZTROOT/run/daemon.log
PIPE_CLI=$ZTROOT/run/pipe
PIPE_APP=$APPROOT/run/pipe
# LD_LIBRARY_PATH for NDK
export LD_LIBRARY_PATH=/system/lib64:/data/adb/zerotier/lib
__start() {
# add main route table to lookup rules
ip rule add from all lookup main pref 1
ip -6 rule add from all lookup main pref 1
# start zerotier daemon
nohup $ZTROOT/zerotier-one -d >> $ZT_LOG 2>&1 &
}
+53 -82
View File
@@ -2,87 +2,58 @@
MODDIR=${0%/*}
ZTROOT=/data/adb/zerotier
ZTRUNTIME=$ZTROOT/run
# load variables
. $MODDIR/lib.sh
pipe=$ZTRUNTIME/pipe
ZTLOG=$ZTRUNTIME/zerotier.log
daemon_log=$ZTRUNTIME/daemon.log
cli_output=$ZTRUNTIME/cli.out
cli_pid=$ZTRUNTIME/cli.pid
log() {
t=`date +"%m-%d %H:%M:%S.%3N"`
echo -e "[$t][$$][L] $1" >> $daemon_log
echo -e "$1" >> $cli_output
}
log_cli() {
echo -e "$1" >> $cli_output
}
_stop() {
pid=`pidof zerotier-one`
if [[ $? -ne 0 ]]; then
log "zerotier-one not running"
return
fi
kill -9 $pid
if [[ $? -ne 0 ]]; then
log "kill zerotier-one failed"
return
fi
wait
log "stopped zerotier-one"
}
__start() {
$ZTROOT/zerotier-one -d >> $ZTLOG 2>&1 &
}
_start() {
if pid=`pidof zerotier-one`; then
log "zerotier-one already running pid $pid"
else
__start
pid=`pidof zerotier-one`
log "started zerotier-one pid $pid"
fi
}
_status() {
# fake systemd lol
if pid=`pidof zerotier-one`; then
log_cli "\033[32m●\033[0m zerotier-one.service - ZeroTier One - Global Area Networking"
log_cli " Active: \033[32mactive (running)\033[0m"
log_cli " Main PID: $pid (zerotier-one)"
else
pid_=`cat $zerotier_root/home/zerotier-one.pid`
log_cli "○ zerotier-one.service - ZeroTier One - Global Area Networking"
log_cli " Active: inactive (dead)"
log_cli " Main PID: $pid_ (code=exited)"
fi
}
cd $ZTROOT
rm -f $ZTRUNTIME
mkfifo $pipe
ip rule add from all lookup main pref 1
export LD_LIBRARY_PATH=/data/adb/zerotier/lib
__start
while true
do
if read cmd < $pipe; then
case "$cmd" in
"start") _start;;
"stop") _stop;;
"restart") _stop; _start;;
"status") _status;;
*) log "unknown command $cmd";;
esac
cpid=`cat $cli_pid`
kill -SIGUSR1 $cpid;
fi
# wait_until_login from yc9559/uperf at uperf/magisk/script/libcommon.sh
wait_until_login() {
# in case of /data encryption is disabled
while [ "$(getprop sys.boot_completed)" != "1" ]; do
sleep 1
done
# we doesn't have the permission to rw "/sdcard" before the user unlocks the screen
local test_file="/sdcard/Android/.PERMISSION_TEST"
true >"$test_file"
while [ ! -f "$test_file" ]; do
true >"$test_file"
sleep 1
done
rm "$test_file"
}
# ----------------------------------------------
# clean before start
# ----------------------------------------------
rm -rf $ZTROOT/run
mkdir -p $ZTROOT/run
# ----------------------------------------------
# start zerotier
# ----------------------------------------------
__start
# ----------------------------------------------
# CLI before login
# ----------------------------------------------
touch $PIPE_CLI
inotifyd $MODDIR/handle.sh $PIPE_CLI:w &>/dev/null & # toybox inotifyd bug: needs an extra character after colon
# ----------------------------------------------
# APP after login
# ----------------------------------------------
wait_until_login
if [[ -d "$APPROOT" ]]; then
rm -rf $APPROOT/run
mkdir -p $APPROOT/run
cp $ZTROOT/home/authtoken.secret $APPROOT/run/authtoken
touch $PIPE_APP
chmod 666 $APPROOT/run/authtoken $PIPE_APP
inotifyd $MODDIR/handle.sh $PIPE_APP:w &>/dev/null &
fi
+8 -10
View File
@@ -1,26 +1,24 @@
#!/system/bin/sh
pipe=/data/adb/zerotier/run/pipe
cli_output=/data/adb/zerotier/run/cli.out
cli_pid=/data/adb/zerotier/run/cli.pid
ZTROOT=/data/adb/zerotier
PIPE=$ZTROOT/run/pipe
help_text="Usage: zerotier.sh {start|stop|restart|status}"
HELP="Usage: zerotier.sh {start|stop|restart|status}"
if [[ ! -p $pipe ]]; then
if [[ ! -f $PIPE ]]; then
echo "daemon not running"
exit 1
fi
rm -f $cli_output
echo $$ > $cli_pid
echo $$ > $ZTROOT/run/cli.pid
on_receive() {
kill -9 %%
cat $cli_output
cat $ZTROOT/run/cli.out
exit 0
}
run() {
echo $cmd > $pipe
echo $cmd > $PIPE
}
trap 'on_receive' SIGUSR1
@@ -35,7 +33,7 @@ if [[ $# -eq 1 ]]; then
*) echo "unknown command $cmd";;
esac
else
echo $help_text
echo $HELP
exit 1
fi