feat: add desktop app hot update channel
This commit is contained in:
@@ -0,0 +1,673 @@
|
||||
// proxy.js —— 全局 / 系统代理:内置 sing-box,把「要密码的上游 SOCKS5」转成
|
||||
// ① 系统代理:本地 mixed 端口(不要密码) + 设置 macOS/Windows 系统代理指过来;
|
||||
// ② 全局 TUN:创建虚拟网卡接管整机流量(需管理员权限,启动时弹一次密码框)。
|
||||
// macOS 系统代理不会发送用户名/密码,所以必须走本地中转——这正是本模块存在的原因。
|
||||
const { app, safeStorage } = require("electron");
|
||||
const { spawn, execFile } = require("child_process");
|
||||
const { promisify } = require("util");
|
||||
const fs = require("fs");
|
||||
const net = require("net");
|
||||
const path = require("path");
|
||||
const http = require("http");
|
||||
|
||||
const pExecFile = promisify(execFile);
|
||||
|
||||
// —— 运行时状态(内存)——
|
||||
let state = {
|
||||
mode: "off", // off | system | global
|
||||
child: null, // sing-box 子进程(系统模式) 或 osascript/powershell 提权进程(全局模式)
|
||||
localPort: 0, // 系统模式本地 mixed 端口
|
||||
services: [], // macOS:已设置系统代理的网络服务名,停止时逐一还原
|
||||
onChange: null, // 状态变化回调(通知前端 + 托盘)
|
||||
};
|
||||
|
||||
function setOnChange(cb) {
|
||||
state.onChange = cb;
|
||||
}
|
||||
function emit() {
|
||||
if (typeof state.onChange === "function") {
|
||||
try {
|
||||
state.onChange(status());
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 路径:sing-box 二进制 / 运行时目录 / 配置文件
|
||||
// ---------------------------------------------------------------------------
|
||||
function resDir() {
|
||||
// 打包后二进制在 resources/singbox;开发时在项目 resources/singbox
|
||||
return app.isPackaged
|
||||
? path.join(process.resourcesPath, "singbox")
|
||||
: path.join(__dirname, "..", "..", "resources", "singbox");
|
||||
}
|
||||
function binPath() {
|
||||
const plat = process.platform;
|
||||
let sub;
|
||||
let name = "sing-box";
|
||||
if (plat === "darwin") sub = process.arch === "arm64" ? "darwin-arm64" : "darwin-x64";
|
||||
else if (plat === "win32") {
|
||||
sub = "windows-x64";
|
||||
name = "sing-box.exe";
|
||||
} else {
|
||||
throw new Error("全局代理暂只支持 macOS 与 Windows");
|
||||
}
|
||||
return path.join(resDir(), sub, name);
|
||||
}
|
||||
function binExists() {
|
||||
try {
|
||||
return fs.existsSync(binPath());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function runtimeDir() {
|
||||
const d = path.join(app.getPath("userData"), "proxy-runtime");
|
||||
fs.mkdirSync(d, { recursive: true });
|
||||
return d;
|
||||
}
|
||||
function cfgPath() {
|
||||
return path.join(app.getPath("userData"), "proxy.json");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 配置持久化(proxyStr 用 safeStorage 加密)
|
||||
// ---------------------------------------------------------------------------
|
||||
function enc(s) {
|
||||
if (!s) return "";
|
||||
try {
|
||||
if (safeStorage.isEncryptionAvailable()) return "v1:" + safeStorage.encryptString(s).toString("base64");
|
||||
} catch {}
|
||||
return "b64:" + Buffer.from(s, "utf-8").toString("base64");
|
||||
}
|
||||
function dec(s) {
|
||||
if (!s) return "";
|
||||
try {
|
||||
if (s.startsWith("v1:")) return safeStorage.decryptString(Buffer.from(s.slice(3), "base64"));
|
||||
if (s.startsWith("b64:")) return Buffer.from(s.slice(4), "base64").toString("utf-8");
|
||||
} catch {}
|
||||
return "";
|
||||
}
|
||||
|
||||
let conf = null;
|
||||
function defaultConf() {
|
||||
return { proxyStr: "", localPort: 0, appliedPort: 0 };
|
||||
}
|
||||
function loadConf() {
|
||||
if (conf) return conf;
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(cfgPath(), "utf-8"));
|
||||
raw.proxyStr = raw.proxyStr ? dec(raw.proxyStr) : "";
|
||||
conf = { ...defaultConf(), ...raw };
|
||||
} catch {
|
||||
conf = defaultConf();
|
||||
}
|
||||
return conf;
|
||||
}
|
||||
function saveConf() {
|
||||
const out = { ...conf, proxyStr: enc(conf.proxyStr) };
|
||||
const p = cfgPath();
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, JSON.stringify(out, null, 2));
|
||||
try {
|
||||
fs.chmodSync(p, 0o600);
|
||||
} catch {}
|
||||
}
|
||||
function getConfig() {
|
||||
loadConf();
|
||||
return { proxyStr: conf.proxyStr, localPort: conf.localPort || 0 };
|
||||
}
|
||||
function setConfig({ proxyStr, localPort } = {}) {
|
||||
loadConf();
|
||||
if (proxyStr !== undefined) conf.proxyStr = String(proxyStr || "").trim();
|
||||
if (localPort !== undefined) conf.localPort = parseInt(localPort, 10) || 0;
|
||||
saveConf();
|
||||
return getConfig();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 解析上游代理字符串:支持
|
||||
// host:port:user:pass / host:port / socks5://user:pass@host:port
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseProxy(str) {
|
||||
str = String(str || "").trim();
|
||||
if (!str) return null;
|
||||
const m = str.match(/^socks5?:\/\/(?:([^:@/]+):([^@/]+)@)?([^:/@]+):(\d+)/i);
|
||||
if (m) return { host: m[3], port: parseInt(m[4], 10), user: m[1] || "", pass: m[2] || "" };
|
||||
const parts = str.split(":");
|
||||
if (parts.length === 4 && /^\d+$/.test(parts[1])) {
|
||||
return { host: parts[0], port: parseInt(parts[1], 10), user: parts[2], pass: parts[3] };
|
||||
}
|
||||
if (parts.length === 2 && /^\d+$/.test(parts[1])) {
|
||||
return { host: parts[0], port: parseInt(parts[1], 10), user: "", pass: "" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 找空闲端口(优先用用户指定端口,占用则随机取)
|
||||
// ---------------------------------------------------------------------------
|
||||
function findFreePort(preferred) {
|
||||
return new Promise((resolve) => {
|
||||
const tryListen = (port, fallback) => {
|
||||
const srv = net.createServer();
|
||||
srv.once("error", () => {
|
||||
if (fallback) tryListen(0, false);
|
||||
else resolve(0);
|
||||
});
|
||||
srv.listen(port, "127.0.0.1", () => {
|
||||
const p = srv.address().port;
|
||||
srv.close(() => resolve(p));
|
||||
});
|
||||
};
|
||||
tryListen(preferred || 0, !!preferred);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sing-box 配置生成
|
||||
// ---------------------------------------------------------------------------
|
||||
function outbounds(p) {
|
||||
const proxy = { type: "socks", tag: "proxy", server: p.host, server_port: p.port, version: "5" };
|
||||
if (p.user) {
|
||||
proxy.username = p.user;
|
||||
proxy.password = p.pass;
|
||||
}
|
||||
return [proxy, { type: "direct", tag: "direct" }];
|
||||
}
|
||||
function systemConfig(p, port) {
|
||||
return {
|
||||
log: { level: "warn" },
|
||||
inbounds: [{ type: "mixed", tag: "mixed-in", listen: "127.0.0.1", listen_port: port }],
|
||||
outbounds: outbounds(p),
|
||||
route: { final: "proxy" },
|
||||
};
|
||||
}
|
||||
function globalConfig(p, logPath) {
|
||||
return {
|
||||
// 让 sing-box 直接把日志写到文件(UTF-8),不依赖 shell 重定向 → 中文路径无损、判断启动可靠
|
||||
log: { level: "info", output: logPath, timestamp: true },
|
||||
dns: {
|
||||
servers: [
|
||||
// 用 TCP-53 远程 DNS(经代理解析)——比 DoT(853) 更通用,避免代理不放行加密 DNS 导致全站解析失败
|
||||
{ type: "tcp", tag: "remote-dns", server: "8.8.8.8", detour: "proxy" },
|
||||
{ type: "local", tag: "local-dns" },
|
||||
],
|
||||
final: "remote-dns",
|
||||
strategy: "ipv4_only",
|
||||
},
|
||||
inbounds: [
|
||||
// gvisor 用户态协议栈:Windows 上比 system 栈更稳、兼容性好
|
||||
{ type: "tun", tag: "tun-in", address: ["172.19.0.1/30"], auto_route: true, strict_route: true, stack: "gvisor" },
|
||||
],
|
||||
outbounds: outbounds(p),
|
||||
route: {
|
||||
auto_detect_interface: true,
|
||||
default_domain_resolver: "local-dns",
|
||||
rules: [
|
||||
{ action: "sniff" }, // 嗅探协议,供下面识别 DNS / QUIC
|
||||
// 关键:把 DNS 查询劫持给 sing-box 自己用 TCP 解析。否则浏览器的 UDP DNS 被塞进 TUN → 交给只支持 TCP 的 SOCKS5 → 丢弃 → 全站解析失败 = 没网
|
||||
{ protocol: "dns", action: "hijack-dns" },
|
||||
{ port: 53, action: "hijack-dns" },
|
||||
// QUIC(HTTP/3, UDP 443)经只支持 TCP 的 SOCKS5 会全丢 → 直接拒绝,逼浏览器回落到 TCP
|
||||
{ network: "udp", port: 443, action: "reject" },
|
||||
],
|
||||
final: "proxy",
|
||||
},
|
||||
};
|
||||
}
|
||||
function writeCfg(name, obj) {
|
||||
const p = path.join(runtimeDir(), name);
|
||||
fs.writeFileSync(p, JSON.stringify(obj, null, 2));
|
||||
return p;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// macOS:系统代理设置 / 还原
|
||||
// ---------------------------------------------------------------------------
|
||||
async function macServices() {
|
||||
// 列出所有网络服务,去掉标题行与被禁用(带*)的
|
||||
const { stdout } = await pExecFile("networksetup", ["-listallnetworkservices"]);
|
||||
return stdout
|
||||
.split("\n")
|
||||
.slice(1)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s && !s.startsWith("*"));
|
||||
}
|
||||
async function macSetSystemProxy(port) {
|
||||
const services = await macServices();
|
||||
const applied = [];
|
||||
for (const svc of services) {
|
||||
try {
|
||||
await pExecFile("networksetup", ["-setwebproxy", svc, "127.0.0.1", String(port)]);
|
||||
await pExecFile("networksetup", ["-setsecurewebproxy", svc, "127.0.0.1", String(port)]);
|
||||
await pExecFile("networksetup", ["-setsocksfirewallproxy", svc, "127.0.0.1", String(port)]);
|
||||
applied.push(svc);
|
||||
} catch {
|
||||
/* 某个服务设置失败(如未连接)则跳过 */
|
||||
}
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
async function macUnsetSystemProxy(services) {
|
||||
for (const svc of services || []) {
|
||||
for (const kind of ["-setwebproxystate", "-setsecurewebproxystate", "-setsocksfirewallproxystate"]) {
|
||||
try {
|
||||
await pExecFile("networksetup", [kind, svc, "off"]);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Windows:系统代理设置 / 还原(HKCU,无需管理员)
|
||||
// ---------------------------------------------------------------------------
|
||||
const WIN_INET = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings";
|
||||
// 通知 WinINET 代理设置已变更,让浏览器(Chrome/Edge 等)立即重新读取,而不是等重启
|
||||
async function winRefreshInet() {
|
||||
const ps =
|
||||
`$s='[DllImport("wininet.dll",SetLastError=true)]public static extern bool InternetSetOption(IntPtr h,int o,IntPtr b,int l);';` +
|
||||
`$t=Add-Type -MemberDefinition $s -Name W -Namespace I -PassThru;` +
|
||||
`$t::InternetSetOption([IntPtr]::Zero,39,[IntPtr]::Zero,0)|Out-Null;` + // INTERNET_OPTION_SETTINGS_CHANGED
|
||||
`$t::InternetSetOption([IntPtr]::Zero,37,[IntPtr]::Zero,0)|Out-Null`; // INTERNET_OPTION_REFRESH
|
||||
await pExecFile("powershell", ["-NoProfile", "-Command", ps]).catch(() => {});
|
||||
}
|
||||
async function winSetSystemProxy(port) {
|
||||
await pExecFile("reg", ["add", WIN_INET, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "1", "/f"]);
|
||||
await pExecFile("reg", ["add", WIN_INET, "/v", "ProxyServer", "/d", `127.0.0.1:${port}`, "/f"]);
|
||||
// 排除本地地址直连
|
||||
await pExecFile("reg", ["add", WIN_INET, "/v", "ProxyOverride", "/d", "localhost;127.*;<local>", "/f"]).catch(() => {});
|
||||
await winRefreshInet();
|
||||
}
|
||||
async function winUnsetSystemProxy() {
|
||||
await pExecFile("reg", ["add", WIN_INET, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f"]).catch(() => {});
|
||||
await winRefreshInet();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 系统代理模式:启动本地 sing-box mixed + 设系统代理
|
||||
// ---------------------------------------------------------------------------
|
||||
async function startSystem(p) {
|
||||
const port = await findFreePort(conf.localPort || 0);
|
||||
if (!port) throw new Error("找不到可用的本地端口");
|
||||
const cfg = writeCfg("system.json", systemConfig(p, port));
|
||||
|
||||
const child = spawn(binPath(), ["run", "-c", cfg], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (d) => (stderr += d.toString()));
|
||||
await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
child.on("error", (e) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(new Error(`sing-box 启动失败(${code}):${stderr.slice(0, 300)}`));
|
||||
}
|
||||
});
|
||||
// 轮询本地端口,通了就算启动成功
|
||||
const t0 = Date.now();
|
||||
const poll = () => {
|
||||
const s = net.connect(port, "127.0.0.1");
|
||||
s.on("connect", () => {
|
||||
s.destroy();
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
s.on("error", () => {
|
||||
s.destroy();
|
||||
if (Date.now() - t0 > 8000) {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(new Error("sing-box 端口未就绪:" + stderr.slice(0, 200)));
|
||||
}
|
||||
} else setTimeout(poll, 300);
|
||||
});
|
||||
};
|
||||
poll();
|
||||
});
|
||||
|
||||
// 端口通了 → 设系统代理
|
||||
let services = [];
|
||||
if (process.platform === "darwin") services = await macSetSystemProxy(port);
|
||||
else if (process.platform === "win32") await winSetSystemProxy(port);
|
||||
|
||||
state.mode = "system";
|
||||
state.child = child;
|
||||
state.localPort = port;
|
||||
state.services = services;
|
||||
conf.appliedPort = port;
|
||||
saveConf();
|
||||
child.on("exit", () => {
|
||||
// 子进程意外退出 → 复位(并尝试还原系统代理)
|
||||
if (state.child === child) {
|
||||
cleanupSystemProxy().catch(() => {});
|
||||
state.mode = "off";
|
||||
state.child = null;
|
||||
emit();
|
||||
}
|
||||
});
|
||||
emit();
|
||||
return status();
|
||||
}
|
||||
|
||||
async function cleanupSystemProxy() {
|
||||
if (process.platform === "darwin") await macUnsetSystemProxy(state.services);
|
||||
else if (process.platform === "win32") await winUnsetSystemProxy();
|
||||
state.services = [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 全局 TUN 模式:提权启动 sing-box(flag 文件做“可停止”,全程只弹一次密码)
|
||||
// 原理:提权 shell 里后台跑 sing-box,前台 while 循环盯住 flag 文件;
|
||||
// 我方(普通权限)删除 flag → 提权循环发现文件没了 → kill sing-box 退出。
|
||||
// ---------------------------------------------------------------------------
|
||||
function flagPath() {
|
||||
return path.join(runtimeDir(), "global.flag");
|
||||
}
|
||||
function stripAnsi(s) {
|
||||
return String(s).replace(/\x1b\[[0-9;]*m/g, "");
|
||||
}
|
||||
// 读日志:Windows 的系统错误常是 GBK 中文,UTF-8 读出来是乱码 → 检测到乱码就用 GBK 兜底
|
||||
function readLogSmart(logf) {
|
||||
let buf;
|
||||
try {
|
||||
buf = fs.readFileSync(logf);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
let s = buf.toString("utf-8");
|
||||
if (s.includes("�")) {
|
||||
for (const enc of ["gbk", "gb18030", "big5"]) {
|
||||
try {
|
||||
const d = new TextDecoder(enc).decode(buf);
|
||||
if (!d.includes("�")) {
|
||||
s = d;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return stripAnsi(s);
|
||||
}
|
||||
// 盯 sing-box 日志判断启动成败(mac / Windows 通用):
|
||||
// 出现 "sing-box started" → 成功;出现 FATAL → 失败;
|
||||
// 提权进程(仅 mac)提前退出且无 started → 取消授权;超时同理。
|
||||
function waitForStart(logf, timeoutMs, child, watchChildExit) {
|
||||
return new Promise((resolve) => {
|
||||
const t0 = Date.now();
|
||||
let childExited = false;
|
||||
if (watchChildExit && child) child.on("exit", () => (childExited = true));
|
||||
const tick = () => {
|
||||
const txt = readLogSmart(logf);
|
||||
if (/sing-box started/i.test(txt)) return resolve({ ok: true });
|
||||
const fatal = txt.match(/FATAL[^\n]*/i) || txt.match(/ERROR[^\n]*(bind|permission|denied|wintun|tun)[^\n]*/i);
|
||||
if (fatal) return resolve({ ok: false, reason: "引擎启动失败:" + fatal[0].trim().slice(0, 260) });
|
||||
if (childExited && Date.now() - t0 > 1500) {
|
||||
return resolve({
|
||||
ok: false,
|
||||
reason: txt ? "启动失败:" + txt.trim().slice(-260) : "已取消管理员授权,全局代理未启动。",
|
||||
});
|
||||
}
|
||||
if (Date.now() - t0 > timeoutMs) {
|
||||
return resolve({
|
||||
ok: false,
|
||||
reason: txt
|
||||
? "启动超时:" + txt.trim().slice(-260)
|
||||
: "未获得管理员授权(密码框 / UAC 被取消),或引擎无法启动。",
|
||||
});
|
||||
}
|
||||
setTimeout(tick, 400);
|
||||
};
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
async function startGlobal(p) {
|
||||
const flag = flagPath();
|
||||
const logf = path.join(runtimeDir(), "global.log");
|
||||
const cfg = writeCfg("global.json", globalConfig(p, logf));
|
||||
// 先自检配置,避免弹了密码才发现配置错
|
||||
await pExecFile(binPath(), ["check", "-c", cfg]);
|
||||
try {
|
||||
fs.unlinkSync(logf); // 清掉上次日志,保证这次判断读到的是本次输出
|
||||
} catch {}
|
||||
fs.writeFileSync(flag, "run");
|
||||
|
||||
let child;
|
||||
if (process.platform === "darwin") {
|
||||
// 提权 bash:后台跑 sing-box(日志由 sing-box 自己写文件),盯 flag;flag 删了或引擎挂了就退出
|
||||
const sh = path.join(runtimeDir(), "run-global.sh");
|
||||
fs.writeFileSync(
|
||||
sh,
|
||||
`#!/bin/bash
|
||||
"${binPath()}" run -c "${cfg}" &
|
||||
SB=$!
|
||||
while [ -f "${flag}" ]; do sleep 1; kill -0 $SB 2>/dev/null || break; done
|
||||
kill $SB 2>/dev/null
|
||||
wait $SB 2>/dev/null
|
||||
`
|
||||
);
|
||||
fs.chmodSync(sh, 0o755);
|
||||
const osa = `do shell script "/bin/bash '${sh}'" with administrator privileges`;
|
||||
child = spawn("osascript", ["-e", osa]);
|
||||
} else if (process.platform === "win32") {
|
||||
// PowerShell(Unicode 原生,兼容中文用户名路径):提权后台跑 sing-box,盯 flag,删了就杀。
|
||||
// sing-box 自己把日志写进 log.output,不用 shell 重定向 → 避开 cmd 的 GBK 编码坑。
|
||||
const ps = path.join(runtimeDir(), "run-global.ps1");
|
||||
const psBody =
|
||||
"" + // UTF-8 BOM,保证 PowerShell 按 UTF-8 读取(路径含中文也不乱)
|
||||
`$ErrorActionPreference='SilentlyContinue'\r\n` +
|
||||
// 先杀掉上次残留的引擎(此脚本已提权,能杀掉之前遗留的管理员进程),释放被占用的 wintun 网卡
|
||||
`Stop-Process -Name sing-box -Force\r\n` +
|
||||
`Start-Sleep -Milliseconds 800\r\n` +
|
||||
`$proc = Start-Process -FilePath '${binPath()}' -ArgumentList @('run','-c','${cfg}') -WindowStyle Hidden -PassThru\r\n` +
|
||||
`while (Test-Path '${flag}') { if ($proc.HasExited) { break }; Start-Sleep -Seconds 1 }\r\n` +
|
||||
`if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force }\r\n` +
|
||||
`Stop-Process -Name sing-box -Force\r\n`;
|
||||
fs.writeFileSync(ps, psBody, "utf-8");
|
||||
// 非提权的外层 powershell 负责触发一次 UAC,把上面的脚本以管理员+隐藏窗口运行
|
||||
const outer = `Start-Process powershell -Verb RunAs -WindowStyle Hidden -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File','${ps}'`;
|
||||
child = spawn("powershell", ["-NoProfile", "-Command", outer], { windowsHide: true });
|
||||
} else {
|
||||
throw new Error("全局代理暂只支持 macOS 与 Windows");
|
||||
}
|
||||
|
||||
// mac 的提权进程会随 sing-box 存活;win 的外层进程点完 UAC 立刻退出,故只在 mac 盯它早退
|
||||
// 开 wintun/utun 网卡可能较慢,给 40 秒;那句 "open interface take too much time" 只是 WARN,会继续尝试
|
||||
const res = await waitForStart(logf, 40000, child, process.platform === "darwin");
|
||||
if (!res.ok) {
|
||||
try {
|
||||
fs.unlinkSync(flag);
|
||||
} catch {}
|
||||
try {
|
||||
if (child) child.kill();
|
||||
} catch {}
|
||||
throw new Error(res.reason);
|
||||
}
|
||||
|
||||
state.mode = "global";
|
||||
state.localPort = 0;
|
||||
if (process.platform === "darwin") {
|
||||
// mac:提权进程存活即代理存活,它退出就复位状态
|
||||
state.child = child;
|
||||
child.on("exit", () => {
|
||||
if (state.child === child) {
|
||||
try {
|
||||
fs.unlinkSync(flag);
|
||||
} catch {}
|
||||
state.mode = "off";
|
||||
state.child = null;
|
||||
emit();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// win:外层进程已退出,靠 flag 文件控制生命周期
|
||||
state.child = null;
|
||||
}
|
||||
emit();
|
||||
return status();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 统一启动 / 停止
|
||||
// ---------------------------------------------------------------------------
|
||||
async function start(mode) {
|
||||
loadConf();
|
||||
if (!binExists()) throw new Error("缺少 sing-box 引擎文件,请重新安装本软件");
|
||||
const p = parseProxy(conf.proxyStr);
|
||||
if (!p) throw new Error("请先填写正确的代理地址,格式:host:port:user:pass");
|
||||
if (state.mode !== "off") await stop();
|
||||
if (mode === "system") return startSystem(p);
|
||||
if (mode === "global") return startGlobal(p);
|
||||
throw new Error("未知模式:" + mode);
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
const prev = state.mode;
|
||||
if (prev === "system") {
|
||||
await cleanupSystemProxy();
|
||||
if (state.child) {
|
||||
try {
|
||||
state.child.kill();
|
||||
} catch {}
|
||||
}
|
||||
} else if (prev === "global") {
|
||||
// 删 flag → 提权循环自行 kill sing-box 并拆除 TUN
|
||||
try {
|
||||
fs.unlinkSync(flagPath());
|
||||
} catch {}
|
||||
// 兜底:等提权进程收尾
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
}
|
||||
state.mode = "off";
|
||||
state.child = null;
|
||||
state.localPort = 0;
|
||||
state.services = [];
|
||||
emit();
|
||||
return status();
|
||||
}
|
||||
|
||||
// 退出前清理:确保不把系统代理 / TUN 残留给用户
|
||||
async function shutdown() {
|
||||
if (state.mode === "off") return;
|
||||
try {
|
||||
await stop();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 启动时的残留清理:上次若崩溃遗留了「指向我们端口的系统代理」,且引擎已不在 → 关掉它,
|
||||
// 但不动用户自己(如 Clash)设的其它端口,避免误伤。
|
||||
async function cleanupStale() {
|
||||
loadConf();
|
||||
if (process.platform !== "darwin" || !conf.appliedPort) return;
|
||||
try {
|
||||
const services = await macServices();
|
||||
for (const svc of services) {
|
||||
const { stdout } = await pExecFile("networksetup", ["-getsocksfirewallproxy", svc]).catch(() => ({ stdout: "" }));
|
||||
if (/Enabled:\s*Yes/i.test(stdout) && stdout.includes("127.0.0.1") && stdout.includes(String(conf.appliedPort))) {
|
||||
await macUnsetSystemProxy([svc]);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 测试连通性:临时起一个本地 mixed 口,经它请求取出口 IP
|
||||
// ---------------------------------------------------------------------------
|
||||
async function testProxy() {
|
||||
loadConf();
|
||||
if (!binExists()) throw new Error("缺少 sing-box 引擎文件");
|
||||
const p = parseProxy(conf.proxyStr);
|
||||
if (!p) throw new Error("代理地址格式不对,应为 host:port:user:pass");
|
||||
const port = await findFreePort(0);
|
||||
const cfg = writeCfg("test.json", systemConfig(p, port));
|
||||
const child = spawn(binPath(), ["run", "-c", cfg], { stdio: ["ignore", "ignore", "pipe"] });
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (d) => (stderr += d.toString()));
|
||||
try {
|
||||
// 等端口就绪
|
||||
await new Promise((resolve, reject) => {
|
||||
const t0 = Date.now();
|
||||
const poll = () => {
|
||||
const s = net.connect(port, "127.0.0.1");
|
||||
s.on("connect", () => {
|
||||
s.destroy();
|
||||
resolve();
|
||||
});
|
||||
s.on("error", () => {
|
||||
s.destroy();
|
||||
if (Date.now() - t0 > 6000) reject(new Error("引擎未就绪:" + stderr.slice(0, 200)));
|
||||
else setTimeout(poll, 250);
|
||||
});
|
||||
};
|
||||
poll();
|
||||
});
|
||||
const ip = await httpGetViaProxy(port, "http://api.ipify.org/");
|
||||
return { ok: true, ip, proxy: `${p.host}:${p.port}` };
|
||||
} finally {
|
||||
try {
|
||||
child.kill();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// 经本地 HTTP 代理端口发一个 GET,返回响应体(用于取出口 IP)
|
||||
function httpGetViaProxy(port, targetUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const u = new URL(targetUrl);
|
||||
const req = http.request(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
port,
|
||||
method: "GET",
|
||||
path: targetUrl, // 绝对 URL → 走代理
|
||||
headers: { Host: u.host, "User-Agent": "codex-market", Connection: "close" },
|
||||
timeout: 15000,
|
||||
},
|
||||
(res) => {
|
||||
const chunks = [];
|
||||
res.on("data", (c) => chunks.push(c));
|
||||
res.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8").trim()));
|
||||
}
|
||||
);
|
||||
req.on("timeout", () => req.destroy(new Error("请求超时(15s)")));
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 状态
|
||||
// ---------------------------------------------------------------------------
|
||||
function status() {
|
||||
loadConf();
|
||||
const p = parseProxy(conf.proxyStr);
|
||||
return {
|
||||
mode: state.mode, // off | system | global
|
||||
running: state.mode !== "off",
|
||||
localPort: state.localPort || 0,
|
||||
binOk: binExists(),
|
||||
platform: process.platform,
|
||||
proxy: p ? { host: p.host, port: p.port, hasAuth: !!p.user } : null,
|
||||
hasConfig: !!p,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getConfig,
|
||||
setConfig,
|
||||
start,
|
||||
stop,
|
||||
status,
|
||||
testProxy,
|
||||
shutdown,
|
||||
cleanupStale,
|
||||
setOnChange,
|
||||
};
|
||||
Reference in New Issue
Block a user