Files
wangjia 4f9d2d2cf3 fix(client): 补提交 stats-overhaul 漏掉的 account_api/kernel_process WIP
同 device_usage:account_api.dart(deviceUsage/usage 方法)、kernel_process.dart
及对应测试一直未提交,本地有→analyze/test 过,但不在 git→Windows 构建报
「deviceUsage isn't defined」。补齐使提交树自洽可构建。
孤儿组件 device_stat_row/metric_card 已无人引用,不提交。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:53:56 +08:00

730 lines
28 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// kernel_process.dart — 桌面端内核子进程管理(tsk_SLCsjNgtmng3 实现版)
//
// 实现说明:
// · 进程管理: dart:io Process.start
// · macOS PoC 提权: sudo(开发机需有 sudo 权限 / sudoers 白名单)
// 正式版替换为 SMJobBless 特权 Helper + 公证 (BACKLOG-11D-HELPER)
// · Clash API: http 包 HTTP 轮询;readiness 探测 GET /connections
// 统计通过 /connections 拿累计字节,差分算瞬时速率
// · 二进制解析优先级(见 _resolveBinaryPath:
// 1. 环境变量 PANGOLIN_SINGBOX_BIN
// 2. <executable dir>/sing-box (或 .exe)
// 3. <executable dir>/../../Resources/sing-box (macOS .app bundle)
// 4. <project>/app/kernel/dist/desktop/<os>-<arch>/sing-box (开发目录)
// 5. /opt/homebrew/bin/sing-box (Apple Silicon brew)
// 6. /usr/local/bin/sing-box (Intel brew / Linux)
// · 配置 Clash API 端口: 由 spawn 调用方(DesktopVpnBridge)写入配置,
// 或从配置文件读 experimental.clash_api.external_controller
//
// 生命周期:
// spawn(configPath) → 起子进程 → 等 Clash API 就绪 → emit connecting→on
// kill() → SIGTERM → 等 ≤gracePeriod → SIGKILL → emit off
// 意外退出 → emit errorUI 可一键重连)
//
// ignore_for_file: avoid_print
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:http/http.dart' as http;
import 'log.dart';
import 'vpn_bridge.dart';
// ══════════════════════════════════════════════════════════════════
// Clash API 客户端
// ══════════════════════════════════════════════════════════════════
/// sing-box Clash 兼容 REST API 客户端。
///
/// baseUrl: http://127.0.0.1:<port>
/// secret : experimental.clash_api.secret 的值(Bearer Token
class ClashApiClient {
ClashApiClient({
required this.baseUrl,
this.secret = '',
http.Client? httpClient,
}) : _http = httpClient ?? http.Client();
final String baseUrl;
final String secret;
final http.Client _http;
Map<String, String> get _headers => {
if (secret.isNotEmpty) 'Authorization': 'Bearer $secret',
'Content-Type': 'application/json',
};
// ── GET /traffic ──────────────────────────────────────────────
// Clash /traffic 是 SSE 端点,推 {"up": N, "down": N}bytes/s)。
// 此实现读首帧后断开,供就绪探测或一次性快照使用。
// 持续统计用 getConnections()(普通 JSON,含 downloadTotal/uploadTotal)。
Future<Map<String, dynamic>> getTraffic() async {
final uri = Uri.parse('$baseUrl/traffic');
final request = http.Request('GET', uri);
request.headers.addAll(_headers);
request.headers['Accept'] = 'text/event-stream';
late http.StreamedResponse streamed;
try {
streamed =
await _http.send(request).timeout(const Duration(seconds: 3));
} on TimeoutException {
throw const HttpException('timeout connecting to /traffic');
}
if (streamed.statusCode != 200) {
// 读掉 body,释放连接
await streamed.stream.drain<void>();
throw HttpException('traffic: ${streamed.statusCode}');
}
final completer = Completer<Map<String, dynamic>>();
StreamSubscription<String>? sub;
sub = streamed.stream
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen(
(line) {
if (line.startsWith('data:')) {
final payload = line.substring(5).trim();
if (payload.isNotEmpty && !completer.isCompleted) {
try {
completer.complete(jsonDecode(payload) as Map<String, dynamic>);
} catch (e) {
if (!completer.isCompleted) completer.completeError(e);
}
sub?.cancel();
}
}
},
onError: (Object e) {
if (!completer.isCompleted) completer.completeError(e);
},
onDone: () {
if (!completer.isCompleted) {
completer.completeError(
const HttpException('SSE stream ended without data'),
);
}
},
cancelOnError: true,
);
return completer.future.timeout(
const Duration(seconds: 5),
onTimeout: () {
sub?.cancel();
throw TimeoutException('/traffic SSE timeout');
},
);
}
// ── GET /proxies ──────────────────────────────────────────────
Future<Map<String, dynamic>> getProxies() async {
final response = await _http
.get(Uri.parse('$baseUrl/proxies'), headers: _headers)
.timeout(const Duration(seconds: 3));
if (response.statusCode != 200) {
throw HttpException('proxies: ${response.statusCode}');
}
return jsonDecode(response.body) as Map<String, dynamic>;
}
// ── PUT /proxies/{group} ──────────────────────────────────────
Future<void> selectProxy(String group, String proxy) async {
final body = jsonEncode({'name': proxy});
final response = await _http
.put(Uri.parse('$baseUrl/proxies/$group'),
headers: _headers, body: body)
.timeout(const Duration(seconds: 3));
if (response.statusCode != 204 && response.statusCode != 200) {
throw HttpException('selectProxy: ${response.statusCode}');
}
}
// ── GET /connections ──────────────────────────────────────────
// 返回 {"downloadTotal": N, "uploadTotal": N, "connections": [...]}
// 累计字节;用于统计轮询和就绪探测。
Future<Map<String, dynamic>> getConnections() async {
final response = await _http
.get(Uri.parse('$baseUrl/connections'), headers: _headers)
.timeout(const Duration(seconds: 3));
if (response.statusCode != 200) {
throw HttpException('connections: ${response.statusCode}');
}
return jsonDecode(response.body) as Map<String, dynamic>;
}
// ── DELETE /connections ───────────────────────────────────────
Future<void> closeAllConnections() async {
final response = await _http
.delete(Uri.parse('$baseUrl/connections'), headers: _headers)
.timeout(const Duration(seconds: 3));
if (response.statusCode != 204 && response.statusCode != 200) {
throw HttpException('closeConnections: ${response.statusCode}');
}
}
// ── GET /group/<name>/delay ───────────────────────────────────
// 触发一次 URLTest 延迟测试并返回结果 {tag: delayMs}。
// 注意:此接口会发起真实网络探测,耗时约 timeoutMs。
// 若只需读缓存延迟,使用 getProxies() + extractUrltestResults()。
Future<Map<String, int>> getGroupDelay(
String groupName, {
String testUrl = 'http://www.gstatic.com/generate_204',
int timeoutMs = 3000,
}) async {
final uri = Uri.parse('$baseUrl/group/$groupName/delay').replace(
queryParameters: {
'url': testUrl,
'timeout': timeoutMs.toString(),
},
);
final response = await _http
.get(uri, headers: _headers)
.timeout(Duration(milliseconds: timeoutMs + 2000));
if (response.statusCode != 200) {
throw HttpException('getGroupDelay($groupName): ${response.statusCode}');
}
final data = jsonDecode(response.body);
if (data is! Map) return {};
return data.cast<String, int>();
}
// ── 工具:从 /proxies 响应提取 URLTest/Fallback 组名 ──────────
static List<String> extractUrltestGroups(Map<String, dynamic> proxiesResponse) {
final proxies = proxiesResponse['proxies'];
if (proxies is! Map) return const [];
final groups = <String>[];
for (final entry in proxies.entries) {
final p = entry.value;
if (p is! Map) continue;
final type = (p['type'] as String?) ?? '';
if (type == 'URLTest' || type == 'Fallback') {
if (entry.key is String) groups.add(entry.key as String);
}
}
return groups;
}
// ── 工具:从 /proxies 响应提取 URLTest 延迟列表 ──────────────
// 扫描所有类型为 URLTest 的出口组,提取成员节点的最新 history delay。
static List<UrltestResult> extractUrltestResults(
Map<String, dynamic> proxiesResponse) {
final proxies = proxiesResponse['proxies'];
if (proxies is! Map) return const [];
final results = <UrltestResult>[];
for (final entry in proxies.entries) {
final p = entry.value;
if (p is! Map) continue;
final type = (p['type'] as String?) ?? '';
// URLTest / Fallback 组都有 all 成员列表
if (type != 'URLTest' && type != 'Fallback') continue;
final all = p['all'];
if (all is! List) continue;
for (final memberTag in all) {
if (memberTag is! String) continue;
final member = proxies[memberTag];
if (member is! Map) continue;
final history = member['history'];
if (history is List && history.isNotEmpty) {
final last = history.last;
if (last is Map) {
final delay = (last['delay'] as num?)?.toInt() ?? -1;
results.add(UrltestResult(tag: memberTag, delayMs: delay));
}
}
}
}
return results;
}
void dispose() => _http.close();
}
// ══════════════════════════════════════════════════════════════════
// KernelProcess 接口
// ══════════════════════════════════════════════════════════════════
/// 桌面端内核子进程管理接口。
abstract class KernelProcess {
/// 启动内核子进程。[configPath] 为 sing-box JSON 配置文件绝对路径。
/// 返回的 Future 在进程就绪(Clash REST API 可用)后 complete。
Future<void> spawn(String configPath);
/// 停止内核子进程。先 SIGTERM[gracePeriod] 内未退出则 SIGKILL。
Future<void> kill({Duration gracePeriod = const Duration(seconds: 5)});
/// 进程是否正在运行。
bool get isRunning;
/// Clash 兼容 REST API 客户端(仅进程运行时有效)。
ClashApiClient get clashApiClient;
/// 进程 stdout/stderr 实时日志流(调试面板用)。
Stream<String> get logStream;
/// VPN 状态事件流:connecting → on(就绪)/ off(停止)/ error(意外退出)。
Stream<VpnStatus> get statusStream;
/// 每秒流量统计帧(字节累计 + 瞬时速率)。
Stream<VpnStatsEvent> get statsStream;
}
// ══════════════════════════════════════════════════════════════════
// DesktopKernelProcess — 真实子进程实现
// ══════════════════════════════════════════════════════════════════
/// 桌面端内核子进程管理(sing-box)。
///
/// 除 [KernelProcess] 接口外,还额外暴露:
/// · [statusStream] — VPN 状态事件(connecting / on / off / error
/// · [statsStream] — 每秒流量统计帧(字节数 + 瞬时速率)
///
/// 用法(通常由 [DesktopVpnBridge] 驱动,不直接在 UI 使用):
/// ```dart
/// final kernel = DesktopKernelProcess();
/// await kernel.spawn('/tmp/pangolin/config.json');
/// // 收流量统计
/// kernel.statsStream.listen(print);
/// // 停止
/// await kernel.kill();
/// ```
class DesktopKernelProcess implements KernelProcess {
DesktopKernelProcess({
Duration readyTimeout = const Duration(seconds: 20),
Duration statsPollInterval = const Duration(seconds: 1),
this.useSudo = true,
}) : _readyTimeout = readyTimeout,
_statsPollInterval = statsPollInterval;
/// macOS/Linux PoC 时是否用 sudo 启动(TUN 接口需 root)。
/// 正式版改用 SMJobBless HelperWindows 直接以管理员启动。
final bool useSudo;
final Duration _readyTimeout;
final Duration _statsPollInterval;
Process? _process;
ClashApiClient? _clashApi;
Timer? _statsTimer;
bool _running = false;
// 上一帧累计字节(用于计算瞬时速率)
int _prevDownTotal = 0;
int _prevUpTotal = 0;
DateTime _prevPollTime = DateTime.now();
// 最新 URLTest 延迟列表(每次 stats 轮询时更新)
List<UrltestResult> _lastUrltestResults = const [];
// URLTest/Fallback 组名(从 /proxies 提取),供主动触发延迟探测。
List<String> _urltestGroups = const [];
// 轮询计数:每 _urlTestEvery 拍主动触发一次 group/delay(补偿 urltest 惰性探测)。
int _pollTick = 0;
static const int _urlTestEvery = 12;
final _statusCtrl = StreamController<VpnStatus>.broadcast();
final _logCtrl = StreamController<String>.broadcast();
final _statsCtrl = StreamController<VpnStatsEvent>.broadcast();
// ── 公开属性 ─────────────────────────────────────────────────
@override
bool get isRunning => _running;
@override
ClashApiClient get clashApiClient {
if (_clashApi == null) throw StateError('kernel not running');
return _clashApi!;
}
@override
Stream<String> get logStream => _logCtrl.stream;
/// VPN 状态事件流(connecting / on / off / error)。
@override
Stream<VpnStatus> get statusStream => _statusCtrl.stream;
/// 每秒流量统计帧。
@override
Stream<VpnStatsEvent> get statsStream => _statsCtrl.stream;
// ── spawn ─────────────────────────────────────────────────────
@override
Future<void> spawn(String configPath) async {
if (_running) throw StateError('DesktopKernelProcess already running');
// 1. 解析二进制路径
final binPath = await _resolveBinaryPath();
_log('binary: $binPath');
// Windows: sing-box loads wintun.dll (from its own directory) to create the
// TUN adapter. Warn early if it is missing so a failed connect is diagnosable
// rather than surfacing as an opaque sing-box error.
if (Platform.isWindows) {
final wintun = File('${File(binPath).parent.path}\\wintun.dll');
if (!await wintun.exists()) {
_log('[kernel] WARNING: wintun.dll not found next to sing-box.exe '
'(${wintun.path}); TUN will fail to start. Run '
'app/kernel/fetch-desktop-bin.sh windows amd64.');
}
}
// 2. 读取配置,提取 Clash API 地址和 secret
final clashEndpoint = await _parseClashEndpoint(configPath);
final clashUrl = 'http://${clashEndpoint.$1}';
final clashSecret = clashEndpoint.$2;
_log('clash_api: $clashUrl (secret=${clashSecret.isNotEmpty})');
// 3. 初始化 ClashApiClient(进程启动前先建好,便于后续就绪检测)
_clashApi = ClashApiClient(baseUrl: clashUrl, secret: clashSecret);
// 4. 发 connecting 事件
_emitStatus(VpnStatus.connecting);
// 5. 构造命令并启动子进程
final cmd = _buildCommand(binPath, configPath);
_log('spawn: ${cmd.join(' ')}');
try {
_process = await Process.start(
cmd.first,
cmd.skip(1).toList(),
environment: Platform.environment,
// 不设 workingDirectory,让 sing-box 自行处理
);
} catch (e) {
_clashApi!.dispose();
_clashApi = null;
_emitStatus(VpnStatus.error);
throw ProcessException(cmd.first, cmd.skip(1).toList(),
'failed to start: $e');
}
_running = true;
// 6. 挂接 stdout/stderr 日志
_process!.stdout
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen((l) => _log('[stdout] $l'));
_process!.stderr
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen((l) => _log('[stderr] $l'));
// 7. 监测意外退出
_process!.exitCode.then((code) {
if (_running) {
_running = false;
_statsTimer?.cancel();
_statsTimer = null;
// 清理 Clash API 客户端,确保下次 spawn() 重建干净的客户端
_clashApi?.dispose();
_clashApi = null;
_log('[kernel] unexpected exit: code=$code');
_emitStatus(VpnStatus.error);
}
});
// 8. 等待 Clash API 就绪
try {
await _waitForClashApi();
} catch (e) {
// 超时或 API 不可用
await kill(gracePeriod: const Duration(seconds: 2));
rethrow;
}
// 9. 启动统计轮询
_startStatsPoll();
// 10. 发 on 事件
_emitStatus(VpnStatus.on);
_log('[kernel] ready');
}
// ── kill ──────────────────────────────────────────────────────
@override
Future<void> kill({Duration gracePeriod = const Duration(seconds: 5)}) async {
if (!_running && _process == null) return;
_running = false;
_statsTimer?.cancel();
_statsTimer = null;
final proc = _process;
_process = null;
if (proc != null) {
_log('[kernel] sending SIGTERM …');
proc.kill(ProcessSignal.sigterm);
try {
await proc.exitCode.timeout(gracePeriod);
_log('[kernel] exited after SIGTERM');
} on TimeoutException {
_log('[kernel] SIGTERM timeout, sending SIGKILL …');
proc.kill(ProcessSignal.sigkill);
await proc.exitCode.timeout(const Duration(seconds: 3)).catchError((_) => -1);
}
}
_clashApi?.dispose();
_clashApi = null;
_resetStats();
_emitStatus(VpnStatus.off);
_log('[kernel] stopped');
}
// ── 释放资源 ─────────────────────────────────────────────────
Future<void> dispose() async {
await kill(gracePeriod: const Duration(seconds: 3));
await _statusCtrl.close();
await _logCtrl.close();
await _statsCtrl.close();
}
// ── 内部方法 ─────────────────────────────────────────────────
void _emitStatus(VpnStatus s) {
if (!_statusCtrl.isClosed) _statusCtrl.add(s);
}
void _log(String msg) {
logLine('DesktopKernel', msg);
if (!_logCtrl.isClosed) _logCtrl.add(msg);
}
void _resetStats() {
_prevDownTotal = 0;
_prevUpTotal = 0;
_prevPollTime = DateTime.now();
_lastUrltestResults = const [];
}
// ── 就绪等待 ─────────────────────────────────────────────────
Future<void> _waitForClashApi() async {
final deadline = DateTime.now().add(_readyTimeout);
while (DateTime.now().isBefore(deadline)) {
if (!_running) throw StateError('process exited during startup');
try {
await _clashApi!.getConnections();
return; // API 已就绪
} catch (_) {
await Future<void>.delayed(const Duration(milliseconds: 250));
}
}
throw TimeoutException(
'Clash API not ready after ${_readyTimeout.inSeconds}s');
}
// ── 统计轮询 ─────────────────────────────────────────────────
void _startStatsPoll() {
_resetStats();
_prevPollTime = DateTime.now();
_statsTimer = Timer.periodic(_statsPollInterval, (_) async {
if (!_running || _statsCtrl.isClosed) return;
try {
final now = DateTime.now();
// 并行拉取连接统计与 URLTest 延迟(Best-effortURLTest 失败不影响主统计)
final connFuture = _clashApi!.getConnections();
final proxiesFuture = _clashApi!.getProxies().then((p) {
_lastUrltestResults = ClashApiClient.extractUrltestResults(p);
_urltestGroups = ClashApiClient.extractUrltestGroups(p);
}).catchError((_) {
// 读取失败静默跳过,保留上次缓存值
});
final data = await connFuture;
await proxiesFuture; // 等 URLTest 解析完再组帧
// 周期主动触发 group/delay 探测:sing-box urltest 默认惰性探测,
// 仅读 /proxies 缓存会长时间拿不到 history → 延迟空白。每 _urlTestEvery
// 拍主动打一次,fire-and-forget(不阻塞主统计帧),下一拍即读到新 history。
if ((_pollTick++ % _urlTestEvery) == 0) {
for (final g in _urltestGroups) {
unawaited(_clashApi!.getGroupDelay(g).catchError(
(_) => <String, int>{}));
}
}
final downTotal =
(data['downloadTotal'] as num?)?.toInt() ?? _prevDownTotal;
final upTotal =
(data['uploadTotal'] as num?)?.toInt() ?? _prevUpTotal;
final deltaSec =
now.difference(_prevPollTime).inMilliseconds / 1000.0;
final downSpeed = deltaSec > 0
? (downTotal - _prevDownTotal) / deltaSec
: 0.0;
final upSpeed = deltaSec > 0
? (upTotal - _prevUpTotal) / deltaSec
: 0.0;
_prevDownTotal = downTotal;
_prevUpTotal = upTotal;
_prevPollTime = now;
if (!_statsCtrl.isClosed) {
_statsCtrl.add(VpnStatsEvent(
uploadBytes: upTotal,
downloadBytes: downTotal,
uploadSpeed: upSpeed.clamp(0, double.infinity),
downloadSpeed: downSpeed.clamp(0, double.infinity),
urltestResults: _lastUrltestResults,
));
}
} catch (_) {
// 统计读取失败不影响连接状态;下次重试
}
});
}
// ── 二进制路径解析 ────────────────────────────────────────────
static Future<String> _resolveBinaryPath() async {
final envBin = Platform.environment['PANGOLIN_SINGBOX_BIN'];
if (envBin != null && envBin.isNotEmpty) {
final f = File(envBin);
if (await f.exists()) return envBin;
}
final binName = Platform.isWindows ? 'sing-box.exe' : 'sing-box';
// 可执行文件同目录
final exeDir = File(Platform.resolvedExecutable).parent;
for (final candidate in [
File('${exeDir.path}/$binName'),
// macOS .app bundle: Contents/MacOS/../Resources/sing-box
File('${exeDir.parent.path}/Resources/$binName'),
]) {
if (await candidate.exists()) return candidate.path;
}
// 开发目录: <project_root>/app/kernel/dist/desktop/<os>-<arch>/sing-box
final os = _platformOsName();
final arch = _platformArchName();
if (os != null && arch != null) {
// 往上找项目根(含 app/kernel 的目录)
Directory dir = exeDir;
for (var i = 0; i < 8; i++) {
final candidate =
File('${dir.path}/app/kernel/dist/desktop/$os-$arch/$binName');
if (await candidate.exists()) return candidate.path;
final parent = dir.parent;
if (parent.path == dir.path) break;
dir = parent;
}
}
// 系统常见安装路径兜底(Apple Silicon brew / Intel·Linux brew)
for (final fallback in const [
'/opt/homebrew/bin/sing-box',
'/usr/local/bin/sing-box',
]) {
if (await File(fallback).exists()) return fallback;
}
throw FileSystemException(
'sing-box binary not found. '
'Set PANGOLIN_SINGBOX_BIN or run: '
'app/kernel/fetch-desktop-bin.sh ${os ?? "darwin"} ${arch ?? "arm64"}',
);
}
static String? _platformOsName() {
if (Platform.isMacOS) return 'darwin';
if (Platform.isLinux) return 'linux';
if (Platform.isWindows) return 'windows';
return null;
}
static String? _platformArchName() {
// Dart 目前没有公开 CPU arch 检测;用环境变量或约定
final envArch = Platform.environment['PROCESSOR_ARCHITECTURE'] ??
Platform.environment['HOSTTYPE'] ??
'';
if (envArch.contains('arm') || envArch.contains('aarch')) return 'arm64';
// Apple Silicon Mac 的 HOSTTYPE 是 arm64Intel 是 x86_64
// 若 uname 不可用则按照 macOS 默认 arm64(M 系芯片为主)
if (Platform.isMacOS) return 'arm64';
return 'amd64';
}
// ── 命令构造 ─────────────────────────────────────────────────
List<String> _buildCommand(String binPath, String configPath) {
if (useSudo && (Platform.isMacOS || Platform.isLinux)) {
// PoC: sudo 提权,开发机需配 sudoers 白名单(无密码)或近期有 sudo 缓存。
// 正式版: SMJobBless (macOS) / polkit (Linux)
// BACKLOG-11D-HELPER: 实装特权 Helper + 公证后删除此分支
return ['sudo', binPath, 'run', '-c', configPath];
}
// Windows: 以管理员权限启动 Flutter 应用时 sing-box 自动获得管理员权
return [binPath, 'run', '-c', configPath];
}
// ── 配置文件解析 ──────────────────────────────────────────────
/// 读取 configPath,提取 experimental.clash_api.external_controller 和 secret。
/// 返回 (controller_address, secret),如 ('127.0.0.1:51234', 'abc123')。
static Future<(String, String)> _parseClashEndpoint(String configPath) async {
final raw = await File(configPath).readAsString();
final Map<String, dynamic> json;
try {
json = jsonDecode(raw) as Map<String, dynamic>;
} catch (e) {
throw FormatException('invalid sing-box config: $e');
}
final exp = json['experimental'];
if (exp is! Map) {
return ('127.0.0.1:9090', '');
}
final api = exp['clash_api'];
if (api is! Map) {
return ('127.0.0.1:9090', '');
}
final controller =
(api['external_controller'] as String?) ?? '127.0.0.1:9090';
final secret = (api['secret'] as String?) ?? '';
return (controller, secret);
}
}
// ══════════════════════════════════════════════════════════════════
// 辅助:随机 Clash API 端口 / Secret 生成器
// ══════════════════════════════════════════════════════════════════
/// 生成用于 Clash API 的随机高位端口(4915265535)。
int generateClashApiPort() {
return 49152 + math.Random().nextInt(65535 - 49152 + 1);
}
/// 生成 32 字节(64 hex 字符)随机 Secret。
String generateClashApiSecret() {
final rng = math.Random.secure();
final bytes = List<int>.generate(32, (_) => rng.nextInt(256));
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}