Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d0a3c2ea9 | ||
|
|
dfcd8922e5 | ||
|
|
f55e880f53 | ||
|
|
e969746889 | ||
|
|
eb00b1ae35 | ||
|
|
9db350f368 | ||
|
|
bacea863bf | ||
|
|
0405277231 | ||
|
|
4191d8cbc1 | ||
|
|
999ac75324 | ||
|
|
d783b6c413 | ||
|
|
4ee668dc13 | ||
|
|
b3210882dd | ||
|
|
e2c0c3a515 | ||
|
|
19e9c26f59 | ||
|
|
9b1059acbd | ||
|
|
fa2d60e1b8 | ||
|
|
7394eee0f0 | ||
|
|
8b70f9c4a0 | ||
|
|
83e8ff3c0a | ||
|
|
bffce9e7fe | ||
|
|
5fdaf2fc5f | ||
|
|
23afcb31b8 | ||
|
|
ca7697d5d9 | ||
|
|
162014041d | ||
|
|
53e2fca297 |
@@ -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
|
||||
@@ -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/
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
After Width: | Height: | Size: 38 KiB |
@@ -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
|
||||
@@ -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)
|
||||
),
|
||||
])
|
||||
)
|
||||
);
|
||||
}
|
||||
),
|
||||
)
|
||||
),
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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, you’ll need to edit this
|
||||
/// file.
|
||||
///
|
||||
/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file.
|
||||
/// Then, in the Project Navigator, open the Info.plist file under the Runner
|
||||
/// project’s 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.'
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 => '直连';
|
||||
}
|
||||
@@ -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": "直连"
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 104 KiB |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 &
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||