diff --git a/.gitignore b/.gitignore index d6f5c9d..fc68b19 100644 --- a/.gitignore +++ b/.gitignore @@ -166,6 +166,16 @@ cython_debug/ backup_config/ +# Google OAuth(含 client_secret,切勿提交) +config/google_oauth.json +config/youtube_oauth_token.json +config/web2_api_token.txt +config/web2_bind_host.txt +config/easytier_network.json + +electron/resources/easytier/easytier-core.exe +electron/resources/easytier/_extract_tmp/ + # Electron 桌面壳 electron/node_modules/ electron/dist/ diff --git a/config/easytier_network.example.json b/config/easytier_network.example.json new file mode 100644 index 0000000..6219949 --- /dev/null +++ b/config/easytier_network.example.json @@ -0,0 +1,5 @@ +{ + "networkName": "lr_自动生成", + "networkSecret": "自动生成请勿手填", + "peerUrls": [] +} diff --git a/config/google_oauth.example.json b/config/google_oauth.example.json new file mode 100644 index 0000000..b5996dd --- /dev/null +++ b/config/google_oauth.example.json @@ -0,0 +1,10 @@ +{ + "redirect_uri": "http://127.0.0.1:8001/youtube/oauth/callback", + "web": { + "client_id": "你的客户端ID.apps.googleusercontent.com", + "client_secret": "你的客户端密钥", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "redirect_uris": ["http://127.0.0.1:8001/youtube/oauth/callback"] + } +} diff --git a/config/web2_api_token.example.txt b/config/web2_api_token.example.txt new file mode 100644 index 0000000..38073ad --- /dev/null +++ b/config/web2_api_token.example.txt @@ -0,0 +1 @@ +changeme_copy_to_web2_api_token_txt_and_replace_with_random_secret diff --git a/config/web2_bind_host.example.txt b/config/web2_bind_host.example.txt new file mode 100644 index 0000000..7b9ad53 --- /dev/null +++ b/config/web2_bind_host.example.txt @@ -0,0 +1 @@ +127.0.0.1 diff --git a/docs/p2p-remote.md b/docs/p2p-remote.md new file mode 100644 index 0000000..e91e064 --- /dev/null +++ b/docs/p2p-remote.md @@ -0,0 +1,72 @@ +# 远程控制与 P2P 虚拟网(EasyTier) + +## 方案对比(简要) + +| 方案 | 特点 | +|------|------| +| [EasyTier](https://github.com/EasyTier/EasyTier) | 去中心化 mesh、NAT 穿透、Win/Android 有现成客户端;与「本机 HTTP API」组合简单。 | +| Tailscale / ZeroTier | 需协调服务或自建 Moon;上手快,依赖厂商或自建协调器。 | +| 纯 WebRTC | 真 P2P,但需信令(常要服务器)或复杂打洞流程,不适合直接替代「虚拟网 + REST」。 | + +对本项目(`web2` FastAPI + Electron + 未来 React Native),**EasyTier + 固定虚拟 IP 访问 `http://:8001`** 是务实组合。 + +## Windows 桌面端:内置 EasyTier + +- 构建/开发前会执行 `pnpm run prepare-easytier`(或 `electron/scripts/download-easytier.mjs`),从 [EasyTier 官方 Release](https://github.com/EasyTier/EasyTier/releases) 下载 Windows x64 包并解压 `easytier-core.exe` 到 `electron/resources/easytier/`(LGPL-3.0,见同目录 `NOTICE-EasyTier.txt`)。 +- 应用启动后自动拉起该进程,并在 `config/easytier_network.json` **首次自动生成**组网名与密钥(`peerUrls` 可留空或按需添加社区共享节点)。 +- 若需跳过下载(如无网络 CI),可设置环境变量 `SKIP_EASYTIER=1`。 + +## 本项目已做的改造 + +1. **监听地址**:Electron 启动的 uvicorn 默认仍为 `127.0.0.1`。若存在 `config/web2_bind_host.txt`(单行,如 `0.0.0.0`)或环境变量 `WEB2_HOST`,则按该地址绑定。 +2. **API 鉴权(可选)**:若存在 `config/web2_api_token.txt`(单行密钥)或环境变量 `WEB2_API_TOKEN`,则除 `/health`、OpenAPI 文档路径外,请求需带: + - `Authorization: Bearer <密钥>`,或 + - `X-API-Token: <密钥>` +3. **Electron 前端**:自动从主进程读取 token 文件并注入请求头;浏览器 / React Native 需自行配置同一密钥。 +4. **「网络」页**:提供「允许远程」开关(写入 `web2_bind_host.txt` 并重启后端)、本机候选虚拟网 IP 列表、**生成 API 密钥**,以及 **二维码**(JSON 文本,供 RN 扫码解析)。 + +### 扫码绑定 JSON 格式(`v` 为版本号) + +```json +{ + "v": 1, + "baseUrl": "http://10.126.126.1:8001", + "port": 8001, + "token": "与 web2_api_token.txt 一致", + "easytier": { + "networkName": "与 PC 上 config/easytier_network.json 一致", + "networkSecret": "同上", + "peerUrls": [] + } +} +``` + +React Native:解析 JSON 后,**App 内**需使用与 PC 同版本的 EasyTier 组件并启动同一 `networkName` / `networkSecret`(可选 `peerUrls`),再请求 `baseUrl` 并带 `Authorization: Bearer ${token}`。 + +### 移动端 App 内置 EasyTier + +- Android 可参考 [EasyTier 官方 APK](https://github.com/EasyTier/EasyTier/releases) 或同仓库原生库,将组网逻辑与扫码得到的 `easytier` 字段对齐;**同一套 JSON 即可实现 PC↔手机同网,无需用户再单独安装 EasyTier 客户端**(由你们 App 内置分发)。 + +## EasyTier 侧(用户操作) + +1. PC 与手机分别安装 EasyTier,使用**相同** `--network-name` 与 `--network-secret`。 +2. 按 [EasyTier 文档](https://github.com/EasyTier/EasyTier) 连接共享节点或自建入口,确认两端能 `ping` 通虚拟 IP。 +3. 在 PC 上把 `config/web2_bind_host.txt` 设为 `0.0.0.0`,并创建 `config/web2_api_token.txt`(随机长串)。 +4. 重启本应用;在手机浏览器或 RN 中请求:`http://:8001/process_monitor` 等,并带上 `Authorization` 头。 + +## React Native 示例 + +```ts +const BASE = 'http://10.126.126.1:8001' // 替换为 PC 在 EasyTier 中的 IP +const TOKEN = '你的 web2_api_token.txt 内容' + +fetch(`${BASE}/health`).then((r) => r.json()) +fetch(`${BASE}/process_monitor`, { + headers: { Authorization: `Bearer ${TOKEN}` }, +}) +``` + +## 安全说明 + +- `0.0.0.0` 且无 token 时,控制台会警告;**不要**将无鉴权 API 暴露到公网。 +- 防火墙仅向 EasyTier 虚拟网段放行 `8001`(若需)。 diff --git a/electron/electron.vite.config.ts b/electron/electron.vite.config.ts index be04438..3b4ab22 100644 --- a/electron/electron.vite.config.ts +++ b/electron/electron.vite.config.ts @@ -8,6 +8,14 @@ export default defineConfig({ }, preload: { plugins: [externalizeDepsPlugin()], + build: { + rollupOptions: { + output: { + format: 'cjs', + entryFileNames: 'index.js', + }, + }, + }, }, renderer: { resolve: { diff --git a/electron/package.json b/electron/package.json index 350ac8f..84ef031 100644 --- a/electron/package.json +++ b/electron/package.json @@ -6,20 +6,24 @@ "main": "./out/main/index.js", "type": "module", "scripts": { - "dev": "electron-vite dev", - "build": "electron-vite build", + "prepare-easytier": "node scripts/download-easytier.mjs", + "dev": "node scripts/download-easytier.mjs && electron-vite dev", + "build": "node scripts/download-easytier.mjs && electron-vite build", "preview": "electron-vite preview", - "dist": "electron-vite build && electron-builder --win --publish never" + "dist": "pnpm run build && electron-builder --win --publish never" }, "dependencies": { + "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { "@types/node": "^22.10.0", + "@types/qrcode": "^1.5.6", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", + "adm-zip": "^0.5.16", "electron": "^33.2.0", "electron-builder": "^25.1.8", "electron-vite": "^2.3.0", @@ -45,6 +49,11 @@ "package.json" ], "extraResources": [ + { + "from": "resources/easytier", + "to": "easytier", + "filter": ["**/*"] + }, { "from": "..", "to": "backend", diff --git a/electron/pnpm-lock.yaml b/electron/pnpm-lock.yaml index 59c096c..7b23575 100644 --- a/electron/pnpm-lock.yaml +++ b/electron/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + qrcode: + specifier: ^1.5.4 + version: 1.5.4 react: specifier: ^18.3.1 version: 18.3.1 @@ -18,6 +21,9 @@ importers: '@types/node': specifier: ^22.10.0 version: 22.19.15 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/react': specifier: ^18.3.12 version: 18.3.28 @@ -27,6 +33,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.3.4 version: 4.7.0(vite@5.4.21(@types/node@22.19.15)) + adm-zip: + specifier: ^0.5.16 + version: 0.5.16 electron: specifier: ^33.2.0 version: 33.4.11 @@ -535,6 +544,9 @@ packages: '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react-dom@18.3.7': resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} peerDependencies: @@ -565,6 +577,10 @@ packages: abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + adm-zip@0.5.16: + resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==} + engines: {node: '>=12.0'} + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -739,6 +755,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001781: resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} @@ -773,6 +793,9 @@ packages: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} engines: {node: '>=8'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -857,6 +880,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -890,6 +917,9 @@ packages: detect-node@2.1.0: resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} @@ -1027,6 +1057,10 @@ packages: filelist@1.0.6: resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -1296,6 +1330,10 @@ packages: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -1506,17 +1544,33 @@ packages: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-map@4.0.0: resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -1543,6 +1597,10 @@ packages: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss@8.5.8: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} @@ -1573,6 +1631,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + quick-lru@5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} @@ -1608,6 +1671,9 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resedit@1.7.2: resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} engines: {node: '>=12', npm: '>=6'} @@ -1868,6 +1934,9 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1876,6 +1945,10 @@ packages: wide-align@1.1.5: resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -1891,6 +1964,9 @@ packages: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -1901,10 +1977,18 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -2389,6 +2473,10 @@ snapshots: '@types/prop-types@15.7.15': {} + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 22.19.15 + '@types/react-dom@18.3.7(@types/react@18.3.28)': dependencies: '@types/react': 18.3.28 @@ -2426,6 +2514,8 @@ snapshots: abbrev@1.1.1: {} + adm-zip@0.5.16: {} + agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -2688,6 +2778,8 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + camelcase@5.3.1: {} + caniuse-lite@1.0.30001781: {} chalk@4.1.2: @@ -2715,6 +2807,12 @@ snapshots: string-width: 4.2.3 optional: true + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -2790,6 +2888,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -2823,6 +2923,8 @@ snapshots: detect-node@2.1.0: optional: true + dijkstrajs@1.0.3: {} + dir-compare@4.2.0: dependencies: minimatch: 3.1.5 @@ -3028,6 +3130,11 @@ snapshots: dependencies: minimatch: 5.1.9 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -3335,6 +3442,10 @@ snapshots: dependencies: readable-stream: 2.3.8 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + lodash.defaults@4.2.0: {} lodash.difference@4.5.0: {} @@ -3550,16 +3661,28 @@ snapshots: p-cancelable@2.1.1: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-map@4.0.0: dependencies: aggregate-error: 3.1.0 + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} + path-exists@4.0.0: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -3581,6 +3704,8 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 + pngjs@5.0.0: {} + postcss@8.5.8: dependencies: nanoid: 3.3.11 @@ -3605,6 +3730,12 @@ snapshots: punycode@2.3.1: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + quick-lru@5.1.1: {} react-dom@18.3.1(react@18.3.1): @@ -3647,6 +3778,8 @@ snapshots: require-directory@2.1.1: {} + require-main-filename@2.0.0: {} + resedit@1.7.2: dependencies: pe-library: 0.4.1 @@ -3916,6 +4049,8 @@ snapshots: dependencies: defaults: 1.0.4 + which-module@2.0.1: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -3924,6 +4059,12 @@ snapshots: dependencies: string-width: 4.2.3 + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -3940,14 +4081,35 @@ snapshots: xmlbuilder@15.1.1: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} yallist@4.0.0: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1 diff --git a/electron/resources/easytier/.easytier_bundle_ok b/electron/resources/easytier/.easytier_bundle_ok new file mode 100644 index 0000000..033eb68 --- /dev/null +++ b/electron/resources/easytier/.easytier_bundle_ok @@ -0,0 +1 @@ +2026-03-24T17:49:18.788Z diff --git a/electron/resources/easytier/NOTICE-EasyTier.txt b/electron/resources/easytier/NOTICE-EasyTier.txt new file mode 100644 index 0000000..4e87710 --- /dev/null +++ b/electron/resources/easytier/NOTICE-EasyTier.txt @@ -0,0 +1,3 @@ +EasyTier 以 LGPL-3.0 授权发布。 +源码: https://github.com/EasyTier/EasyTier +本程序随官方 GitHub Release 附带 easytier-core.exe 及同目录依赖,未修改二进制。 diff --git a/electron/resources/easytier/Packet.dll b/electron/resources/easytier/Packet.dll new file mode 100644 index 0000000..7e1bf12 Binary files /dev/null and b/electron/resources/easytier/Packet.dll differ diff --git a/electron/resources/easytier/easytier-cli.exe b/electron/resources/easytier/easytier-cli.exe new file mode 100644 index 0000000..6747591 Binary files /dev/null and b/electron/resources/easytier/easytier-cli.exe differ diff --git a/electron/resources/easytier/easytier-web-embed.exe b/electron/resources/easytier/easytier-web-embed.exe new file mode 100644 index 0000000..162c8bd Binary files /dev/null and b/electron/resources/easytier/easytier-web-embed.exe differ diff --git a/electron/resources/easytier/easytier-web.exe b/electron/resources/easytier/easytier-web.exe new file mode 100644 index 0000000..57493cb Binary files /dev/null and b/electron/resources/easytier/easytier-web.exe differ diff --git a/electron/resources/easytier/wintun.dll b/electron/resources/easytier/wintun.dll new file mode 100644 index 0000000..aee04e7 Binary files /dev/null and b/electron/resources/easytier/wintun.dll differ diff --git a/electron/scripts/download-easytier.mjs b/electron/scripts/download-easytier.mjs new file mode 100644 index 0000000..54bddff --- /dev/null +++ b/electron/scripts/download-easytier.mjs @@ -0,0 +1,103 @@ +/** + * 下载官方 EasyTier Windows x64 发行包并解压到 resources/easytier/ + * 须复制 exe 同目录下全部 DLL 等依赖,否则 easytier-core 会以 0xC0000135(3221225781)退出。 + * 设置 SKIP_EASYTIER=1 可跳过(CI 无网环境) + */ +import fs from 'fs' +import path from 'path' +import https from 'https' +import { fileURLToPath } from 'url' +import AdmZip from 'adm-zip' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const root = path.resolve(__dirname, '..') +const resDir = path.join(root, 'resources', 'easytier') +const DEFAULT_URL = + process.env.EASYTIER_DOWNLOAD_URL || + 'https://github.com/EasyTier/EasyTier/releases/download/v2.4.5/easytier-windows-x86_64-v2.4.5.zip' +const exeName = 'easytier-core.exe' +const bundleMarker = '.easytier_bundle_ok' + +if (process.env.SKIP_EASYTIER === '1') { + console.log('[easytier] SKIP_EASYTIER=1,跳过下载') + process.exit(0) +} + +function download(url) { + return new Promise((resolve, reject) => { + https + .get(url, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + download(res.headers.location).then(resolve).catch(reject) + return + } + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode} ${url}`)) + return + } + const chunks = [] + res.on('data', (c) => chunks.push(c)) + res.on('end', () => resolve(Buffer.concat(chunks))) + res.on('error', reject) + }) + .on('error', reject) + }) +} + +function findExe(dir, name) { + if (!fs.existsSync(dir)) return null + for (const f of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, f.name) + if (f.isDirectory()) { + const r = findExe(p, name) + if (r) return r + } else if (f.name === name) { + return p + } + } + return null +} + +fs.mkdirSync(resDir, { recursive: true }) +const outExe = path.join(resDir, exeName) +const markerPath = path.join(resDir, bundleMarker) +if (fs.existsSync(outExe) && fs.existsSync(markerPath)) { + console.log('[easytier] 已存在完整包,跳过:', outExe) + process.exit(0) +} +if (fs.existsSync(outExe) && !fs.existsSync(markerPath)) { + console.log('[easytier] 检测到旧版仅复制了 exe,将重新下载并解压全部依赖文件…') +} + +console.log('[easytier] 下载:', DEFAULT_URL) +const buf = await download(DEFAULT_URL) +const zip = new AdmZip(buf) +const tmp = path.join(resDir, '_extract_tmp') +if (fs.existsSync(tmp)) fs.rmSync(tmp, { recursive: true }) +fs.mkdirSync(tmp, { recursive: true }) +zip.extractAllTo(tmp, true) +const found = findExe(tmp, exeName) +if (!found) { + console.error('[easytier] zip 内未找到', exeName) + process.exit(1) +} +const srcDir = path.dirname(found) +for (const name of fs.readdirSync(srcDir)) { + const from = path.join(srcDir, name) + const to = path.join(resDir, name) + if (!fs.statSync(from).isFile()) continue + fs.copyFileSync(from, to) +} +fs.rmSync(tmp, { recursive: true }) +fs.writeFileSync( + path.join(resDir, 'NOTICE-EasyTier.txt'), + [ + 'EasyTier 以 LGPL-3.0 授权发布。', + '源码: https://github.com/EasyTier/EasyTier', + '本程序随官方 GitHub Release 附带 easytier-core.exe 及同目录依赖,未修改二进制。', + '', + ].join('\n'), + 'utf-8', +) +fs.writeFileSync(markerPath, `${new Date().toISOString()}\n`, 'utf-8') +console.log('[easytier] 完成(已复制 zip 内同目录全部文件):', outExe) diff --git a/electron/src/main/easytier.ts b/electron/src/main/easytier.ts new file mode 100644 index 0000000..06274d8 --- /dev/null +++ b/electron/src/main/easytier.ts @@ -0,0 +1,158 @@ +/** + * 内置 EasyTier(Windows):随应用分发 easytier-core.exe,自动生成组网密钥,无需用户单独安装。 + * 二进制来源:https://github.com/EasyTier/EasyTier (LGPL-3.0) + */ +import { app } from 'electron' +import { spawn, type ChildProcess } from 'child_process' +import crypto from 'crypto' +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +export type EasyTierNetworkConfig = { + networkName: string + networkSecret: string + peerUrls: string[] +} + +let easytierChild: ChildProcess | null = null +let easytierLastError = '' + +/** Windows 进程退出码(含 NTSTATUS)可读说明 */ +function formatWindowsExitHint(code: number): string { + const u = code >>> 0 + if (u === 0xc0000135) { + return ' — 缺少 DLL(常见:官方 zip 内依赖未与 exe 同目录;开发环境请删除 electron/resources/easytier 后重新执行 pnpm run prepare-easytier)。也可能是未安装 VC++ 运行库。' + } + if (u === 0xc000007b) { + return ' — 无效映像(32/64 位与系统不符或文件损坏)。' + } + return '' +} + +export function getBundledEasyTierExePath(): string | null { + if (process.platform !== 'win32') return null + const exe = 'easytier-core.exe' + if (app.isPackaged) { + const p = path.join(process.resourcesPath, 'easytier', exe) + return fs.existsSync(p) ? p : null + } + const dev = path.resolve(__dirname, '../../resources/easytier', exe) + return fs.existsSync(dev) ? dev : null +} + +export function readEasyTierNetworkName(backendRoot: string): string { + const p = path.join(backendRoot, 'config', 'easytier_network.json') + if (!fs.existsSync(p)) return '' + try { + const raw = JSON.parse(fs.readFileSync(p, 'utf-8')) as Record + return String(raw.networkName ?? '') + } catch { + return '' + } +} + +export function loadOrCreateNetworkConfig(backendRoot: string): EasyTierNetworkConfig { + const p = path.join(backendRoot, 'config', 'easytier_network.json') + fs.mkdirSync(path.dirname(p), { recursive: true }) + if (fs.existsSync(p)) { + try { + const raw = JSON.parse(fs.readFileSync(p, 'utf-8')) as Record + return { + networkName: String(raw.networkName ?? ''), + networkSecret: String(raw.networkSecret ?? ''), + peerUrls: Array.isArray(raw.peerUrls) ? raw.peerUrls.map(String) : [], + } + } catch { + /* fallthrough */ + } + } + const cfg: EasyTierNetworkConfig = { + networkName: `lr_${crypto.randomBytes(6).toString('hex')}`, + networkSecret: crypto.randomBytes(24).toString('hex'), + peerUrls: [], + } + fs.writeFileSync(p, JSON.stringify(cfg, null, 2), 'utf-8') + return cfg +} + +export function getEasyTierRuntimeState(): { + bundled: boolean + running: boolean + lastError: string +} { + const bundled = !!getBundledEasyTierExePath() + const running = !!easytierChild && !easytierChild.killed + return { bundled, running, lastError: easytierLastError } +} + +export function startBundledEasyTier(backendRoot: string): void { + easytierLastError = '' + const exe = getBundledEasyTierExePath() + if (!exe) { + easytierLastError = '未找到内置 easytier-core.exe(开发环境可执行 pnpm run prepare-easytier)' + return + } + if (easytierChild && !easytierChild.killed) { + return + } + const cfg = loadOrCreateNetworkConfig(backendRoot) + const args = ['-d', '--network-name', cfg.networkName, '--network-secret', cfg.networkSecret] + for (const u of cfg.peerUrls) { + const t = u.trim() + if (t) args.push('-p', t) + } + let child: ChildProcess + try { + child = spawn(exe, args, { + cwd: path.dirname(exe), + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env }, + }) + } catch (e) { + easytierLastError = e instanceof Error ? e.message : String(e) + easytierChild = null + return + } + easytierChild = child + const appendErr = (chunk: Buffer): void => { + const s = chunk.toString() + if (s.length < 2000) easytierLastError = (easytierLastError + s).slice(-2000) + } + child.stderr?.on('data', appendErr) + child.stdout?.on('data', appendErr) + child.on('error', (err) => { + easytierLastError = err.message + }) + child.on('close', (code) => { + if (code !== 0 && code !== null) { + const hint = formatWindowsExitHint(code) + easytierLastError = `easytier-core 已退出(代码 ${code})${hint}${easytierLastError ? `\n${easytierLastError}` : ''}` + } + easytierChild = null + }) +} + +export function stopBundledEasyTier(): void { + if (!easytierChild || easytierChild.killed) { + easytierChild = null + return + } + try { + if (process.platform === 'win32') { + spawn('taskkill', ['/PID', String(easytierChild.pid), '/T', '/F'], { + windowsHide: true, + stdio: 'ignore', + }) + } else { + easytierChild.kill('SIGTERM') + } + } catch { + /* ignore */ + } + easytierChild = null +} diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts index c48d679..faba286 100644 --- a/electron/src/main/index.ts +++ b/electron/src/main/index.ts @@ -1,19 +1,135 @@ -import { app, BrowserWindow, dialog } from 'electron' -import { spawn, ChildProcessWithoutNullStreams } from 'child_process' +import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron' +import { execFile, spawn, ChildProcessWithoutNullStreams } from 'child_process' +import { promisify } from 'util' import http from 'http' import fs from 'fs' import path from 'path' +import crypto from 'crypto' +import { networkInterfaces } from 'os' import { fileURLToPath } from 'url' +import { + getEasyTierRuntimeState, + loadOrCreateNetworkConfig, + readEasyTierNetworkName, + startBundledEasyTier, + stopBundledEasyTier, +} from './easytier' + const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) +const execFileAsync = promisify(execFile) + const PORT = Number(process.env.WEB2_PORT || 8001) -const HOST = '127.0.0.1' +/** uvicorn 监听地址;0.0.0.0 时建议配置 config/web2_api_token.txt 供远程/EasyTier 访问 */ +let web2Host = '127.0.0.1' let mainWindow: BrowserWindow | null = null let pythonChild: ChildProcessWithoutNullStreams | null = null let stderrBuf = '' +let _backendRoot: string | null = null +let _pythonExe: string | null = null + +/** + * 供扫码绑定 baseUrl:优先虚拟网卡(EasyTier 等),其次 10.x,再补充 Wi‑Fi/以太网等局域网 IPv4, + * 避免仅有虚拟网时列表为空、用户无处选 IP。 + */ +function collectEasyTierCandidateIPs(): { address: string; name: string }[] { + const nets = networkInterfaces() + const seen = new Set() + const out: { address: string; name: string }[] = [] + const nameMatch = /easytier|wintun|tun|tap|netbird|wireguard|tailscale|zerotier|sing-tun|nordlynx/i + + for (const [name, addrs] of Object.entries(nets)) { + if (!addrs) continue + for (const a of addrs) { + if (a.family !== 'IPv4' || a.internal) continue + if (nameMatch.test(name) && !seen.has(a.address)) { + seen.add(a.address) + out.push({ address: a.address, name }) + } + } + } + if (out.length === 0) { + for (const [name, addrs] of Object.entries(nets)) { + if (!addrs) continue + for (const a of addrs) { + if (a.family !== 'IPv4' || a.internal) continue + if (a.address.startsWith('10.') && !seen.has(a.address)) { + seen.add(a.address) + out.push({ address: a.address, name: `${name}(10.x,可能含 EasyTier)` }) + } + } + } + } + for (const [name, addrs] of Object.entries(nets)) { + if (!addrs) continue + for (const a of addrs) { + if (a.family !== 'IPv4' || a.internal) continue + if (a.address === '127.0.0.1') continue + if (seen.has(a.address)) continue + seen.add(a.address) + out.push({ address: a.address, name: `${name}(局域网)` }) + } + } + return out +} + +async function restartWeb2Backend(): Promise<{ ok: true } | { ok: false; error: string }> { + const br = _backendRoot ?? getBackendRoot() + const py = _pythonExe ?? resolvePythonExe(br) + web2Host = resolveWeb2Host() + await killPythonProcessForRestart() + startPythonBackend(br, py) + try { + await waitForHealth(90000) + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) } + } + return { ok: true } +} + +/** + * 切换监听地址 / 轮换 API 密钥时必须等旧 uvicorn 完全退出并释放端口。 + * Windows 上若仅 spawn taskkill 不等待,旧进程仍占用 8001,新进程启动失败,但 /health 仍由旧进程响应, + * waitForHealth 误判成功,随后旧进程被杀死 → 前端 ERR_CONNECTION_REFUSED。 + */ +async function killPythonProcessForRestart(): Promise { + if (!pythonChild || pythonChild.killed) { + pythonChild = null + return + } + const child = pythonChild + const pid = child.pid + pythonChild = null + if (process.platform === 'win32') { + try { + await execFileAsync('taskkill', ['/PID', String(pid), '/T', '/F'], { + windowsHide: true, + timeout: 25000, + maxBuffer: 1024 * 1024, + }) + } catch { + /* 进程已退出等 */ + } + await new Promise((r) => setTimeout(r, 500)) + } else { + try { + child.kill('SIGTERM') + } catch { + /* ignore */ + } + await new Promise((resolve) => { + const t = setTimeout(() => resolve(), 10000) + child.once('close', () => { + clearTimeout(t) + resolve() + }) + }) + await new Promise((r) => setTimeout(r, 200)) + } +} function getBackendRoot(): string { if (app.isPackaged) { @@ -23,6 +139,38 @@ function getBackendRoot(): string { return path.resolve(__dirname, '../../..') } +function resolveWeb2Host(): string { + const e = process.env.WEB2_HOST?.trim() + if (e) return e + const br = getBackendRoot() + const p = path.join(br, 'config', 'web2_bind_host.txt') + try { + if (fs.existsSync(p)) { + const line = fs.readFileSync(p, 'utf-8').trim().split('\n')[0]?.trim() + if (line) return line + } + } catch { + /* ignore */ + } + return '127.0.0.1' +} + +function resolveWeb2ApiTokenForChild(): string | undefined { + const e = process.env.WEB2_API_TOKEN?.trim() + if (e) return e + const br = getBackendRoot() + const p = path.join(br, 'config', 'web2_api_token.txt') + try { + if (fs.existsSync(p)) { + const line = fs.readFileSync(p, 'utf-8').trim().split('\n')[0]?.trim() + if (line) return line + } + } catch { + /* ignore */ + } + return undefined +} + function resolvePythonExe(backendRoot: string): string { if (process.env.PYTHON_EXE && fs.existsSync(process.env.PYTHON_EXE)) { return process.env.PYTHON_EXE @@ -68,6 +216,56 @@ function buildChildEnv(backendRoot: string): NodeJS.ProcessEnv { return env } +/** 开发模式:等 Vite 监听后再 loadURL,避免先打开窗口却加载失败导致黑屏 */ +function waitForDevServer(devUrl: string, timeoutMs = 60000): Promise { + return new Promise((resolve, reject) => { + let u: URL + try { + u = new URL(devUrl) + } catch { + reject(new Error(`无效的 ELECTRON_RENDERER_URL: ${devUrl}`)) + return + } + const port = u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80 + const start = Date.now() + const tryOnce = (): void => { + if (Date.now() - start > timeoutMs) { + reject( + new Error( + `开发服务器在 ${timeoutMs / 1000}s 内未响应(${devUrl})。请先保持 pnpm dev / electron-vite dev 运行,或检查端口是否被占用。`, + ), + ) + return + } + const req = http.get( + { + hostname: u.hostname, + port, + path: '/', + timeout: 2500, + }, + (res) => { + res.resume() + resolve() + }, + ) + req.on('error', () => { + req.destroy() + setTimeout(tryOnce, 350) + }) + req.on('timeout', () => { + req.destroy() + setTimeout(tryOnce, 350) + }) + } + tryOnce() + }) +} + +function healthCheckHost(): string { + return web2Host === '0.0.0.0' ? '127.0.0.1' : web2Host +} + function waitForHealth(timeoutMs = 120000): Promise { return new Promise((resolve, reject) => { const start = Date.now() @@ -77,7 +275,7 @@ function waitForHealth(timeoutMs = 120000): Promise { reject(new Error(`本地服务在 ${timeoutMs / 1000} 秒内未就绪。\n\n${stderrTail()}`)) return } - const req = http.get(`http://${HOST}:${PORT}/health`, (res) => { + const req = http.get(`http://${healthCheckHost()}:${PORT}/health`, (res) => { res.resume() if (res.statusCode === 200) { clearInterval(iv) @@ -93,13 +291,20 @@ function waitForHealth(timeoutMs = 120000): Promise { function startPythonBackend(backendRoot: string, pythonExe: string): void { stderrBuf = '' + const apiTok = resolveWeb2ApiTokenForChild() + if (web2Host === '0.0.0.0' && !apiTok) { + console.warn( + '[直播录制助手] web2 监听 0.0.0.0 但未设置 WEB2_API_TOKEN 或 config/web2_api_token.txt,API 无鉴权,请勿暴露到公网。', + ) + } const env = { ...buildChildEnv(backendRoot), PYTHONUNBUFFERED: '1', WEB2_SUPERVISOR: 'builtin', PYTHONIOENCODING: 'utf-8', + ...(apiTok ? { WEB2_API_TOKEN: apiTok } : {}), } - const args = ['-m', 'uvicorn', 'web2:app', '--host', HOST, '--port', String(PORT)] + const args = ['-m', 'uvicorn', 'web2:app', '--host', web2Host, '--port', String(PORT)] pythonChild = spawn(pythonExe, args, { cwd: backendRoot, env, @@ -141,13 +346,15 @@ function killPythonTree(): void { function resolvePreload(): string { const base = path.join(__dirname, '../preload') - const mjs = path.join(base, 'index.mjs') const js = path.join(base, 'index.js') + const mjs = path.join(base, 'index.mjs') + // 构建产物为 CJS 的 index.js;若目录里残留旧版 index.mjs,须优先使用 index.js + if (fs.existsSync(js)) return js if (fs.existsSync(mjs)) return mjs return js } -function createWindow(): void { +async function createWindow(): Promise { mainWindow = new BrowserWindow({ width: 1020, height: 720, @@ -167,7 +374,43 @@ function createWindow(): void { const devUrl = process.env.ELECTRON_RENDERER_URL if (devUrl) { - mainWindow.loadURL(devUrl) + try { + await waitForDevServer(devUrl) + } catch (e) { + dialog.showErrorBox('直播录制助手', e instanceof Error ? e.message : String(e)) + mainWindow.destroy() + mainWindow = null + return + } + mainWindow.webContents.once('did-finish-load', () => { + if (process.env.NODE_ENV === 'development' || devUrl.includes('localhost')) { + mainWindow?.webContents.openDevTools({ mode: 'detach' }) + } + }) + try { + for (let i = 0; i < 8; i++) { + try { + await mainWindow.loadURL(devUrl) + break + } catch { + if (i === 7) { + dialog.showErrorBox( + '直播录制助手', + `无法加载开发页面:${devUrl}\n请确认 Vite 已启动且端口未被占用。`, + ) + mainWindow.destroy() + mainWindow = null + return + } + await new Promise((r) => setTimeout(r, 400)) + } + } + } catch (e) { + dialog.showErrorBox('直播录制助手', e instanceof Error ? e.message : String(e)) + mainWindow.destroy() + mainWindow = null + return + } } else { mainWindow.loadFile(path.join(__dirname, '../renderer/index.html')) } @@ -189,8 +432,95 @@ if (!gotLock) { }) app.whenReady().then(async () => { + ipcMain.removeHandler('open-external') + ipcMain.handle('open-external', async (_evt, url: unknown) => { + if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) { + throw new Error('无效的链接地址') + } + await shell.openExternal(url) + }) + + ipcMain.removeHandler('web2:get-api-token') + ipcMain.handle('web2:get-api-token', () => resolveWeb2ApiTokenForChild() ?? null) + + ipcMain.removeHandler('remote:get-status') + ipcMain.handle('remote:get-status', () => { + const bindHost = resolveWeb2Host() + const et = getEasyTierRuntimeState() + const br = _backendRoot ?? getBackendRoot() + const easytierNetworkName = readEasyTierNetworkName(br) + return { + bindHost, + remoteEnabled: bindHost === '0.0.0.0', + port: PORT, + hasToken: !!resolveWeb2ApiTokenForChild(), + candidates: collectEasyTierCandidateIPs(), + envHostOverride: !!process.env.WEB2_HOST?.trim(), + easytierBundled: et.bundled, + easytierRunning: et.running, + easytierLastError: et.lastError, + easytierNetworkName, + } + }) + + ipcMain.removeHandler('remote:get-bind-payload') + ipcMain.handle('remote:get-bind-payload', (_evt, selectedIp: unknown) => { + if (typeof selectedIp !== 'string' || !selectedIp.trim()) return null + const br = _backendRoot ?? getBackendRoot() + const cfg = loadOrCreateNetworkConfig(br) + const token = resolveWeb2ApiTokenForChild() ?? '' + const ip = selectedIp.trim() + return { + v: 1 as const, + baseUrl: `http://${ip}:${PORT}`, + port: PORT, + token, + easytier: { + networkName: cfg.networkName, + networkSecret: cfg.networkSecret, + peerUrls: cfg.peerUrls, + }, + } + }) + + ipcMain.removeHandler('remote:set-enabled') + ipcMain.handle('remote:set-enabled', async (_evt, enabled: unknown) => { + if (process.env.WEB2_HOST?.trim()) { + return { + ok: false, + error: '已设置环境变量 WEB2_HOST,请改用环境变量或删除后再用界面开关。', + } + } + if (typeof enabled !== 'boolean') { + return { ok: false, error: '参数无效' } + } + const br = _backendRoot ?? getBackendRoot() + const cfgDir = path.join(br, 'config') + fs.mkdirSync(cfgDir, { recursive: true }) + fs.writeFileSync(path.join(cfgDir, 'web2_bind_host.txt'), enabled ? '0.0.0.0' : '127.0.0.1', 'utf-8') + const r = await restartWeb2Backend() + if (r.ok && enabled) startBundledEasyTier(br) + return r + }) + + ipcMain.removeHandler('remote:generate-token') + ipcMain.handle('remote:generate-token', async () => { + const br = _backendRoot ?? getBackendRoot() + const cfgDir = path.join(br, 'config') + fs.mkdirSync(cfgDir, { recursive: true }) + const tok = crypto.randomBytes(24).toString('hex') + fs.writeFileSync(path.join(cfgDir, 'web2_api_token.txt'), tok, 'utf-8') + const r = await restartWeb2Backend() + if (r.ok) startBundledEasyTier(br) + return r + }) + + web2Host = resolveWeb2Host() + const backendRoot = getBackendRoot() const pythonExe = resolvePythonExe(backendRoot) + _backendRoot = backendRoot + _pythonExe = pythonExe if (!fs.existsSync(path.join(backendRoot, 'web2.py'))) { dialog.showErrorBox( @@ -215,19 +545,23 @@ if (!gotLock) { return } - createWindow() + startBundledEasyTier(backendRoot) + + await createWindow() app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) createWindow() + if (BrowserWindow.getAllWindows().length === 0) void createWindow() }) }) app.on('window-all-closed', () => { + stopBundledEasyTier() killPythonTree() app.quit() }) app.on('before-quit', () => { + stopBundledEasyTier() killPythonTree() }) } diff --git a/electron/src/preload/index.ts b/electron/src/preload/index.ts index b3de91f..a5b66bd 100644 --- a/electron/src/preload/index.ts +++ b/electron/src/preload/index.ts @@ -1,5 +1,18 @@ -import { contextBridge } from 'electron' +import { contextBridge, ipcRenderer } from 'electron' contextBridge.exposeInMainWorld('recorderConsole', { platform: process.platform, + /** 沙箱下 preload 无可靠 shell,经主进程打开系统浏览器 */ + openExternal: (url: string) => ipcRenderer.invoke('open-external', url), +}) + +contextBridge.exposeInMainWorld('web2Remote', { + getApiToken: (): Promise => ipcRenderer.invoke('web2:get-api-token'), +}) + +contextBridge.exposeInMainWorld('remoteBinding', { + getStatus: () => ipcRenderer.invoke('remote:get-status'), + setRemoteEnabled: (enabled: boolean) => ipcRenderer.invoke('remote:set-enabled', enabled), + generateToken: () => ipcRenderer.invoke('remote:generate-token'), + getBindPayload: (selectedIp: string) => ipcRenderer.invoke('remote:get-bind-payload', selectedIp), }) diff --git a/electron/src/renderer/src/App.css b/electron/src/renderer/src/App.css index c42a354..51024b1 100644 --- a/electron/src/renderer/src/App.css +++ b/electron/src/renderer/src/App.css @@ -1179,6 +1179,153 @@ body.ready { } /* —— 录制代理 + 系统指标 —— */ +.app-panel--network { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.card--remote { + border-color: rgba(100, 149, 237, 0.28); + background: linear-gradient(165deg, rgba(100, 149, 237, 0.07) 0%, var(--bg-card) 100%); +} + +.remote-link { + color: var(--accent, #6d9eff); + text-decoration: underline; + text-underline-offset: 2px; +} + +.remote-code { + font-family: ui-monospace, monospace; + font-size: 12px; + color: #b8c0cc; +} + +.remote-et-status { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 0.75rem; + margin-bottom: 0.75rem; +} + +.remote-et-pill { + font-size: 12px; + padding: 0.2rem 0.55rem; + border-radius: 999px; +} + +.remote-et-pill.on { + background: rgba(61, 214, 140, 0.15); + color: #9ee6c0; +} + +.remote-et-pill.off { + background: rgba(139, 146, 158, 0.15); + color: #8b929e; +} + +.remote-et-pill.warn { + background: rgba(245, 166, 35, 0.15); + color: #e8c07d; +} + +.remote-et-meta { + font-size: 12px; + color: var(--text-muted, #8b929e); +} + +.remote-warn { + margin: 0 0 0.75rem; + padding: 0.6rem 0.75rem; + border-radius: 8px; + background: rgba(245, 166, 35, 0.12); + border: 1px solid rgba(245, 166, 35, 0.35); + color: #e8c07d; + font-size: 13px; +} + +.remote-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem 1rem; + margin-bottom: 0.75rem; +} + +.remote-toggle-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + user-select: none; +} + +.remote-toggle-input { + width: 1.1rem; + height: 1.1rem; + accent-color: var(--accent, #6d9eff); +} + +.remote-token-badge { + font-size: 13px; + padding: 0.2rem 0.6rem; + border-radius: 999px; +} + +.remote-token-badge.on { + background: rgba(61, 214, 140, 0.15); + color: #9ee6c0; +} + +.remote-token-badge.off { + background: rgba(245, 101, 101, 0.12); + color: #f0a0a0; +} + +.remote-qr-block { + margin-top: 0.5rem; + padding: 1rem; + border-radius: 10px; + background: rgba(0, 0, 0, 0.2); + border: 1px solid rgba(255, 255, 255, 0.06); + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; +} + +.remote-qr-hint { + margin: 0; + font-size: 13px; + color: var(--text-muted, #8b929e); +} + +.remote-qr-img { + border-radius: 8px; + background: #fff; + padding: 8px; +} + +.remote-qr-json { + margin: 0; + padding: 0.75rem; + font-size: 12px; + line-height: 1.45; + max-width: 100%; + overflow: auto; + border-radius: 8px; + background: rgba(0, 0, 0, 0.35); + color: #c8cdd5; +} + +.remote-error { + margin: 0.5rem 0 0; + color: #f56565; + font-size: 13px; +} + .card--proxy { border-color: rgba(61, 214, 140, 0.22); background: linear-gradient(165deg, rgba(61, 214, 140, 0.06) 0%, var(--bg-card) 100%); @@ -1228,6 +1375,39 @@ body.ready { background: linear-gradient(165deg, rgba(124, 92, 255, 0.07) 0%, var(--bg-card) 100%); } +/* YouTube OAuth 行 */ +.yt-oauth-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-bottom: 14px; + padding: 12px 14px; + border-radius: var(--radius-md); + background: rgba(91, 157, 255, 0.06); + border: 1px solid rgba(91, 157, 255, 0.15); +} + +.yt-oauth-badge { + font-size: 12px; + font-weight: 650; + padding: 4px 10px; + border-radius: var(--radius-pill); + margin-right: 4px; +} + +.yt-oauth-badge--on { + color: var(--success); + background: var(--success-soft); + border: 1px solid rgba(61, 214, 140, 0.35); +} + +.yt-oauth-badge--off { + color: var(--text-muted); + background: var(--bg-card-muted); + border: 1px solid var(--border-card); +} + .metrics-grid { display: grid; grid-template-columns: repeat(3, 1fr); diff --git a/electron/src/renderer/src/App.tsx b/electron/src/renderer/src/App.tsx index 08143eb..ad441a0 100644 --- a/electron/src/renderer/src/App.tsx +++ b/electron/src/renderer/src/App.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { apiUrl } from './api' +import { apiFetch, apiUrl } from './api' import NetworkDashboard from './NetworkDashboard' +import RemoteBinding from './RemoteBinding' import ProxySettings from './ProxySettings' import SystemMetrics from './SystemMetrics' -import { UI_DROPDOWN_YOUTUBE_ONLY } from './constants' +import { UI_DROPDOWN_YOUTUBE_ONLY, UI_SHOW_YOUTUBE_GOOGLE_OAUTH } from './constants' import { isObsScript, isTiktokScript, isYoutubeScript, pm2NameFromScript } from './scriptUtils' type Entry = { script: string; label: string; pm2: string } @@ -61,6 +62,9 @@ export default function App() { const [saveYoutubeLoading, setSaveYoutubeLoading] = useState(false) const [saveUrlLoading, setSaveUrlLoading] = useState(false) + const [ytOauth, setYtOauth] = useState<{ connected: boolean; has_refresh: boolean } | null>(null) + const [fetchKeyLoading, setFetchKeyLoading] = useState(false) + const [nav, setNav] = useState('record') const logRef = useRef(null) @@ -86,7 +90,7 @@ export default function App() { const isObs = ent ? isObsScript(script) : false const loadProcessMonitor = useCallback(async () => { - const res = await fetch(apiUrl('/process_monitor')) + const res = await apiFetch(apiUrl('/process_monitor')) if (!res.ok) throw new Error(String(res.status)) const data = await res.json() const raw: Entry[] = (data.entries || []).map( @@ -105,7 +109,7 @@ export default function App() { async (endpoint: string, setContent: (s: string) => void, setSt: (s: string) => void) => { setSt('正在读取…') try { - const res = await fetch(`${apiUrl(endpoint)}?process=${encodeURIComponent(currentProcess)}`) + const res = await apiFetch(`${apiUrl(endpoint)}?process=${encodeURIComponent(currentProcess)}`) if (!res.ok) throw new Error(`连接失败 ${res.status}`) const data = await res.json() setContent(data.content || '') @@ -117,6 +121,61 @@ export default function App() { [currentProcess], ) + const loadYtOauthStatus = useCallback(async () => { + try { + const res = await apiFetch(apiUrl('/youtube/oauth/status')) + if (!res.ok) throw new Error(String(res.status)) + const data = (await res.json()) as { + connected?: boolean + has_refresh?: boolean + } + setYtOauth({ + connected: !!data.connected, + has_refresh: !!data.has_refresh, + }) + } catch { + setYtOauth(null) + } + }, []) + + const startYoutubeOAuth = useCallback(async () => { + setYoutubeStatus('正在获取授权地址…') + try { + const res = await apiFetch(apiUrl('/youtube/oauth/start')) + const data = (await res.json()) as { url?: string; error?: string } + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`) + const url = data.url + if (!url) throw new Error('未返回授权地址') + const open = window.recorderConsole?.openExternal + if (open) { + await open(url) + } else { + window.open(url, '_blank', 'noopener,noreferrer') + } + setYoutubeStatus('请在浏览器中完成授权') + } catch (err) { + setYoutubeStatus(`授权失败:${err instanceof Error ? err.message : String(err)}`) + } + }, []) + + const fetchYoutubeStreamKey = useCallback(async () => { + setFetchKeyLoading(true) + try { + const res = await apiFetch( + `${apiUrl('/youtube/stream/fetch_key')}?process=${encodeURIComponent(currentProcess)}`, + { method: 'POST' }, + ) + const data = (await res.json()) as { error?: string; message?: string } + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`) + if (data.message) setYoutubeStatus(data.message) + await loadConfig('/get_config', setYoutubeText, setYoutubeStatus) + } catch (err) { + setYoutubeStatus(`拉取失败:${err instanceof Error ? err.message : String(err)}`) + } finally { + setFetchKeyLoading(false) + } + }, [currentProcess, loadConfig]) + const saveConfig = useCallback( async ( endpoint: string, @@ -126,7 +185,7 @@ export default function App() { ) => { setSt('正在保存…') try { - const res = await fetch(`${apiUrl(endpoint)}?process=${encodeURIComponent(currentProcess)}`, { + const res = await apiFetch(`${apiUrl(endpoint)}?process=${encodeURIComponent(currentProcess)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }), @@ -148,7 +207,7 @@ export default function App() { const isStatus = action === 'status' if (!isStatus) setLoadingAction(action) try { - const res = await fetch(`${apiUrl(`/${action}`)}?process=${encodeURIComponent(currentProcess)}`) + const res = await apiFetch(`${apiUrl(`/${action}`)}?process=${encodeURIComponent(currentProcess)}`) if (!res.ok) throw new Error('无法连接到本机服务') const data = await res.json() @@ -257,6 +316,13 @@ export default function App() { if (isYoutube || isTiktok) void loadConfig('/get_url_config', setUrlText, setUrlStatus) }, [ent, currentProcess, isYoutube, isTiktok, loadConfig]) + useEffect(() => { + if (!UI_SHOW_YOUTUBE_GOOGLE_OAUTH || !isYoutube) return + void loadYtOauthStatus() + const id = window.setInterval(() => void loadYtOauthStatus(), 5000) + return () => clearInterval(id) + }, [isYoutube, loadYtOauthStatus]) + const onProcessChange = (v: string) => { setCurrentProcess(v) } @@ -340,7 +406,8 @@ export default function App() {
{nav === 'network' && ( -
+
+
)} @@ -410,9 +477,39 @@ export default function App() { {isYoutube && (

YouTube 相关设置

-

一般不用改;需要时改完点「保存」即可。

+

+ {UI_SHOW_YOUTUBE_GOOGLE_OAUTH ? ( + <> + 可手动编辑下方配置,或通过 Google 官方授权后一键拉取串流密钥(写入 key=)。首次使用需在 Google Cloud + 控制台为 OAuth 客户端添加重定向 URI:http://127.0.0.1:8001/youtube/oauth/callback + + ) : ( + <>可手动编辑下方配置并保存;串流密钥请自行写入 key=。 + )} +

+ {UI_SHOW_YOUTUBE_GOOGLE_OAUTH && ( +
+ + {ytOauth?.connected ? '已连接 Google' : '未连接 Google'} + + + +
+ )}
- 设置内容 + 设置内容(youtube.ini)