diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts
index 45af68a..286c4a8 100644
--- a/android-app/app/build.gradle.kts
+++ b/android-app/app/build.gradle.kts
@@ -1,4 +1,5 @@
import org.gradle.api.tasks.Exec
+import java.io.File
import java.io.FileInputStream
import java.util.Properties
@@ -118,9 +119,42 @@ dependencies {
val buildRust by tasks.registering(Exec::class) {
group = "build"
description = "Cross-compile plane-core (Rust) into jniLibs via cargo-ndk"
- workingDir = rootProject.projectDir.parentFile // 仓库根目录(android-app 的上一级)
+ workingDir = File(rootProject.projectDir.parentFile, "plane-core")
val profile = if (project.hasProperty("rustRelease")) "release" else "debug"
- commandLine("bash", "scripts/build-rust.sh", profile)
+
+ // 用 cargo 和 cargo-ndk 的全路径,绕过 MSYS2 bash 的 HOME/PATH 问题。
+ // cargo 执行时本身会正确处理 Rust 工具链。
+ val cargoHome = (System.getenv("CARGO_HOME")
+ ?: "${System.getProperty("user.home")}\\.cargo").replace("\\", "/")
+ val cargoExe = "$cargoHome/bin/cargo"
+ // 将 cargo/bin 加入 PATH(bash 的 MSYS2 转换会破坏 /mnt/c/... 路径,
+ // 但直接传给子进程的 PATH 条目若为 C:/... 格式,MSYS2 在 exec 时也会转换。
+ // 因此改为直接用 cargo 跑 ndk 子命令,不再依赖 bash 脚本做路径发现。)
+ val abis = listOf("arm64-v8a", "armeabi-v7a", "x86_64")
+ val jniLibsDir = file("src/main/jniLibs")
+ val outDir = jniLibsDir.absolutePath.replace("\\", "/")
+ // 构建 cargo ndk 参数
+ val args = mutableListOf(cargoExe, "ndk")
+ for (abi in abis) { args.add("-t"); args.add(abi) }
+ args.add("-o"); args.add(outDir)
+ args.add("build")
+ if (profile == "release") { args.add("--release") }
+ commandLine(args)
+
+ // 传递 ANDROID_NDK_HOME
+ var ndkHome = System.getenv("ANDROID_NDK_HOME")
+ ?: System.getenv("ANDROID_SDK_ROOT")
+ ?: System.getenv("ANDROID_HOME")
+ if (ndkHome != null) {
+ val ndkDir = File(ndkHome, "ndk")
+ if (ndkDir.isDirectory) {
+ val versions = ndkDir.listFiles()?.filter { it.isDirectory }?.sortedByDescending { it.name }
+ if (!versions.isNullOrEmpty()) {
+ ndkHome = versions[0].absolutePath.replace("\\", "/")
+ }
+ }
+ environment("ANDROID_NDK_HOME", ndkHome)
+ }
}
tasks.named("preBuild") {
diff --git a/desktop/src-tauri/resources/config-templates/proxy.yml b/desktop/src-tauri/resources/config-templates/proxy.yml
new file mode 100644
index 0000000..f851505
--- /dev/null
+++ b/desktop/src-tauri/resources/config-templates/proxy.yml
@@ -0,0 +1,109 @@
+localPort: 1080
+remoteServers:
+ - host: 54.234.196.30
+ port: 9090
+ cipher: chacha20
+ cipherKey: your-cipher-key
+ ssl: false
+cluster: failover
+loadBalance: roundrobin
+timeoutMs: 8000
+connectionsPerNode: 1
+httpProxyEnabled: true
+route:
+ defaultRoute: proxy
+ proxyList:
+ - "*.google.com"
+ - "*.google.com.hk"
+ - "*.googleapis.com"
+ - "*.gstatic.com"
+ - "*.googleusercontent.com"
+ - "*.googleadservices.com"
+ - "*.googlesyndication.com"
+ - "*.googletagmanager.com"
+ - "*.googletagservices.com"
+ - "*.google-analytics.com"
+ - "*.ggpht.com"
+ - "*.github.com"
+ - github.com
+ - "*.githubusercontent.com"
+ - "*.ghcr.io"
+ - "*.stackoverflow.com"
+ - "*.stackexchange.com"
+ - "*.wikipedia.org"
+ - "*.wikimedia.org"
+ - "*.youtube.com"
+ - "*.ytimg.com"
+ - "*.googlevideo.com"
+ - "*.twitter.com"
+ - twitter.com
+ - "*.x.com"
+ - x.com
+ - "*.twimg.com"
+ - "*.openai.com"
+ - "*.chatgpt.com"
+ - "*.anthropic.com"
+ - "*.claude.ai"
+ - "*.reddit.com"
+ - "*.medium.com"
+ - "*.docker.io"
+ - "*.docker.com"
+ - "*.npmjs.org"
+ - "*.npmjs.com"
+ - "*.cloudflare.com"
+ - "*.amazonaws.com"
+ directList:
+ - "*.meituan.com"
+ - "*.sankuai.com"
+ - "*.dianping.com"
+ - "*.neixin.cn"
+ - "*.baidu.com"
+ - "*.qq.com"
+ - "*.weixin.qq.com"
+ - "*.wechat.com"
+ - "*.aliyun.com"
+ - "*.aliyuncs.com"
+ - "*.taobao.com"
+ - "*.tmall.com"
+ - "*.jd.com"
+ - "*.bilibili.com"
+ - "*.bilivideo.com"
+ - "*.hdslb.com"
+ - "*.zhihu.com"
+ - "*.163.com"
+ - "*.126.com"
+ - "*.chaoxing.com"
+ - "*.vocabgo.com"
+ - "*.apple.com"
+ - "*.icloud.com"
+ - "*.cdn-apple.com"
+ - "*.aaplimg.com"
+ - "*.tencent.com"
+ - "*.myqcloud.com"
+ - "*.gtimg.cn"
+ - "*.idqqimg.com"
+ - "*.csdn.net"
+ - "*.douyin.com"
+ - "*.bytedance.com"
+ - "*.pstatp.com"
+ - "*.snssdk.com"
+ - "*.sina.com"
+ - "*.sina.com.cn"
+ - "*.sinaimg.cn"
+ - "*.weibo.com"
+ - "*.sogou.com"
+ - "*.sogo.com"
+ - "*.360.cn"
+ - "*.so.com"
+ - "*.cnblogs.com"
+ - "*.jianshu.com"
+ - "*.gitee.com"
+ - "*.coding.net"
+ - "*.dingtalk.com"
+ - "*.feishu.cn"
+ - "*.xuexitong.com"
+ - 127.0.0.1
+ - localhost
+systemProxy:
+ enabled: false
+ host: 127.0.0.1
diff --git a/desktop/src-tauri/resources/config-templates/tun.toml b/desktop/src-tauri/resources/config-templates/tun.toml
new file mode 100644
index 0000000..cf0e357
--- /dev/null
+++ b/desktop/src-tauri/resources/config-templates/tun.toml
@@ -0,0 +1,114 @@
+# TUN Adapter 配置文件
+
+[tun]
+name = "utun9"
+address = "198.18.0.1"
+netmask = "255.254.0.0"
+mtu = 1500
+enabled = true
+
+[fakeip]
+range = "198.18.0.0/15"
+capacity = 65536
+
+[proxy]
+socks5_addr = "127.0.0.1:1080"
+health_check_interval = 5
+health_failure_threshold = 3
+
+[routing]
+default_action = "proxy"
+
+# --- 内网域名直连(最高优先级)---
+[[routing.rules]]
+type = "domain_suffix"
+value = "sankuai.com"
+action = "direct"
+
+[[routing.rules]]
+type = "domain_suffix"
+value = "meituan.com"
+action = "direct"
+
+[[routing.rules]]
+type = "domain_suffix"
+value = "sankuai.info"
+action = "direct"
+
+[[routing.rules]]
+type = "domain_suffix"
+value = "neixin.cn"
+action = "direct"
+
+[[routing.rules]]
+type = "domain_suffix"
+value = "dianping.com"
+action = "direct"
+
+[[routing.rules]]
+type = "domain_suffix"
+value = "meituan.net"
+action = "direct"
+
+# --- 通用直连规则 ---
+[[routing.rules]]
+type = "domain_suffix"
+value = "cn"
+action = "direct"
+
+[[routing.rules]]
+type = "domain_suffix"
+value = "local"
+action = "direct"
+
+[[routing.rules]]
+type = "domain_keyword"
+value = "baidu"
+action = "direct"
+
+[[routing.rules]]
+type = "domain_keyword"
+value = "aliyun"
+action = "direct"
+
+[[routing.rules]]
+type = "ip_cidr"
+value = "10.0.0.0/8"
+action = "direct"
+
+[[routing.rules]]
+type = "ip_cidr"
+value = "172.16.0.0/12"
+action = "direct"
+
+[[routing.rules]]
+type = "ip_cidr"
+value = "192.168.0.0/16"
+action = "direct"
+
+[[routing.rules]]
+type = "ip_cidr"
+value = "127.0.0.0/8"
+action = "direct"
+
+[log]
+level = "info,tun_adapter::socks5=debug"
+format = "pretty"
+
+[bypass]
+# proxy-remote 真实 IP,需要排除路由。
+# 必须与 proxy-local 的 proxy.yml 中 remoteServers[].host 保持一致,
+# 否则 proxy-local 连接 proxy-remote 的流量会被 utun9 劫持成环路,导致全网不通。
+proxy_remote_ips = ["54.234.196.30"]
+# 额外排除的网段(不进 TUN 的网段)
+extra_cidrs = ["10.0.0.0/8", "11.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
+# DNS bypass:这些 DNS 服务器的流量不走 TUN(供 proxy-local 直连做真实 DNS 解析)
+# 注意:内网 DNS (11.11.11.11/12) 已通过 extra_cidrs 中的 11.0.0.0/8 覆盖
+# 注意:必须和 start-tun.sh 的 DNS_SERVERS/DNS_FALLBACK 保持一致(114 主、223 兜底)。
+dns_bypass_ips = ["114.114.114.114", "223.5.5.5"]
+
+[intranet_dns]
+# 内网 DNS 服务器(用于解析内网域名,流量走 bypass 不经过 TUN)
+servers = ["11.11.11.11", "11.11.11.12"]
+# 需要走内网 DNS 解析的域名后缀(会在 /etc/resolver/ 下创建分流规则)
+domains = ["sankuai.com", "meituan.com", "sankuai.info", "neixin.cn", "dianping.com", "meituan.net"]
diff --git a/desktop/src-tauri/resources/jre/.gitkeep b/desktop/src-tauri/resources/jre/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/desktop/src-tauri/resources/tun-adapter b/desktop/src-tauri/resources/tun-adapter
new file mode 100755
index 0000000..f87174f
Binary files /dev/null and b/desktop/src-tauri/resources/tun-adapter differ
diff --git a/desktop/src-tauri/resources/web/app.js b/desktop/src-tauri/resources/web/app.js
new file mode 100644
index 0000000..4091803
--- /dev/null
+++ b/desktop/src-tauri/resources/web/app.js
@@ -0,0 +1,723 @@
+/**
+ * SimplePlane Desktop — Full Frontend Application
+ * 通过 Tauri IPC (window.__TAURI__.core.invoke) 与 Rust 后端通信
+ */
+const App = (function () {
+ 'use strict';
+
+ const { invoke } = window.__TAURI__.core;
+
+ let localConfig = null;
+ let originalConfigJson = '';
+ let hasChanges = false;
+ let currentSection = 'dashboard';
+ let currentLogService = 'proxy-local';
+ let statusInterval = null;
+ let logs = { 'proxy-local': [], 'tun-adapter': [], 'system': [] };
+ let presets = [];
+
+ const $ = (sel, ctx = document) => ctx.querySelector(sel);
+ const $$ = (sel, ctx = document) => [...ctx.querySelectorAll(sel)];
+
+ // ============================================================
+ // Init
+ // ============================================================
+ async function init() {
+ bindNavigation();
+ bindActions();
+ bindGlobalKeys();
+ await loadConfig();
+ await loadTunConfig();
+ await loadPresets();
+ startStatusPolling();
+ addLog('info', '应用已启动,等待连接...');
+ }
+
+ // ============================================================
+ // Navigation
+ // ============================================================
+ function bindNavigation() {
+ $$('.nav-item').forEach(item => {
+ item.addEventListener('click', () => {
+ const section = item.dataset.section;
+ switchSection(section);
+ });
+ });
+ }
+
+ function switchSection(name) {
+ currentSection = name;
+ $$('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.section === name));
+ $$('.section').forEach(s => {
+ s.classList.toggle('active', s.dataset.section === name);
+ });
+ // 切换到日志页时自动加载
+ if (name === 'logs') refreshLogView();
+ }
+
+ // ============================================================
+ // Status Polling
+ // ============================================================
+ function startStatusPolling() {
+ pollStatus();
+ statusInterval = setInterval(pollStatus, 3000);
+ }
+
+ async function pollStatus() {
+ try {
+ const st = await invoke('status');
+ updateServiceUI('proxy-local', st.proxy_status, st.proxy_port_listening);
+ updateServiceUI('tun-adapter', st.tun_status, false);
+ $('#info-proxy-port').textContent = st.proxy_port;
+ $('#info-http-port').textContent = st.http_port;
+ $('#info-mode').textContent = st.proxy_mode === 'tun' ? 'TUN 模式' : '系统代理';
+ $('#info-port-status').textContent = st.proxy_port_listening ? '监听中' : '未监听';
+
+ // 系统代理状态
+ const proxyRunning = st.proxy_status === 'running';
+ $('#systemProxyToggle').checked = proxyRunning && st.proxy_mode === 'system';
+ $('#systemProxyStatus').textContent = proxyRunning ? '已开启' : '已关闭';
+ } catch (e) {
+ // 静默
+ }
+ }
+
+ function updateServiceUI(name, status, portListening) {
+ const statusEl = $(`#status-${name}`);
+ if (!statusEl) return;
+ const indicator = $('.status-indicator', statusEl);
+ const label = $('.status-label', statusEl);
+ const card = $(`#card-${name}`);
+
+ indicator.className = 'status-indicator ' + (status === 'running' ? 'running' : status === 'starting' ? 'starting' : 'stopped');
+ const labels = { running: '运行中', starting: '启动中', stopped: '已停止', stopping: '停止中', error: '异常' };
+ label.textContent = labels[status] || status;
+ card.className = 'service-card ' + (status === 'running' ? 'card-running' : status === 'starting' ? 'card-starting' : status === 'error' ? 'card-error' : '');
+ }
+
+ // ============================================================
+ // Service Control
+ // ============================================================
+ async function startService(name) {
+ toast(`正在启动 ${name}...`, 'info');
+ addLog('info', `启动 ${name}...`);
+ try {
+ if (name === 'proxy-local') {
+ await invoke('connect', { mode: 'system' });
+ toast(`${name} 已启动`, 'success');
+ addLog('success', `${name} 已启动`);
+ } else if (name === 'tun-adapter') {
+ const result = await invoke('connect', { mode: 'tun' });
+ if (result && result !== 'connected') {
+ toast(result, 'warning');
+ addLog('warning', result);
+ } else {
+ toast(`${name} 已启动`, 'success');
+ addLog('success', `${name} 已启动`);
+ }
+ }
+ } catch (err) {
+ toast(`启动失败: ${err}`, 'error');
+ addLog('error', `${name} 启动失败: ${err}`);
+ }
+ await pollStatus();
+ }
+
+ async function stopService(name) {
+ toast(`正在停止 ${name}...`, 'info');
+ addLog('info', `停止 ${name}...`);
+ try {
+ await invoke('disconnect');
+ toast(`${name} 已停止`, 'success');
+ addLog('info', `${name} 已停止`);
+ } catch (err) {
+ toast(`停止失败: ${err}`, 'error');
+ addLog('error', `${name} 停止失败: ${err}`);
+ }
+ setTimeout(pollStatus, 1000);
+ }
+
+ async function restartService(name) {
+ toast(`正在重启 ${name}...`, 'info');
+ addLog('info', `重启 ${name}...`);
+ try {
+ await invoke('disconnect');
+ await sleep(1000);
+ await invoke('connect', { mode: 'system' });
+ toast(`${name} 已重启`, 'success');
+ addLog('success', `${name} 已重启`);
+ } catch (err) {
+ toast(`重启失败: ${err}`, 'error');
+ addLog('error', `${name} 重启失败: ${err}`);
+ }
+ setTimeout(pollStatus, 2000);
+ }
+
+ // ============================================================
+ // Quick Actions
+ // ============================================================
+ async function quickStartProxy() {
+ toast('一键代理模式: 启动 proxy-local + 开启系统代理...', 'info');
+ addLog('info', '一键代理模式启动...');
+ try {
+ await invoke('connect', { mode: 'system' });
+ toast('代理模式已激活', 'success');
+ addLog('success', '一键代理模式已激活');
+ } catch (err) {
+ toast(`启动失败: ${err}`, 'error');
+ addLog('error', `一键代理失败: ${err}`);
+ }
+ await pollStatus();
+ }
+
+ async function quickStartTun() {
+ toast('一键 TUN 模式: 启动 proxy-local + tun-adapter...', 'info');
+ addLog('info', '一键 TUN 模式启动...');
+ try {
+ const result = await invoke('connect', { mode: 'tun' });
+ if (result && result !== 'connected') {
+ toast(result, 'warning');
+ addLog('warning', result);
+ } else {
+ toast('TUN 模式已激活', 'success');
+ addLog('success', 'TUN 模式已激活');
+ }
+ } catch (err) {
+ toast(`TUN 启动失败: ${err}`, 'error');
+ addLog('error', `一键 TUN 失败: ${err}`);
+ }
+ await pollStatus();
+ }
+
+ async function quickStopAll() {
+ toast('停止所有服务...', 'info');
+ addLog('info', '停止所有服务...');
+ try {
+ await invoke('disconnect');
+ toast('所有服务已停止', 'success');
+ addLog('info', '所有服务已停止');
+ } catch (err) {
+ toast(`停止失败: ${err}`, 'error');
+ }
+ await sleep(1000);
+ await pollStatus();
+ }
+
+ async function resetNetwork() {
+ if (!confirm('确认恢复网络?将强制还原 DNS/路由并关闭系统代理。')) return;
+ toast('正在恢复网络...', 'info');
+ addLog('info', '恢复网络...');
+ try {
+ await invoke('reset_network');
+ toast('网络已恢复', 'success');
+ addLog('success', '网络已恢复');
+ } catch (err) {
+ toast(`恢复失败: ${err}`, 'error');
+ addLog('error', `网络恢复失败: ${err}`);
+ }
+ await pollStatus();
+ }
+
+ async function toggleSystemProxy(enabled) {
+ try {
+ if (enabled) {
+ await invoke('connect', { mode: 'system' });
+ toast('系统代理已开启', 'success');
+ } else {
+ await invoke('disconnect');
+ toast('系统代理已关闭', 'success');
+ }
+ } catch (err) {
+ toast(`操作失败: ${err}`, 'error');
+ $('#systemProxyToggle').checked = !enabled;
+ }
+ await pollStatus();
+ }
+
+ // ============================================================
+ // Config
+ // ============================================================
+ async function loadConfig() {
+ try {
+ const config = await invoke('get_config');
+ localConfig = config;
+ originalConfigJson = JSON.stringify(config);
+ populateConfigForm(config);
+ } catch (e) {
+ addLog('warning', `加载配置失败: ${e}`);
+ }
+ }
+
+ function populateConfigForm(c) {
+ if (!c) return;
+ setVal('#localPort', c.local.port);
+ $('#httpProxyEnabled').checked = c.local.http_proxy_enabled;
+
+ // 远端服务器
+ renderServerCards([c.remote]);
+
+ // 路由
+ if (c.route) {
+ setVal('#defaultRoute', c.route.default_route);
+ $('#proxyList').value = (c.route.proxy_list || []).join('\n');
+ $('#directList').value = (c.route.direct_list || []).join('\n');
+ updateRouteCounts();
+ }
+ }
+
+ function collectConfig() {
+ if (!localConfig) localConfig = {};
+ localConfig.local = {
+ port: intVal('#localPort', 1080),
+ http_proxy_enabled: $('#httpProxyEnabled').checked,
+ http_proxy_port: intVal('#localPort', 1080) + 7,
+ };
+
+ // 收集服务器卡片
+ const cards = $$('.server-card', $('#remoteServers'));
+ if (cards.length > 0) {
+ const card = cards[0];
+ localConfig.remote = {
+ host: val($('[data-key="host"]', card)),
+ port: parseInt(val($('[data-key="port"]', card))) || 9090,
+ cipher: val($('[data-key="cipher"]', card)),
+ key: val($('[data-key="key"]', card)),
+ };
+ }
+
+ localConfig.route = {
+ default_route: val('#defaultRoute'),
+ proxy_list: parseLines('#proxyList'),
+ direct_list: parseLines('#directList'),
+ };
+
+ return localConfig;
+ }
+
+ async function saveAll() {
+ const configData = collectConfig();
+ try {
+ await invoke('save_config', { configData });
+ originalConfigJson = JSON.stringify(configData);
+ updateChangeState();
+ toast('配置已保存', 'success');
+ addLog('success', '配置已保存');
+ } catch (e) {
+ toast(`保存失败: ${e}`, 'error');
+ addLog('error', `保存配置失败: ${e}`);
+ }
+ }
+
+ // ============================================================
+ // Server Cards
+ // ============================================================
+ function renderServerCards(servers) {
+ const container = $('#remoteServers');
+ container.innerHTML = '';
+ (servers || []).forEach((srv, i) => container.appendChild(createServerCard(srv, i)));
+ }
+
+ function createServerCard(srv, index) {
+ const card = document.createElement('div');
+ card.className = 'server-card';
+ card.innerHTML = `
+
+
+
+
+
+
+
+
+
`;
+ $$('input, select', card).forEach(el => {
+ el.addEventListener('input', markChanged);
+ el.addEventListener('change', markChanged);
+ });
+ $('.btn-remove', card).addEventListener('click', () => { card.remove(); markChanged(); });
+ return card;
+ }
+
+ // ============================================================
+ // TUN Config (Raw TOML Editor)
+ // ============================================================
+ async function loadTunConfig() {
+ try {
+ const text = await invoke('get_tun_config_raw');
+ $('#tunConfigEditor').value = text;
+ } catch (e) {
+ // 降级:从结构化接口生成文本
+ try {
+ const config = await invoke('get_tun_config');
+ $('#tunConfigEditor').value = tunConfigToToml(config);
+ } catch (e2) {
+ $('#tunConfigEditor').value = '# 加载 TUN 配置失败: ' + e2;
+ }
+ }
+ }
+
+ function tunConfigToToml(c) {
+ let t = '# TUN Adapter 配置\n\n[tun]\n';
+ t += `name = "${c.tun.name}"\n`;
+ t += `address = "${c.tun.address}"\n`;
+ if (c.tun.netmask) t += `netmask = "${c.tun.netmask}"\n`;
+ t += `mtu = ${c.tun.mtu}\n`;
+ t += `enabled = ${c.tun.enabled}\n\n`;
+ t += '[dns]\n';
+ t += `listen = "${c.dns.listen}"\n`;
+ t += `upstream = "${c.dns.upstream}"\n\n`;
+ t += '[proxy]\n';
+ if (c.proxy.socks5) t += `socks5 = "${c.proxy.socks5}"\n`;
+ if (c.proxy.socks5_addr) t += `socks5_addr = "${c.proxy.socks5_addr}"\n`;
+ if (c.routing) {
+ t += '\n[routing]\n';
+ t += `default_action = "${c.routing.default_action}"\n`;
+ if (c.routing.rules && c.routing.rules.length > 0) {
+ t += '\n';
+ c.routing.rules.forEach(r => {
+ t += `[[routing.rules]]\ntype = "${r.rule_type || r.type}"\nvalue = "${r.value}"\naction = "${r.action}"\n\n`;
+ });
+ }
+ }
+ if (c.bypass) {
+ t += '[bypass]\n';
+ if (c.bypass.proxy_remote_ips && c.bypass.proxy_remote_ips.length > 0) {
+ t += `proxy_remote_ips = [${c.bypass.proxy_remote_ips.map(s => `"${s}"`).join(', ')}]\n`;
+ }
+ if (c.bypass.extra_cidrs && c.bypass.extra_cidrs.length > 0) {
+ t += `extra_cidrs = [${c.bypass.extra_cidrs.map(s => `"${s}"`).join(', ')}]\n`;
+ }
+ if (c.bypass.dns_bypass_ips && c.bypass.dns_bypass_ips.length > 0) {
+ t += `dns_bypass_ips = [${c.bypass.dns_bypass_ips.map(s => `"${s}"`).join(', ')}]\n`;
+ }
+ }
+ return t;
+ }
+
+ async function saveTunConfig() {
+ const content = $('#tunConfigEditor').value;
+ try {
+ await invoke('save_tun_config_raw', { content });
+ toast('TUN 配置已保存', 'success');
+ addLog('success', 'TUN 配置已保存');
+ } catch (e) {
+ toast(`保存失败: ${e}`, 'error');
+ addLog('error', `TUN 配置保存失败: ${e}`);
+ }
+ }
+
+ // ============================================================
+ // Presets (Backend-managed)
+ // ============================================================
+ async function loadPresets() {
+ try {
+ presets = await invoke('get_presets');
+ renderPresets();
+ } catch (e) {
+ // 降级到本地存储
+ try {
+ const stored = localStorage.getItem('simpleplane-presets');
+ if (stored) presets = JSON.parse(stored);
+ } catch {}
+ renderPresets();
+ }
+ }
+
+ function renderPresets() {
+ const container = $('#presetList');
+ if (!container) return;
+ if (presets.length === 0) {
+ container.innerHTML = '暂无预设,点击上方按钮保存当前配置
';
+ return;
+ }
+ container.innerHTML = '';
+ presets.forEach((p, i) => {
+ const el = document.createElement('div');
+ el.className = 'preset-item';
+ const cfg = p.config || p;
+ const remote = cfg.remote || {};
+ el.innerHTML = `
+
+ ${esc(p.name)}
+ ${esc(p.description || '')}
+ ${remote.host || ''}:${remote.port || ''} | ${remote.cipher || 'chacha20'}
+
+
+
+
+
`;
+ container.appendChild(el);
+ });
+ // 绑定按钮事件
+ $$('[data-action="apply"]', container).forEach(btn => {
+ btn.addEventListener('click', () => applyPreset(parseInt(btn.dataset.index)));
+ });
+ $$('[data-action="delete"]', container).forEach(btn => {
+ btn.addEventListener('click', () => deletePreset(parseInt(btn.dataset.index)));
+ });
+ }
+
+ async function savePreset() {
+ const name = prompt('预设名称:', `预设 ${presets.length + 1}`);
+ if (!name) return;
+ const description = prompt('预设描述 (可选):', '') || '';
+ const config = collectConfig();
+ const preset = { name, description, config };
+ try {
+ await invoke('save_preset', { preset });
+ toast(`预设 "${name}" 已保存`, 'success');
+ addLog('success', `预设已保存: ${name}`);
+ await loadPresets();
+ } catch (e) {
+ // 降级到本地存储
+ presets.push(preset);
+ localStorage.setItem('simpleplane-presets', JSON.stringify(presets));
+ renderPresets();
+ toast(`预设 "${name}" 已保存(本地)`, 'success');
+ }
+ }
+
+ async function applyPreset(index) {
+ const p = presets[index];
+ if (!p) return;
+ try {
+ await invoke('apply_preset', { name: p.name });
+ await loadConfig();
+ toast(`已加载预设 "${p.name}"`, 'success');
+ addLog('info', `已加载预设: ${p.name}`);
+ } catch (e) {
+ // 降级到直接应用
+ const cfg = p.config || p;
+ localConfig = { local: cfg.local, remote: cfg.remote, route: cfg.route };
+ populateConfigForm(localConfig);
+ markChanged();
+ toast(`已加载预设 "${p.name}" (本地)`, 'success');
+ }
+ }
+
+ async function deletePreset(index) {
+ const p = presets[index];
+ if (!p) return;
+ if (!confirm(`删除预设 "${p.name}"?`)) return;
+ try {
+ await invoke('delete_preset', { name: p.name });
+ toast('预设已删除', 'success');
+ await loadPresets();
+ } catch (e) {
+ presets.splice(index, 1);
+ localStorage.setItem('simpleplane-presets', JSON.stringify(presets));
+ renderPresets();
+ toast('预设已删除 (本地)', 'success');
+ }
+ }
+
+ // ============================================================
+ // Route
+ // ============================================================
+ function updateRouteCounts() {
+ const p = parseLines('#proxyList').length;
+ const d = parseLines('#directList').length;
+ const pc = $('#proxyListCount');
+ const dc = $('#directListCount');
+ if (pc) pc.textContent = `${p} 条`;
+ if (dc) dc.textContent = `${d} 条`;
+ }
+
+ // ============================================================
+ // Logs (实时日志流 —— 通过后端环形缓冲区增量拉取)
+ // ============================================================
+ let lastLogTimestamp = 0;
+ let logPollInterval = null;
+
+ function addLog(level, message) {
+ const entry = { timestamp: Date.now(), level, message, service: 'system' };
+ appendLogLine(entry);
+ }
+
+ function appendLogLine(entry) {
+ const container = $('#logContent');
+ if (!container) return;
+ const line = document.createElement('div');
+ line.className = 'log-line';
+ const time = new Date(entry.timestamp).toLocaleTimeString();
+ const levelClass = entry.level === 'error' ? 'log-error' : entry.level === 'warning' ? 'log-warning' : entry.level === 'success' ? 'log-success' : 'log-info';
+ line.innerHTML = `${time}[${entry.service || 'system'}]${esc(entry.message)}`;
+ container.appendChild(line);
+ autoScrollLog();
+
+ // 限制显示条数
+ while (container.children.length > 2000) container.removeChild(container.firstChild);
+ }
+
+ function autoScrollLog() {
+ if ($('#logAutoScroll') && $('#logAutoScroll').checked) {
+ const term = $('#logTerminal');
+ if (term) term.scrollTop = term.scrollHeight;
+ }
+ }
+
+ function switchLogService(service) {
+ currentLogService = service;
+ refreshLogView();
+ }
+
+ async function refreshLogView() {
+ const container = $('#logContent');
+ if (!container) return;
+ container.innerHTML = '';
+ lastLogTimestamp = 0;
+
+ try {
+ const svc = currentLogService === 'all' ? null : currentLogService;
+ const entries = await invoke('get_logs', { service: svc, count: 500 });
+ entries.forEach(e => appendLogLine(e));
+ if (entries.length > 0) {
+ lastLogTimestamp = entries[entries.length - 1].timestamp;
+ }
+ } catch (e) {
+ // 静默
+ }
+
+ // 启动增量轮询
+ startLogPolling();
+ }
+
+ function startLogPolling() {
+ if (logPollInterval) clearInterval(logPollInterval);
+ logPollInterval = setInterval(pollNewLogs, 1000);
+ }
+
+ async function pollNewLogs() {
+ if (currentSection !== 'logs') return;
+ try {
+ const svc = currentLogService === 'all' ? null : currentLogService;
+ const entries = await invoke('get_logs_stream', { since: lastLogTimestamp, service: svc });
+ if (entries && entries.length > 0) {
+ entries.forEach(e => appendLogLine(e));
+ lastLogTimestamp = entries[entries.length - 1].timestamp;
+ }
+ } catch (e) {
+ // 静默
+ }
+ }
+
+ async function clearLogs() {
+ try {
+ const svc = currentLogService === 'all' ? null : currentLogService;
+ await invoke('clear_logs', { service: svc });
+ } catch (e) {}
+ const container = $('#logContent');
+ if (container) container.innerHTML = '';
+ lastLogTimestamp = 0;
+ addLog('info', '日志已清空');
+ }
+
+ async function runDiagnose() {
+ try {
+ const results = await invoke('diagnose_tun');
+ const container = $('#logContent');
+ if (container) {
+ results.forEach(line => {
+ appendLogLine({ timestamp: Date.now(), level: 'info', service: 'diagnose', message: line });
+ });
+ }
+ toast('诊断完成,结果见日志面板', 'info');
+ } catch (e) {
+ toast(`诊断失败: ${e}`, 'error');
+ }
+ }
+
+ // ============================================================
+ // Change Detection
+ // ============================================================
+ function markChanged() {
+ collectConfig();
+ updateChangeState();
+ }
+
+ function updateChangeState() {
+ hasChanges = JSON.stringify(localConfig) !== originalConfigJson;
+ const btn = $('#btnSave');
+ const ind = $('#changeIndicator');
+ if (btn) btn.disabled = !hasChanges;
+ if (ind) ind.hidden = !hasChanges;
+ }
+
+ // ============================================================
+ // Bindings
+ // ============================================================
+ function bindActions() {
+ $('#btnSave')?.addEventListener('click', saveAll);
+ $('#btnAddServer')?.addEventListener('click', () => {
+ const idx = $$('.server-card', $('#remoteServers')).length;
+ $('#remoteServers').appendChild(createServerCard({ host: '', port: 9090, cipher: 'chacha20', key: '' }, idx));
+ markChanged();
+ });
+
+ ['#proxyList', '#directList'].forEach(sel => {
+ $(sel)?.addEventListener('input', () => { updateRouteCounts(); markChanged(); });
+ });
+
+ // 全局表单变更监测
+ document.addEventListener('input', (e) => {
+ if (e.target.closest('[data-section="proxy-config"]') || e.target.closest('[data-section="route"]')) markChanged();
+ });
+ document.addEventListener('change', (e) => {
+ if (e.target.closest('[data-section="proxy-config"]') || e.target.closest('[data-section="route"]')) markChanged();
+ });
+ }
+
+ function bindGlobalKeys() {
+ document.addEventListener('keydown', (e) => {
+ if ((e.ctrlKey || e.metaKey) && e.key === 's') {
+ e.preventDefault();
+ if (hasChanges) saveAll();
+ }
+ });
+ }
+
+ // ============================================================
+ // Toast
+ // ============================================================
+ function toast(message, type = 'info') {
+ const container = $('#toastContainer');
+ if (!container) return;
+ const el = document.createElement('div');
+ el.className = `toast ${type}`;
+ el.textContent = message;
+ container.appendChild(el);
+ setTimeout(() => { el.classList.add('leaving'); setTimeout(() => el.remove(), 200); }, 4000);
+ }
+
+ // ============================================================
+ // Helpers
+ // ============================================================
+ function val(sel) { const el = typeof sel === 'string' ? $(sel) : sel; return el ? el.value : ''; }
+ function intVal(sel, fb) { const v = parseInt(val(sel), 10); return isNaN(v) ? fb : v; }
+ function setVal(sel, v) { const el = $(sel); if (el) el.value = v ?? ''; }
+ function esc(s) { return String(s).replace(/&/g, '&').replace(//g, '>'); }
+ function escAttr(s) { return String(s).replace(/"/g, '"'); }
+ function parseLines(sel) { return val(sel).split('\n').map(s => s.trim()).filter(s => s && !s.startsWith('#')); }
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+ // ============================================================
+ // Boot
+ // ============================================================
+ document.addEventListener('DOMContentLoaded', init);
+
+ // Public API
+ return {
+ startService, stopService, restartService,
+ quickStartProxy, quickStartTun, quickStopAll, resetNetwork, toggleSystemProxy,
+ saveTunConfig, switchLogService, clearLogs, runDiagnose,
+ savePreset, applyPreset, deletePreset,
+ };
+})();
diff --git a/desktop/src-tauri/resources/web/index.html b/desktop/src-tauri/resources/web/index.html
new file mode 100644
index 0000000..c4ba665
--- /dev/null
+++ b/desktop/src-tauri/resources/web/index.html
@@ -0,0 +1,328 @@
+
+
+
+
+
+ SimplePlane
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
快捷操作
+
+
+
+
+
+
+
+
+
+
+
+ 系统代理
+ 已关闭
+
+
+
+
+
+
+
+ SOCKS5 端口
+ 1080
+
+
+ HTTP 端口
+ 1087
+
+
+ 代理模式
+ 系统代理
+
+
+ 端口状态
+ --
+
+
+
+
+
+
+
+
+
+
+
+ macOS: 首次使用 TUN 需要管理员授权。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 保存和加载不同的代理配置方案,方便在多个服务器/场景间快速切换。
+
+
+
+
+
+
+
+
+
--:--:--SimplePlane Desktop v0.1.0 就绪
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/desktop/src-tauri/resources/web/style.css b/desktop/src-tauri/resources/web/style.css
new file mode 100644
index 0000000..c7012aa
--- /dev/null
+++ b/desktop/src-tauri/resources/web/style.css
@@ -0,0 +1,609 @@
+/* SimplePlane Desktop — Full Dark Theme */
+:root {
+ --bg-primary: #0f1117;
+ --bg-secondary: #1a1d27;
+ --bg-card: #21242f;
+ --bg-hover: #282c3a;
+ --bg-input: #161922;
+ --text-primary: #e8eaed;
+ --text-secondary: #9aa0a6;
+ --text-muted: #6b7280;
+ --accent: #4f8cff;
+ --accent-hover: #3b7af0;
+ --accent-dim: rgba(79, 140, 255, 0.15);
+ --success: #34d399;
+ --success-dim: rgba(52, 211, 153, 0.15);
+ --warning: #fbbf24;
+ --warning-dim: rgba(251, 191, 36, 0.15);
+ --error: #f87171;
+ --error-dim: rgba(248, 113, 113, 0.15);
+ --border: #2d3140;
+ --border-light: #363b4d;
+ --radius: 10px;
+ --radius-sm: 6px;
+ --sidebar-width: 200px;
+ --shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+}
+
+* { margin: 0; padding: 0; box-sizing: border-box; }
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', sans-serif;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ height: 100vh;
+ overflow: hidden;
+ font-size: 14px;
+ -webkit-font-smoothing: antialiased;
+}
+
+.app { display: flex; height: 100vh; }
+
+/* ============================================================
+ Sidebar
+ ============================================================ */
+.sidebar {
+ width: var(--sidebar-width);
+ background: var(--bg-secondary);
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ padding: 16px 0;
+ -webkit-app-region: drag;
+ flex-shrink: 0;
+}
+
+.logo {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 0 16px 20px;
+ color: var(--accent);
+ font-weight: 600;
+ font-size: 15px;
+}
+
+.nav-list {
+ list-style: none;
+ flex: 1;
+ -webkit-app-region: no-drag;
+}
+
+.nav-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 9px 16px;
+ margin: 2px 8px;
+ border-radius: 7px;
+ cursor: pointer;
+ color: var(--text-secondary);
+ font-size: 13px;
+ transition: all 0.12s ease;
+}
+
+.nav-item:hover { background: var(--bg-hover); color: var(--text-primary); }
+.nav-item.active { background: var(--accent); color: white; }
+
+.nav-footer {
+ padding: 10px 16px;
+ color: var(--text-muted);
+ font-size: 11px;
+ -webkit-app-region: no-drag;
+}
+
+/* ============================================================
+ Content
+ ============================================================ */
+.content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 28px 32px;
+}
+
+.section { display: none; }
+.section.active { display: block; }
+
+.section-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 24px;
+}
+
+.section-header h2 { font-size: 20px; font-weight: 600; }
+
+.section-desc {
+ color: var(--text-secondary);
+ font-size: 13px;
+ margin-bottom: 16px;
+ line-height: 1.5;
+}
+
+.header-actions { display: flex; align-items: center; gap: 12px; }
+
+.change-indicator {
+ font-size: 12px;
+ color: var(--warning);
+ background: var(--warning-dim);
+ padding: 3px 8px;
+ border-radius: 4px;
+}
+
+.status-badge {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ color: var(--text-muted);
+}
+
+.status-badge .status-dot {
+ width: 8px; height: 8px;
+ border-radius: 50%;
+ background: var(--success);
+}
+
+.status-badge.disconnected .status-dot { background: var(--error); }
+
+/* ============================================================
+ Service Cards
+ ============================================================ */
+.service-cards {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+ gap: 14px;
+ margin-bottom: 24px;
+}
+
+.service-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 16px 20px;
+ transition: border-color 0.2s;
+}
+
+.service-card.card-running { border-color: var(--success); }
+.service-card.card-starting { border-color: var(--warning); }
+.service-card.card-error { border-color: var(--error); }
+
+.service-card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 12px;
+}
+
+.service-name {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: 500;
+ font-size: 14px;
+}
+
+.service-status {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+}
+
+.status-indicator {
+ width: 10px; height: 10px;
+ border-radius: 50%;
+ background: var(--text-muted);
+}
+
+.status-indicator.running { background: var(--success); box-shadow: 0 0 6px rgba(52, 211, 153, 0.5); }
+.status-indicator.starting { background: var(--warning); animation: pulse 1.5s infinite; }
+.status-indicator.stopped { background: var(--text-muted); }
+
+@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
+
+.service-card-body {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.service-uptime { font-size: 12px; color: var(--text-muted); }
+
+.service-actions { display: flex; gap: 6px; }
+
+/* ============================================================
+ Quick Actions
+ ============================================================ */
+.quick-actions { margin-bottom: 24px; }
+.quick-actions h3 { font-size: 14px; font-weight: 500; margin-bottom: 12px; color: var(--text-secondary); }
+
+.action-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ gap: 10px;
+}
+
+.btn-action {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 10px 14px;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text-secondary);
+ font-size: 13px;
+ cursor: pointer;
+ transition: all 0.12s ease;
+}
+
+.btn-action:hover { background: var(--bg-hover); color: var(--text-primary); border-color: var(--border-light); }
+.btn-action.action-proxy:hover { border-color: var(--accent); color: var(--accent); }
+.btn-action.action-tun:hover { border-color: var(--success); color: var(--success); }
+.btn-action.action-stop:hover { border-color: var(--warning); color: var(--warning); }
+.btn-action.action-reset:hover { border-color: var(--error); color: var(--error); }
+
+/* ============================================================
+ Proxy Toggle
+ ============================================================ */
+.proxy-toggle-section {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 12px 18px;
+ margin-bottom: 20px;
+}
+
+.proxy-toggle-info { display: flex; align-items: center; gap: 12px; font-size: 14px; }
+.proxy-toggle-status { font-size: 12px; color: var(--text-muted); }
+
+/* Toggle Switch */
+.toggle { position: relative; display: inline-flex; cursor: pointer; }
+.toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
+.toggle-track {
+ width: 40px; height: 22px;
+ background: var(--border);
+ border-radius: 11px;
+ position: relative;
+ transition: background 0.2s;
+}
+.toggle input:checked + .toggle-track { background: var(--accent); }
+.toggle-thumb {
+ position: absolute;
+ top: 2px; left: 2px;
+ width: 18px; height: 18px;
+ background: white;
+ border-radius: 50%;
+ transition: transform 0.2s;
+}
+.toggle input:checked + .toggle-track .toggle-thumb { transform: translateX(18px); }
+
+/* ============================================================
+ Info Grid
+ ============================================================ */
+.info-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: 10px;
+}
+
+.info-item {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 12px 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.info-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
+.info-value { font-size: 15px; font-weight: 500; font-family: 'SF Mono', 'JetBrains Mono', monospace; }
+
+/* ============================================================
+ Config Form
+ ============================================================ */
+.config-form { display: flex; flex-direction: column; gap: 24px; }
+
+.form-group h3 {
+ font-size: 14px;
+ font-weight: 500;
+ margin-bottom: 14px;
+ color: var(--text-primary);
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--border);
+}
+
+.form-group-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 14px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--border);
+}
+
+.form-group-header h3 { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
+
+.form-row {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ margin-bottom: 10px;
+}
+
+.form-row label {
+ width: 120px;
+ font-size: 13px;
+ color: var(--text-secondary);
+ flex-shrink: 0;
+}
+
+.input, .select {
+ flex: 1;
+ padding: 7px 11px;
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ font-size: 13px;
+ font-family: inherit;
+ outline: none;
+ transition: border-color 0.15s;
+}
+
+.input:focus, .select:focus { border-color: var(--accent); }
+
+/* ============================================================
+ Server Cards
+ ============================================================ */
+.server-cards-container { display: flex; flex-direction: column; gap: 12px; }
+
+.server-card {
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 14px;
+}
+
+.server-card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 10px;
+}
+
+.server-card-title { font-size: 13px; font-weight: 500; }
+.server-index { color: var(--accent); margin-right: 6px; }
+
+.server-fields {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ gap: 8px;
+}
+
+.server-field label {
+ display: block;
+ font-size: 11px;
+ color: var(--text-muted);
+ margin-bottom: 4px;
+}
+
+.server-field .input, .server-field .select { width: 100%; }
+
+/* ============================================================
+ Code Editor / TUN Config
+ ============================================================ */
+.tun-editor-wrap { margin-top: 12px; }
+
+.code-editor {
+ width: 100%;
+ min-height: 400px;
+ padding: 14px;
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ font-family: 'SF Mono', 'JetBrains Mono', 'Menlo', monospace;
+ font-size: 12px;
+ line-height: 1.6;
+ resize: vertical;
+ outline: none;
+ tab-size: 4;
+}
+
+.code-editor:focus { border-color: var(--accent); }
+
+/* ============================================================
+ Route Rules
+ ============================================================ */
+.route-columns {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+ margin-top: 16px;
+}
+
+.route-col-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 8px;
+}
+
+.route-col-header h3 { font-size: 13px; font-weight: 500; }
+.route-count { font-size: 11px; color: var(--text-muted); }
+
+.route-editor { min-height: 300px; }
+
+/* ============================================================
+ Presets
+ ============================================================ */
+.preset-list { display: flex; flex-direction: column; gap: 8px; }
+
+.preset-empty {
+ text-align: center;
+ padding: 40px 20px;
+ color: var(--text-muted);
+ font-size: 13px;
+}
+
+.preset-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 12px 16px;
+}
+
+.preset-info { display: flex; flex-direction: column; gap: 3px; }
+.preset-name { font-size: 14px; font-weight: 500; }
+.preset-meta { font-size: 12px; color: var(--text-muted); font-family: monospace; }
+.preset-actions { display: flex; gap: 6px; }
+
+/* ============================================================
+ Log Terminal
+ ============================================================ */
+.log-controls {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.log-controls select {
+ padding: 4px 8px;
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ color: var(--text-primary);
+ font-size: 12px;
+}
+
+.log-autoscroll {
+ font-size: 12px;
+ color: var(--text-muted);
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ cursor: pointer;
+}
+
+.log-terminal {
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 14px;
+ height: calc(100vh - 180px);
+ overflow-y: auto;
+ font-family: 'SF Mono', 'JetBrains Mono', 'Menlo', monospace;
+ font-size: 12px;
+ line-height: 1.7;
+ margin-top: 12px;
+}
+
+.log-line {
+ display: flex;
+ gap: 10px;
+ padding: 1px 0;
+}
+
+.log-time { color: var(--text-muted); flex-shrink: 0; }
+.log-text { white-space: pre-wrap; word-break: break-all; }
+.log-info { color: var(--text-secondary); }
+.log-success { color: var(--success); }
+.log-warning { color: var(--warning); }
+.log-error { color: var(--error); }
+
+/* ============================================================
+ Buttons
+ ============================================================ */
+.btn-sm {
+ padding: 5px 10px;
+ font-size: 12px;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ background: var(--bg-card);
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: all 0.12s;
+}
+
+.btn-sm:hover { background: var(--bg-hover); color: var(--text-primary); }
+.btn-sm.btn-start { border-color: var(--success); color: var(--success); }
+.btn-sm.btn-start:hover { background: var(--success-dim); }
+.btn-sm.btn-stop { border-color: var(--error); color: var(--error); }
+.btn-sm.btn-stop:hover { background: var(--error-dim); }
+.btn-sm.btn-restart { border-color: var(--accent); color: var(--accent); }
+.btn-sm.btn-restart:hover { background: var(--accent-dim); }
+.btn-sm.btn-danger { border-color: var(--error); color: var(--error); }
+.btn-sm.btn-danger:hover { background: var(--error-dim); }
+.btn-sm.btn-add { border-color: var(--accent); color: var(--accent); }
+.btn-sm.btn-add:hover { background: var(--accent-dim); }
+
+.btn-save {
+ padding: 7px 18px;
+ background: var(--accent);
+ color: white;
+ border: none;
+ border-radius: var(--radius-sm);
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 0.15s;
+}
+
+.btn-save:hover { background: var(--accent-hover); }
+.btn-save:disabled { opacity: 0.5; cursor: not-allowed; }
+
+/* ============================================================
+ Toast Notifications
+ ============================================================ */
+.toast-container {
+ position: fixed;
+ top: 16px;
+ right: 16px;
+ z-index: 9999;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ pointer-events: none;
+}
+
+.toast {
+ padding: 10px 16px;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ font-size: 13px;
+ color: var(--text-primary);
+ box-shadow: var(--shadow);
+ animation: toastIn 0.2s ease;
+ max-width: 340px;
+ pointer-events: auto;
+}
+
+.toast.success { border-left: 3px solid var(--success); }
+.toast.error { border-left: 3px solid var(--error); }
+.toast.warning { border-left: 3px solid var(--warning); }
+.toast.info { border-left: 3px solid var(--accent); }
+.toast.leaving { opacity: 0; transform: translateX(20px); transition: all 0.2s; }
+
+@keyframes toastIn {
+ from { transform: translateX(100%); opacity: 0; }
+ to { transform: translateX(0); opacity: 1; }
+}
+
+/* ============================================================
+ Scrollbar
+ ============================================================ */
+::-webkit-scrollbar { width: 5px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
+::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
diff --git a/plane-core/src/dispatcher.rs b/plane-core/src/dispatcher.rs
index 995a6df..0d40063 100644
--- a/plane-core/src/dispatcher.rs
+++ b/plane-core/src/dispatcher.rs
@@ -91,17 +91,12 @@ where
return Ok(());
};
- // FakeIP → 真实域名(反查不到则丢弃该连接)。
+ // FakeIP → 真实域名,回退到 IP 字符串(应用直连 IP 也能走代理)。
let domain = {
let engine = fake_dns.lock().await;
- engine.lookup_domain(&dst_ip).map(|s| s.to_string())
- };
- let Some(domain) = domain else {
- tracing::warn!(
- "丢弃连接:FakeIP {} 反查不到域名(src={}, port={})",
- dst_ip, src_ip, dst_port
- );
- continue;
+ engine.lookup_domain(&dst_ip)
+ .map(|s| s.to_string())
+ .unwrap_or_else(|| dst_ip.to_string())
};
tracing::info!("代理新连接: {}:{} -> {} (FakeIP {})", src_ip, dst_port, domain, dst_ip);
diff --git a/plane-core/src/jni_bridge.rs b/plane-core/src/jni_bridge.rs
index eeee381..8aa14b7 100644
--- a/plane-core/src/jni_bridge.rs
+++ b/plane-core/src/jni_bridge.rs
@@ -231,8 +231,14 @@ where
tracing::error!(error = %e, "nativeStart 失败");
0
}
- Err(_) => {
- tracing::error!("nativeStart 捕获到 panic,已隔离,返回 0");
+ Err(panic_payload) => {
+ // 尝试从 panic payload 中提取消息字符串,便于定位崩溃原因。
+ let msg: String = panic_payload
+ .downcast_ref::<&str>()
+ .map(|s| s.to_string())
+ .or_else(|| panic_payload.downcast_ref::().cloned())
+ .unwrap_or_else(|| "(无法解析 panic 消息)".to_string());
+ tracing::error!(panic_msg = %msg, "nativeStart 捕获到 panic,已隔离,返回 0");
0
}
}
@@ -324,7 +330,10 @@ fn spawn_data_plane(
config: &AndroidConfig,
) -> Result<()> {
// SAFETY: tun_fd 由 Kotlin VpnService.establish().detachFd() 移交,独占且有效。
- let tun = unsafe { AndroidTun::from_raw_fd(tun_fd, config.mtu) }?;
+ // 必须在 tokio runtime 上下文中创建 AsyncFd,否则 panic: "no reactor running"。
+ let tun = handle
+ .rt
+ .block_on(async { unsafe { AndroidTun::from_raw_fd(tun_fd, config.mtu) } })?;
let (tun_reader, tun_writer) = tun.split();
let fake_dns = std::sync::Arc::new(tokio::sync::Mutex::new(FakeDnsEngine::new(
diff --git a/plane-core/src/net_probe.rs b/plane-core/src/net_probe.rs
index fd88a62..5d37d7e 100644
--- a/plane-core/src/net_probe.rs
+++ b/plane-core/src/net_probe.rs
@@ -366,7 +366,7 @@ fn build_dns_response_packet(query: &DnsQueryInfo, dns_payload: &[u8]) -> Vec u16 {
+pub fn checksum(data: &[u8]) -> u16 {
let mut sum: u32 = 0;
let mut i = 0;
while i + 1 < data.len() {
diff --git a/plane-core/src/outbound.rs b/plane-core/src/outbound.rs
index b22b403..0deed8e 100644
--- a/plane-core/src/outbound.rs
+++ b/plane-core/src/outbound.rs
@@ -228,6 +228,46 @@ impl RequestIdGen {
}
}
+/// 全局唯一的 stream_id 生成器。
+///
+/// 服务端 `SessionManager` 以 `streamId` 为 session key,如果所有代理连接都用
+/// `streamId=0`,会导致 session 不断被覆盖(“Replacing existing session”),
+/// 并发代理全部失败。每个 stream 必须分配独立的 stream_id。
+///
+/// 生成的值采用较大的起始值加递增,避免与 Java 客户端的 stream_id 冲突。
+#[derive(Debug)]
+pub struct StreamIdGen {
+ next: AtomicI64,
+}
+
+impl Default for StreamIdGen {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl StreamIdGen {
+ /// 构造,从一个基于时间戳的唯一基数开始,避免各实例/各连接冲突。
+ pub fn new() -> Self {
+ // 用当前时间戳(毫秒)左移 20 位作为基数,高位表示时间,低位递增。
+ // 这与 Java 客户端的大数值 stream_id 类似,且不会重复。
+ let base = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_millis() as i64;
+ // 左移 20 位给足约 100万个递增空间,单次会话足够用。
+ let start = (base << 20) | 1;
+ Self {
+ next: AtomicI64::new(start),
+ }
+ }
+
+ /// 取下一个全局唯一的 stream_id。
+ pub fn next_id(&self) -> i64 {
+ self.next.fetch_add(1, Ordering::Relaxed)
+ }
+}
+
/// 出站连接 —— 一条到 proxy-remote 的 HTTP/2 连接,承载多路 stream。
///
/// 通过 [`OutboundConnection::handshake`] 在「已 protect 并 connect」的 IO 流上完成 h2 握手。
@@ -236,6 +276,8 @@ pub struct OutboundConnection {
send_request: h2::client::SendRequest,
cipher: Cipher,
req_id_gen: Arc,
+ /// 全局唯一的 stream_id 生成器,服务端以 stream_id 为 session key。
+ stream_id_gen: Arc,
config: OutboundConfig,
}
@@ -269,6 +311,7 @@ impl OutboundConnection {
send_request,
cipher,
req_id_gen: Arc::new(RequestIdGen::new()),
+ stream_id_gen: Arc::new(StreamIdGen::new()),
config,
})
}
@@ -300,7 +343,8 @@ impl OutboundConnection {
// 首个 CONNECT 消息(加密 DATA 帧)。
let request_id = self.req_id_gen.next_id();
- let connect_msg = ProxyMessage::connect(request_id, host, port);
+ let stream_id = self.stream_id_gen.next_id();
+ let connect_msg = ProxyMessage::connect(request_id, stream_id, host, port);
let frame = encode_encrypted_frame(&self.cipher, &connect_msg)?;
send_stream
.send_data(Bytes::from(frame), false)
@@ -314,10 +358,12 @@ impl OutboundConnection {
Ok(OutboundStream {
request_id,
+ stream_id,
cipher: self.cipher.clone(),
send_stream,
recv_stream,
reassembler: InboundReassembler::new(self.cipher.clone()),
+ pending: Vec::new(),
})
}
@@ -333,10 +379,15 @@ impl OutboundConnection {
/// 接收:h2 DATA 帧 → 解密 → 切包 → DATA `ProxyMessage` → 取出业务负载。
pub struct OutboundStream {
request_id: i64,
+ /// 本 stream 的唯一 stream_id,服务端以此为 session key。
+ stream_id: i64,
cipher: Cipher,
send_stream: h2::SendStream,
recv_stream: h2::RecvStream,
reassembler: InboundReassembler,
+ /// 已解密但尚未消费的消息(由 wait_for_connect_response 存入,
+ /// recv_messages 先出队这里再读网络,避免 CONNECT_RESPONSE 之外的 DATA 丢失)。
+ pending: Vec,
}
impl OutboundStream {
@@ -347,7 +398,7 @@ impl OutboundStream {
/// 发送一段业务数据(包成 DATA 类型 ProxyMessage,加密为一个 h2 DATA 帧)。
pub fn send_payload(&mut self, payload: &[u8]) -> Result<()> {
- let msg = ProxyMessage::data(self.request_id, payload);
+ let msg = ProxyMessage::data(self.request_id, self.stream_id, payload);
let frame = encode_encrypted_frame(&self.cipher, &msg)?;
self.send_stream
.send_data(Bytes::from(frame), false)
@@ -357,7 +408,7 @@ impl OutboundStream {
/// 发送 DISCONNECT 并以 end_stream 关闭发送侧。
pub fn send_disconnect(&mut self) -> Result<()> {
- let msg = ProxyMessage::disconnect(self.request_id);
+ let msg = ProxyMessage::disconnect(self.request_id, self.stream_id);
let frame = encode_encrypted_frame(&self.cipher, &msg)?;
self.send_stream
.send_data(Bytes::from(frame), true)
@@ -367,30 +418,110 @@ impl OutboundStream {
/// 读取下一批入站 [`ProxyMessage`](驱动一个 h2 DATA 帧 → 解密 → 切包)。
///
- /// 返回 `Ok(Some(msgs))`:本次帧切出的消息(可能为空,半条消息留缓冲)。
- /// 返回 `Ok(None)`:对端已结束 stream(EOF)。
+ /// 先出队 [`self.pending`] 中由 CONNECT_RESPONSE 阶段预读的积压消息,
+ /// 为空时才从 h2 stream 拉新帧。返回 `Ok(None)` 表示流已关闭。
pub async fn recv_messages(&mut self) -> Result