From bacea863bfeebb55c5a94a3d2218eff1847dc1e7 Mon Sep 17 00:00:00 2001
From: eventlOwOp <3064273390@qq.com>
Date: Sun, 6 Apr 2025 20:20:14 +0800
Subject: [PATCH] [feat] add i18n localization: en/zh; add peers display; ui
refresh
---
app/.gitignore | 2 +
.../reports/problems/problems-report.html | 663 ++++++++++++++++++
app/android/gradle.properties | 3 +
.../gradle/wrapper/gradle-wrapper.properties | 4 +-
app/android/settings.gradle | 4 +-
app/l10n.yaml | 7 +
app/lib/authed_client.dart | 39 --
app/lib/components/network_page.dart | 357 ++++++++++
app/lib/components/peers_page.dart | 250 +++++++
app/lib/components/status_page.dart | 390 +++++++++++
app/lib/l10n/app_en.arb | 125 ++++
app/lib/l10n/app_localizations.dart | 429 ++++++++++++
app/lib/l10n/app_localizations_en.dart | 176 +++++
app/lib/l10n/app_localizations_zh.dart | 176 +++++
app/lib/l10n/app_zh.arb | 125 ++++
app/lib/main.dart | 323 +--------
app/lib/models/peer_info.dart | 82 +++
app/lib/screens/home_page.dart | 128 ++++
app/lib/services/auth_service.dart | 120 ++++
app/lib/services/zerotier_service.dart | 305 ++++++++
app/pubspec.lock | 215 +++---
app/pubspec.yaml | 10 +-
22 files changed, 3487 insertions(+), 446 deletions(-)
create mode 100644 app/android/build/reports/problems/problems-report.html
create mode 100644 app/l10n.yaml
delete mode 100644 app/lib/authed_client.dart
create mode 100644 app/lib/components/network_page.dart
create mode 100644 app/lib/components/peers_page.dart
create mode 100644 app/lib/components/status_page.dart
create mode 100644 app/lib/l10n/app_en.arb
create mode 100644 app/lib/l10n/app_localizations.dart
create mode 100644 app/lib/l10n/app_localizations_en.dart
create mode 100644 app/lib/l10n/app_localizations_zh.dart
create mode 100644 app/lib/l10n/app_zh.arb
create mode 100644 app/lib/models/peer_info.dart
create mode 100644 app/lib/screens/home_page.dart
create mode 100644 app/lib/services/auth_service.dart
create mode 100644 app/lib/services/zerotier_service.dart
diff --git a/app/.gitignore b/app/.gitignore
index 6922819..61e1e40 100644
--- a/app/.gitignore
+++ b/app/.gitignore
@@ -1,3 +1,5 @@
+**/.cxx/
+
# Miscellaneous
*.class
*.log
diff --git a/app/android/build/reports/problems/problems-report.html b/app/android/build/reports/problems/problems-report.html
new file mode 100644
index 0000000..d98e180
--- /dev/null
+++ b/app/android/build/reports/problems/problems-report.html
@@ -0,0 +1,663 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Gradle Configuration Cache
+
+
+
+
+
+
+ Loading...
+
+
+
+
+
+
+
diff --git a/app/android/gradle.properties b/app/android/gradle.properties
index 598d13f..73c3168 100644
--- a/app/android/gradle.properties
+++ b/app/android/gradle.properties
@@ -1,3 +1,6 @@
org.gradle.jvmargs=-Xmx4G
android.useAndroidX=true
android.enableJetifier=true
+android.defaults.buildfeatures.buildconfig=true
+android.nonTransitiveRClass=false
+android.nonFinalResIds=false
diff --git a/app/android/gradle/wrapper/gradle-wrapper.properties b/app/android/gradle/wrapper/gradle-wrapper.properties
index e1ca574..37f853b 100644
--- a/app/android/gradle/wrapper/gradle-wrapper.properties
+++ b/app/android/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +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
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip
diff --git a/app/android/settings.gradle b/app/android/settings.gradle
index a6efc8a..1b7f201 100644
--- a/app/android/settings.gradle
+++ b/app/android/settings.gradle
@@ -19,8 +19,8 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
- id "com.android.application" version "7.3.0" apply false
- id "org.jetbrains.kotlin.android" version "1.7.10" apply false
+ id "com.android.application" version '8.10.0-beta01' apply false
+ id "org.jetbrains.kotlin.android" version "2.1.20" apply false
}
include ":app"
\ No newline at end of file
diff --git a/app/l10n.yaml b/app/l10n.yaml
new file mode 100644
index 0000000..fcf468c
--- /dev/null
+++ b/app/l10n.yaml
@@ -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
\ No newline at end of file
diff --git a/app/lib/authed_client.dart b/app/lib/authed_client.dart
deleted file mode 100644
index 32b35e7..0000000
--- a/app/lib/authed_client.dart
+++ /dev/null
@@ -1,39 +0,0 @@
-import 'dart:io';
-import 'package:http/http.dart' as http;
-import 'package:path_provider/path_provider.dart';
-
-class AuthedClientInner extends http.BaseClient {
- final String _secret;
- final http.Client _inner;
-
- AuthedClientInner(this._secret, this._inner);
-
- @override
- Future send(http.BaseRequest request) {
- request.headers['X-ZT1-Auth'] = _secret;
- return _inner.send(request);
- }
-
- // @override
- // Future post(Uri url,
- // {Map? headers, Object? body, Encoding? encoding}) {
- // Map hd = headers ?? {};
- // hd['content-type'] = 'application/json';
- // return super.post(url, headers: hd, body: body, encoding: encoding);
- // }
-}
-
-class AuthedClient {
- late Future client;
- Future loadSecret() async {
- final path = (await getApplicationDocumentsDirectory()).path;
- final file = File('$path/run/authtoken');
- final token = await file.readAsString();
-
- return AuthedClientInner(token.trim(), http.Client());
- }
-
- AuthedClient() {
- client = loadSecret();
- }
-}
diff --git a/app/lib/components/network_page.dart b/app/lib/components/network_page.dart
new file mode 100644
index 0000000..5e8857a
--- /dev/null
+++ b/app/lib/components/network_page.dart
@@ -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 createState() => _NetworkPageState();
+}
+
+class _NetworkPageState extends State {
+ final _idInputController = TextEditingController();
+ bool _isLoading = false;
+ List _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 _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 _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 _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: [
+ 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)
+ ),
+ ])
+ )
+ );
+ }
+ ),
+ )
+ ),
+ ],
+ ));
+ }
+}
\ No newline at end of file
diff --git a/app/lib/components/peers_page.dart b/app/lib/components/peers_page.dart
new file mode 100644
index 0000000..3739b2a
--- /dev/null
+++ b/app/lib/components/peers_page.dart
@@ -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 Function()? onRefreshPeers;
+
+ const PeersPage({
+ super.key,
+ required this.zerotierService,
+ // Remove callback parameter from constructor
+ // this.onRefreshPeers,
+ });
+
+ @override
+ // State createState() => _PeersPageState(); // Add createState
+ State createState() => _PeersPageState();
+}
+
+// Add State class
+class _PeersPageState extends State {
+ bool _isLoading = false;
+ // Use the PeerInfo model for the list
+ List _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 _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,
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/lib/components/status_page.dart b/app/lib/components/status_page.dart
new file mode 100644
index 0000000..494d55a
--- /dev/null
+++ b/app/lib/components/status_page.dart
@@ -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 createState() => _StatusPageState();
+}
+
+class _StatusPageState extends State {
+ 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 _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 _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: [
+ 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),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb
new file mode 100644
index 0000000..16ddc50
--- /dev/null
+++ b/app/lib/l10n/app_en.arb
@@ -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"
+}
\ No newline at end of file
diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart
new file mode 100644
index 0000000..2aae36d
--- /dev/null
+++ b/app/lib/l10n/app_localizations.dart
@@ -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(context, AppLocalizations)!;
+ }
+
+ static const LocalizationsDelegate 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> localizationsDelegates = >[
+ delegate,
+ GlobalMaterialLocalizations.delegate,
+ GlobalCupertinoLocalizations.delegate,
+ GlobalWidgetsLocalizations.delegate,
+ ];
+
+ /// A list of this localizations delegate's supported locales.
+ static const List supportedLocales = [
+ 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 {
+ const _AppLocalizationsDelegate();
+
+ @override
+ Future load(Locale locale) {
+ return SynchronousFuture(lookupAppLocalizations(locale));
+ }
+
+ @override
+ bool isSupported(Locale locale) => ['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.'
+ );
+}
diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart
new file mode 100644
index 0000000..d6d0cf3
--- /dev/null
+++ b/app/lib/l10n/app_localizations_en.dart
@@ -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';
+}
diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart
new file mode 100644
index 0000000..09cc147
--- /dev/null
+++ b/app/lib/l10n/app_localizations_zh.dart
@@ -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 => '直连';
+}
diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb
new file mode 100644
index 0000000..0063d30
--- /dev/null
+++ b/app/lib/l10n/app_zh.arb
@@ -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": "直连"
+}
\ No newline at end of file
diff --git a/app/lib/main.dart b/app/lib/main.dart
index 0fcbcdc..ec7fd95 100644
--- a/app/lib/main.dart
+++ b/app/lib/main.dart
@@ -1,14 +1,16 @@
-import 'dart:convert';
import 'package:flutter/material.dart';
-import 'dart:io';
import 'dart:developer' as developer;
-import 'package:flutter/services.dart';
-import 'package:quickalert/quickalert.dart';
-
-import './authed_client.dart';
-import 'package:path_provider/path_provider.dart';
+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());
}
@@ -19,6 +21,16 @@ class MyApp extends StatelessWidget {
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,
@@ -34,300 +46,3 @@ class MyApp extends StatelessWidget {
);
}
}
-
-class MyHomePage extends StatefulWidget {
- const MyHomePage({super.key});
-
- @override
- State createState() => _MyHomePageState();
-}
-
-Future zerotierCommand(String command) async {
- final path = (await getApplicationDocumentsDirectory()).path;
- final file = File('$path/run/pipe');
- await file.writeAsString(command);
-}
-
-class _MyHomePageState extends State {
- List networkList = [];
- bool runningStatus = false;
-
- final AuthedClient authed = AuthedClient();
-
- loadNetwork() async {
- final client = await authed.client;
- final resp = await client.get(
- Uri.http('localhost:9993', 'network'),
- );
- final body = jsonDecode(resp.body);
- networkList = List.from(body.map((u) => u['id']));
- }
-
- void leaveNetwork(String id) async {
- final client = await authed.client;
- final resp = await client.delete(Uri.http('localhost:9993', 'network/$id'));
- if (resp.statusCode != 200) {
- // failed
- return;
- }
- // success
- setState(() {
- networkList.remove(id);
- });
- }
-
- Future joinNetwork(String id) async {
- final client = await authed.client;
- final resp = await client.put(Uri.http('localhost:9993', 'network/$id'));
- if (resp.statusCode != 200) {
- // failed
- return;
- }
- // success
- if (!networkList.contains(id)) {
- setState(() {
- networkList.add(id);
- });
- }
- }
-
- Future loadStatus() async {
- final client = await authed.client;
- try {
- await client.get(Uri.http('localhost:9993'));
- setState(() => runningStatus = true);
- return;
- } on SocketException {
- setState(() => runningStatus = false);
- } catch (e) {
- developer.log('error', error: e.toString());
- // error
- }
- }
-
- late Future loadingNetworkFuture = loadNetwork();
-
- @override
- void initState() {
- super.initState();
- loadStatus();
- }
-
- Function cmdButtonWrapper(func) {
- bool doing = false;
- return () => doing
- ? null
- : () async {
- setState(() => doing = true);
- await func();
- setState(() => doing = false);
- };
- }
-
- late Function restartFn, startFn, stopFn, joinFn, leaveFn;
-
- _MyHomePageState() : super() {
- restartFn = cmdButtonWrapper(() => zerotierCommand('restart'));
- startFn = cmdButtonWrapper(() => zerotierCommand('start'));
- stopFn = cmdButtonWrapper(() => zerotierCommand('stop'));
- joinFn = cmdButtonWrapper(() => joinNetwork(_idInputController.text));
- }
-
- final _idInputController = TextEditingController();
-
- homePage({bool title = false}) {
- if (title) {
- return 'Status';
- }
- return Column(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [
- Builder(builder: (BuildContext context) {
- final color = (runningStatus ? Colors.green : Colors.red)[300];
-
- return Column(children: [
- Icon(runningStatus ? Icons.play_arrow : Icons.stop,
- color: color, size: 72),
- Row(mainAxisAlignment: MainAxisAlignment.center, children: [
- Text(runningStatus ? 'Running' : 'Stopped',
- style: TextStyle(color: color, fontSize: 18)),
- IconButton(icon: const Icon(Icons.refresh), onPressed: loadStatus)
- ])
- ]);
- }),
- IntrinsicWidth(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- mainAxisAlignment: MainAxisAlignment.center,
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- OutlinedButton.icon(
- style: const ButtonStyle(alignment: Alignment.centerLeft),
- onPressed: startFn(),
- label: const Text('Start'),
- icon: const Icon(Icons.play_arrow),
- ),
- OutlinedButton.icon(
- style: const ButtonStyle(alignment: Alignment.centerLeft),
- onPressed: restartFn(),
- label: const Text('Restart'),
- icon: const Icon(Icons.restart_alt),
- ),
- OutlinedButton.icon(
- style: const ButtonStyle(alignment: Alignment.centerLeft),
- onPressed: stopFn(),
- label: const Text('Stop'),
- icon: const Icon(Icons.stop),
- ),
- ],
- ))
- ]);
- }
-
- networkPage({bool title = false}) {
- if (title) {
- return 'Network';
- }
- return Padding(
- padding: const EdgeInsets.all(12),
- child: Column(
- children: [
- Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [
- SizedBox(
- width: 200,
- child: TextField(
- controller: _idInputController,
- textAlign: TextAlign.center,
- inputFormatters: [
- FilteringTextInputFormatter.allow(RegExp('[0-9a-f]'))
- ],
- maxLength: 16,
- decoration: const InputDecoration(
- labelText: "network id", icon: Icon(Icons.router)))),
- OutlinedButton.icon(
- onPressed: () {
- joinFn()();
- QuickAlert.show(
- context: context,
- type: QuickAlertType.success,
- text: 'Joined ${_idInputController.text}',
- );
- },
- label: const Text('Join'),
- icon: const Icon(Icons.add)),
- ]),
- const SizedBox(height: 30),
- const Divider(height: 10),
- ListTile(
- title: const Text('Networks'),
- titleTextStyle: const TextStyle(fontSize: 18),
- trailing: IconButton(
- icon: const Icon(Icons.refresh),
- onPressed: () {
- setState(() {
- loadingNetworkFuture = loadNetwork();
- });
- })),
- const SizedBox(height: 10),
- FutureBuilder(
- future: loadingNetworkFuture,
- builder: (context, snapshot) {
- if (snapshot.connectionState == ConnectionState.done) {
- if (snapshot.hasError) {
- return Text('Error: ${snapshot.error}');
- }
- return ListView.builder(
- shrinkWrap: true,
- itemCount: networkList.length,
- itemBuilder: (BuildContext ctx, int i) {
- return Card(
- child: ListTile(
- title: Text(networkList[i]),
- trailing: SizedBox(
- width: 96,
- child: Row(children: [
- IconButton(
- onPressed: () {
- Clipboard.setData(ClipboardData(
- text: networkList[i]));
- QuickAlert.show(
- context: context,
- type: QuickAlertType.success,
- text: 'Copied to clipboard!',
- );
- },
- tooltip: '复制',
- icon: const Icon(Icons.copy)),
- IconButton(
- onPressed: () => QuickAlert.show(
- context: context,
- type: QuickAlertType.confirm,
- text:
- 'Delete network ${networkList[i]} ?',
- confirmBtnText: 'Yes',
- cancelBtnText: 'No',
- confirmBtnColor: Colors.green,
- onConfirmBtnTap: () {
- leaveNetwork(networkList[i]);
- Navigator.pop(context);
- }),
- tooltip: '离开',
- icon: const Icon(Icons.close)),
- ]))));
- });
- }
- return const CircularProgressIndicator();
- },
- ),
- ],
- ));
- }
-
- peersPage({bool title = false}) {
- if (title) {
- return 'Peers';
- }
- return Container();
- }
-
- int currentPageIndex = 0;
- genWidgetList({bool title = false}) {
- return [
- homePage(title: title),
- networkPage(title: title),
- peersPage(title: title)
- ][currentPageIndex];
- }
-
- @override
- Widget build(BuildContext context) {
- return Scaffold(
- appBar: AppBar(title: Text(genWidgetList(title: true))),
- bottomNavigationBar: NavigationBar(
- onDestinationSelected: (int index) {
- setState(() {
- currentPageIndex = index;
- });
- },
- selectedIndex: currentPageIndex,
- destinations: const [
- NavigationDestination(
- selectedIcon: Icon(Icons.home),
- icon: Icon(Icons.home_outlined),
- label: 'Status',
- ),
- NavigationDestination(
- selectedIcon: Icon(Icons.router),
- icon: Icon(Icons.router_outlined),
- label: 'Network',
- ),
- NavigationDestination(
- selectedIcon: Icon(Icons.account_box),
- icon: Icon(Icons.account_box_outlined),
- label: 'Peers',
- ),
- ],
- ),
- body: Center(
- child: genWidgetList(),
- ));
- }
-}
diff --git a/app/lib/models/peer_info.dart b/app/lib/models/peer_info.dart
new file mode 100644
index 0000000..3c72e41
--- /dev/null
+++ b/app/lib/models/peer_info.dart
@@ -0,0 +1,82 @@
+import 'dart:math'; // For min function
+
+class PeerInfo implements Comparable {
+ 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 json) {
+ String? bestPath;
+ List paths = json['paths'] as List? ?? [];
+
+ // Find the preferred active path
+ var preferred = paths.firstWhere(
+ (p) =>
+ p is Map &&
+ 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 &&
+ 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);
+ }
+}
\ No newline at end of file
diff --git a/app/lib/screens/home_page.dart b/app/lib/screens/home_page.dart
new file mode 100644
index 0000000..b5e6d86
--- /dev/null
+++ b/app/lib/screens/home_page.dart
@@ -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 createState() => _MyHomePageState();
+}
+
+class _MyHomePageState extends State {
+ final ZerotierService _zerotierService = ZerotierService();
+ int currentPageIndex = 0;
+ // Remove state related to specific page data/futures
+ // Future? _networkFuture;
+ // Future? _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 _loadStatus() async { ... }
+ Future _loadNetworkList() async { ... }
+ Future _loadPeers() async { ... }
+ Future _startZeroTier() async { ... }
+ Future _restartZeroTier() async { ... }
+ Future _stopZeroTier() async { ... }
+ Future _joinNetwork(String id) async { ... }
+ Future _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: [
+ 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(),
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/lib/services/auth_service.dart b/app/lib/services/auth_service.dart
new file mode 100644
index 0000000..b225e76
--- /dev/null
+++ b/app/lib/services/auth_service.dart
@@ -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 get(String path) {
+ return _dio.get(path);
+ }
+
+ /// 发送POST请求
+ Future post(String path, {dynamic data}) {
+ return _dio.post(path, data: data);
+ }
+
+ /// 发送PUT请求
+ Future put(String path, {dynamic data}) {
+ return _dio.put(path, data: data);
+ }
+
+ /// 发送DELETE请求
+ Future 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 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 _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;
+}
\ No newline at end of file
diff --git a/app/lib/services/zerotier_service.dart b/app/lib/services/zerotier_service.dart
new file mode 100644
index 0000000..a30ea53
--- /dev/null
+++ b/app/lib/services/zerotier_service.dart
@@ -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 listeningOn;
+
+ ZerotierStatus({
+ required this.address,
+ required this.version,
+ required this.online,
+ required this.primaryPort,
+ required this.listeningOn,
+ });
+
+ /// 从JSON创建状态对象
+ factory ZerotierStatus.fromJson(Map json) {
+ // 提取监听地址列表
+ List listeningAddresses = [];
+ if (json['config'] != null &&
+ json['config']['settings'] != null &&
+ json['config']['settings']['listeningOn'] != null) {
+ listeningAddresses = List.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 _networkList = [];
+ List _peersList = [];
+ ZerotierStatus? _statusInfo;
+
+ // 表示ZeroTier服务是否正在运行
+ bool runningStatus = false;
+
+ // 允许外部访问最新数据的getter方法
+ List get networkList => _networkList;
+ List get peersList => _peersList;
+ ZerotierStatus? get statusInfo => _statusInfo;
+
+ /// 向ZeroTier服务发送命令
+ Future 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?> 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.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 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 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?> 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 rawPeers = resp.data;
+ _peersList = rawPeers
+ .map((p) => PeerInfo.fromJson(p as Map))
+ .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 loadStatus() async {
+ developer.log('检查ZeroTier服务状态');
+ Map? 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();
+ }
+}
\ No newline at end of file
diff --git a/app/pubspec.lock b/app/pubspec.lock
index a7050fe..a7569de 100644
--- a/app/pubspec.lock
+++ b/app/pubspec.lock
@@ -5,42 +5,42 @@ packages:
dependency: transitive
description:
name: archive
- sha256: ecf4273855368121b1caed0d10d4513c7241dfc813f7d3c8933b36622ae9b265
+ sha256: "7dcbd0f87fe5f61cb28da39a1a8b70dbc106e2fe0516f7836eb7bb2948481a12"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "3.5.1"
+ version: "4.0.5"
args:
dependency: transitive
description:
name: args
- sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a"
+ sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "2.5.0"
+ version: "2.7.0"
async:
dependency: transitive
description:
name: async
- sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
+ sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "2.11.0"
+ version: "2.13.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
- sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
+ sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "2.1.1"
+ version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
- sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
+ sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "1.3.0"
+ version: "1.4.0"
checked_yaml:
dependency: transitive
description:
@@ -53,34 +53,34 @@ packages:
dependency: transitive
description:
name: cli_util
- sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19
+ sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "0.4.1"
+ version: "0.4.2"
clock:
dependency: transitive
description:
name: clock
- sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
+ sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "1.1.1"
+ version: "1.1.2"
collection:
dependency: transitive
description:
name: collection
- sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
+ sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "1.18.0"
+ version: "1.19.1"
crypto:
dependency: transitive
description:
name: crypto
- sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab
+ sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "3.0.3"
+ version: "3.0.6"
cupertino_icons:
dependency: "direct main"
description:
@@ -89,22 +89,38 @@ packages:
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "1.0.8"
+ dio:
+ dependency: "direct main"
+ description:
+ name: dio
+ sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9"
+ url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
+ source: hosted
+ version: "5.8.0+1"
+ dio_web_adapter:
+ dependency: transitive
+ description:
+ name: dio_web_adapter
+ sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78"
+ url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
+ source: hosted
+ version: "2.1.1"
fake_async:
dependency: transitive
description:
name: fake_async
- sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
+ sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "1.3.1"
+ version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
- sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21"
+ sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "2.1.2"
+ version: "2.1.4"
flutter:
dependency: "direct main"
description: flutter
@@ -114,47 +130,52 @@ packages:
dependency: "direct dev"
description:
name: flutter_launcher_icons
- sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
+ sha256: bfa04787c85d80ecb3f8777bde5fc10c3de809240c48fa061a2c2bf15ea5211c
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "0.13.1"
+ version: "0.14.3"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
- sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c"
+ sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "4.0.0"
+ version: "5.0.0"
+ flutter_localizations:
+ dependency: "direct main"
+ description: flutter
+ source: sdk
+ version: "0.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
- http:
- dependency: "direct main"
- description:
- name: http
- sha256: "761a297c042deedc1ffbb156d6e2af13886bb305c2a343a4d972504cd67dd938"
- url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
- source: hosted
- version: "1.2.1"
http_parser:
dependency: transitive
description:
name: http_parser
- sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
+ sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "4.0.2"
+ version: "4.1.2"
image:
dependency: transitive
description:
name: image
- sha256: "4c68bfd5ae83e700b5204c1e74451e7bf3cf750e6843c6e158289cf56bda018e"
+ sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "4.1.7"
+ version: "4.5.4"
+ intl:
+ dependency: "direct main"
+ description:
+ name: intl
+ sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
+ url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
+ source: hosted
+ version: "0.19.0"
json_annotation:
dependency: transitive
description:
@@ -175,18 +196,18 @@ packages:
dependency: transitive
description:
name: leak_tracker
- sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a"
+ sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "10.0.4"
+ version: "10.0.8"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
- sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8"
+ sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "3.0.3"
+ version: "3.0.9"
leak_tracker_testing:
dependency: transitive
description:
@@ -199,66 +220,66 @@ packages:
dependency: transitive
description:
name: lints
- sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
+ sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "4.0.0"
+ version: "5.1.1"
matcher:
dependency: transitive
description:
name: matcher
- sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
+ sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "0.12.16+1"
+ version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
- sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a"
+ sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "0.8.0"
+ version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
- sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136"
+ sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "1.12.0"
+ version: "1.16.0"
path:
dependency: transitive
description:
name: path
- sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
+ sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "1.9.0"
+ version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
- sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161
+ sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "2.1.3"
+ version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
- sha256: a248d8146ee5983446bf03ed5ea8f6533129a12b11f12057ad1b4a67a2b3b41d
+ sha256: "0ca7359dad67fd7063cb2892ab0c0737b2daafd807cf1acecd62374c8fae6c12"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "2.2.4"
+ version: "2.2.16"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
- sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16
+ sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "2.4.0"
+ version: "2.4.1"
path_provider_linux:
dependency: transitive
description:
@@ -279,26 +300,26 @@ packages:
dependency: transitive
description:
name: path_provider_windows
- sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170"
+ sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "2.2.1"
+ version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
- sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27
+ sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "6.0.2"
+ version: "6.1.0"
platform:
dependency: transitive
description:
name: platform
- sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65"
+ sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "3.1.5"
+ version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
@@ -307,6 +328,14 @@ packages:
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "2.1.8"
+ posix:
+ dependency: transitive
+ description:
+ name: posix
+ sha256: a0117dc2167805aa9125b82eee515cc891819bac2f538c83646d355b16f58b9a
+ url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
+ source: hosted
+ version: "6.0.1"
quickalert:
dependency: "direct main"
description:
@@ -319,63 +348,63 @@ packages:
dependency: transitive
description: flutter
source: sdk
- version: "0.0.99"
+ version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
- sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
+ sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "1.10.0"
+ version: "1.10.1"
stack_trace:
dependency: transitive
description:
name: stack_trace
- sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
+ sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "1.11.1"
+ version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
- sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
+ sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "2.1.2"
+ version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
- sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde"
+ sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "1.2.0"
+ version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
- sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
+ sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "1.2.1"
+ version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
- sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f"
+ sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "0.7.0"
+ version: "0.7.4"
typed_data:
dependency: transitive
description:
name: typed_data
- sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c
+ sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "1.3.2"
+ version: "1.4.0"
vector_math:
dependency: transitive
description:
@@ -388,34 +417,26 @@ packages:
dependency: transitive
description:
name: vm_service
- sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec"
+ sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "14.2.1"
+ version: "14.3.1"
web:
dependency: transitive
description:
name: web
- sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27"
+ sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "0.5.1"
- win32:
- dependency: transitive
- description:
- name: win32
- sha256: "0eaf06e3446824099858367950a813472af675116bf63f008a4c2a75ae13e9cb"
- url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
- source: hosted
- version: "5.5.0"
+ version: "1.1.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
- sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d
+ sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "1.0.4"
+ version: "1.1.0"
xml:
dependency: transitive
description:
@@ -428,10 +449,10 @@ packages:
dependency: transitive
description:
name: yaml
- sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5"
+ sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
- version: "3.1.2"
+ version: "3.1.3"
sdks:
- dart: ">=3.3.4 <4.0.0"
- flutter: ">=3.16.6"
+ dart: ">=3.7.0 <4.0.0"
+ flutter: ">=3.27.0"
diff --git a/app/pubspec.yaml b/app/pubspec.yaml
index 9f8d668..0b51178 100644
--- a/app/pubspec.yaml
+++ b/app/pubspec.yaml
@@ -30,12 +30,15 @@ environment:
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
- http: ^1.2.1
+ dio: ^5.4.0
path_provider: ^2.1.3
quickalert: ^1.1.0
@@ -48,8 +51,8 @@ dev_dependencies:
# 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: ^4.0.0
- flutter_launcher_icons: ^0.13.1
+ 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
@@ -62,6 +65,7 @@ flutter:
# 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: