This commit is contained in:
eric
2026-03-24 12:55:52 -05:00
parent 9e812215d2
commit 6ceadb7e0d
33 changed files with 1941 additions and 39 deletions

10
.gitignore vendored
View File

@@ -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/

View File

@@ -0,0 +1,5 @@
{
"networkName": "lr_自动生成",
"networkSecret": "自动生成请勿手填",
"peerUrls": []
}

View File

@@ -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"]
}
}

View File

@@ -0,0 +1 @@
changeme_copy_to_web2_api_token_txt_and_replace_with_random_secret

View File

@@ -0,0 +1 @@
127.0.0.1

72
docs/p2p-remote.md Normal file
View File

@@ -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://<PC的虚拟IP>: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://<PC的EasyTier虚拟IP>: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`(若需)。

View File

@@ -8,6 +8,14 @@ export default defineConfig({
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
output: {
format: 'cjs',
entryFileNames: 'index.js',
},
},
},
},
renderer: {
resolve: {

View File

@@ -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",

162
electron/pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -0,0 +1 @@
2026-03-24T17:49:18.788Z

View File

@@ -0,0 +1,3 @@
EasyTier 以 LGPL-3.0 授权发布。
源码: https://github.com/EasyTier/EasyTier
本程序随官方 GitHub Release 附带 easytier-core.exe 及同目录依赖,未修改二进制。

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,103 @@
/**
* 下载官方 EasyTier Windows x64 发行包并解压到 resources/easytier/
* 须复制 exe 同目录下全部 DLL 等依赖,否则 easytier-core 会以 0xC00001353221225781退出。
* 设置 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)

View File

@@ -0,0 +1,158 @@
/**
* 内置 EasyTierWindows随应用分发 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<string, unknown>
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<string, unknown>
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
}

View File

@@ -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再补充 WiFi/以太网等局域网 IPv4
* 避免仅有虚拟网时列表为空、用户无处选 IP。
*/
function collectEasyTierCandidateIPs(): { address: string; name: string }[] {
const nets = networkInterfaces()
const seen = new Set<string>()
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<void> {
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<void>((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<void> {
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<void> {
return new Promise((resolve, reject) => {
const start = Date.now()
@@ -77,7 +275,7 @@ function waitForHealth(timeoutMs = 120000): Promise<void> {
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<void> {
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.txtAPI 无鉴权,请勿暴露到公网。',
)
}
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<void> {
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()
})
}

View File

@@ -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<string | null> => 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),
})

View File

@@ -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);

View File

@@ -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<NavId>('record')
const logRef = useRef<HTMLPreElement>(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() {
<div className="app-workspace__scroll">
{nav === 'network' && (
<div className="app-panel">
<div className="app-panel app-panel--network">
<RemoteBinding />
<NetworkDashboard />
</div>
)}
@@ -410,9 +477,39 @@ export default function App() {
{isYoutube && (
<section className="card">
<h2 className="card__title">YouTube </h2>
<p className="card__desc"></p>
<p className="card__desc">
{UI_SHOW_YOUTUBE_GOOGLE_OAUTH ? (
<>
Google key=使 Google Cloud
OAuth URIhttp://127.0.0.1:8001/youtube/oauth/callback
</>
) : (
<> key=</>
)}
</p>
{UI_SHOW_YOUTUBE_GOOGLE_OAUTH && (
<div className="yt-oauth-bar">
<span
className={`yt-oauth-badge ${ytOauth?.connected ? 'yt-oauth-badge--on' : 'yt-oauth-badge--off'}`}
>
{ytOauth?.connected ? '已连接 Google' : '未连接 Google'}
</span>
<button type="button" className="btn btn-secondary" onClick={() => void startYoutubeOAuth()}>
Google
</button>
<button
type="button"
className={`btn btn-primary ${fetchKeyLoading ? 'loading' : ''}`}
disabled={!ytOauth?.connected || fetchKeyLoading}
title={!ytOauth?.connected ? '请先完成 Google 授权' : ''}
onClick={() => void fetchYoutubeStreamKey()}
>
{fetchKeyLoading ? '拉取中…' : '拉取串流密钥并写入配置'}
</button>
</div>
)}
<div className="card__field">
<span className="card__field-label"></span>
<span className="card__field-label">youtube.ini</span>
<textarea
id="youtubeConfig"
className="input-area"

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { apiUrl } from './api'
import { apiFetch, apiUrl } from './api'
type SiteInfo = {
host: string
@@ -75,7 +75,7 @@ export default function NetworkDashboard() {
setLoading(true)
setErr(null)
try {
const res = await fetch(apiUrl('/network_status'))
const res = await apiFetch(apiUrl('/network_status'))
const json = (await res.json()) as NetworkPayload & { error?: string }
if (!res.ok) {
setErr(json.error || `请求失败 ${res.status}`)

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { apiUrl } from './api'
import { apiFetch, apiUrl } from './api'
type ProxyCfg = { enabled: boolean; http: string; socks: string }
@@ -13,7 +13,7 @@ export default function ProxySettings() {
setLoading(true)
setMsg('')
try {
const res = await fetch(apiUrl('/proxy_config'))
const res = await apiFetch(apiUrl('/proxy_config'))
const data = (await res.json()) as ProxyCfg
if (!res.ok) throw new Error('读取失败')
setCfg({
@@ -36,7 +36,7 @@ export default function ProxySettings() {
setSaving(true)
setMsg('')
try {
const res = await fetch(apiUrl('/proxy_config'), {
const res = await apiFetch(apiUrl('/proxy_config'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg),

View File

@@ -0,0 +1,345 @@
import { useCallback, useEffect, useState } from 'react'
import QRCode from 'qrcode'
import { initApiFromMain } from './api'
type RemoteStatus = {
bindHost: string
remoteEnabled: boolean
port: number
hasToken: boolean
candidates: { address: string; name: string }[]
envHostOverride: boolean
easytierBundled: boolean
easytierRunning: boolean
easytierLastError: string
easytierNetworkName: string
}
type RemoteBindingApi = {
getStatus: () => Promise<RemoteStatus>
setRemoteEnabled: (enabled: boolean) => Promise<{ ok: true } | { ok: false; error: string }>
generateToken: () => Promise<{ ok: true } | { ok: false; error: string }>
getBindPayload: (selectedIp: string) => Promise<Record<string, unknown> | null>
}
/** React Native 扫码JSON 含 baseUrl、token、easytier组网名/密钥/可选 peer */
export default function RemoteBinding() {
const rb = (typeof window !== 'undefined'
? (window as Window & { remoteBinding?: RemoteBindingApi }).remoteBinding
: undefined) as RemoteBindingApi | undefined
const [status, setStatus] = useState<RemoteStatus | null>(null)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [selectedIp, setSelectedIp] = useState('')
const [manualHost, setManualHost] = useState('')
const [qrImg, setQrImg] = useState<string | null>(null)
const [bindPayload, setBindPayload] = useState<Record<string, unknown> | null>(null)
const pickBindIp = useCallback((): string => {
if (!status?.candidates?.length) return manualHost.trim()
if (selectedIp.trim()) return selectedIp.trim()
return manualHost.trim()
}, [status?.candidates?.length, selectedIp, manualHost])
const load = useCallback(async () => {
if (!rb?.getStatus) {
setLoading(false)
setMsg('仅 Electron 桌面端支持此面板;网页调试请直接配置 API。')
return
}
setLoading(true)
try {
const s = await rb.getStatus()
setStatus(s)
setMsg('')
} catch (e) {
setStatus(null)
setMsg(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [rb])
useEffect(() => {
void load()
}, [load])
useEffect(() => {
if (!status?.candidates?.length) return
setSelectedIp((prev) => {
if (prev && status.candidates.some((c) => c.address === prev)) return prev
return status.candidates[0].address
})
}, [status])
useEffect(() => {
if (!status?.remoteEnabled || !rb?.getBindPayload) {
setBindPayload(null)
return
}
const ip = pickBindIp()
if (!ip) {
setBindPayload(null)
return
}
let cancelled = false
void rb.getBindPayload(ip).then((p) => {
if (!cancelled) setBindPayload(p)
})
return () => {
cancelled = true
}
}, [status?.remoteEnabled, rb, status?.hasToken, pickBindIp])
useEffect(() => {
if (!bindPayload) {
setQrImg(null)
return
}
const json = JSON.stringify(bindPayload)
void QRCode.toDataURL(json, { margin: 2, width: 224 }).then(setQrImg).catch(() => setQrImg(null))
}, [bindPayload])
const onToggle = async (enabled: boolean) => {
if (!rb?.setRemoteEnabled) return
setBusy(true)
setMsg('')
try {
const r = await rb.setRemoteEnabled(enabled)
if (!r.ok) {
setMsg(r.error)
return
}
await initApiFromMain()
await load()
} finally {
setBusy(false)
}
}
const onGenerateToken = async () => {
if (!rb?.generateToken) return
setBusy(true)
setMsg('')
try {
const r = await rb.generateToken()
if (!r.ok) {
setMsg(r.error)
return
}
await initApiFromMain()
await load()
} finally {
setBusy(false)
}
}
const onGenerateQr = async () => {
if (!rb?.getBindPayload) return
if (!status?.remoteEnabled) {
setMsg('请先勾选「允许局域网 / 虚拟网访问」并等待后端重启完成。')
return
}
const ip = pickBindIp()
if (!ip) {
const hasList = !!status.candidates?.length
setMsg(
hasList
? '请从上方下拉选择本机 IP或在「手动 IP」中填写手机能访问的地址。'
: '未检测到本机局域网 IP请在下方「手动 IP」中输入例如 192.168.x.x 或与手机同网段地址、EasyTier 虚拟 IP或点「刷新 IP 列表」后重试。',
)
return
}
setBusy(true)
setMsg('')
try {
const p = await rb.getBindPayload(ip)
setBindPayload(p)
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}
if (loading) {
return (
<section className="card card--remote" aria-busy>
<h2 className="card__title"></h2>
<p className="card__desc"></p>
</section>
)
}
if (!rb?.getStatus) {
return (
<section className="card card--remote">
<h2 className="card__title"></h2>
<p className="card__desc">{msg || '不可用'}</p>
</section>
)
}
if (!status) {
return (
<section className="card card--remote">
<h2 className="card__title"></h2>
<p className="remote-error" role="alert">
{msg || '无法加载状态'}
</p>
<button type="button" className="btn btn-secondary" onClick={() => void load()}>
</button>
</section>
)
}
return (
<section className="card card--remote" aria-labelledby="remote-binding-title">
<h2 className="card__title" id="remote-binding-title">
</h2>
<p className="card__desc">
Windows {' '}
<a href="https://github.com/EasyTier/EasyTier" target="_blank" rel="noreferrer" className="remote-link">
EasyTier
</a>{' '}
App 使 EasyTier JSON {' '}
<code className="remote-code">easytier</code> 0.0.0.0 API
</p>
<div className="remote-et-status" role="status">
<span className={`remote-et-pill ${status.easytierBundled ? 'on' : 'off'}`}>
{status.easytierBundled ? '已内置 easytier-core' : '未找到内置程序(开发环境请执行 pnpm run prepare-easytier'}
</span>
{status.easytierBundled && (
<span className={`remote-et-pill ${status.easytierRunning ? 'on' : 'warn'}`}>
{status.easytierRunning ? 'EasyTier 进程运行中' : '进程未运行或已退出'}
</span>
)}
{!!status.easytierNetworkName && (
<span className="remote-et-meta">{status.easytierNetworkName}</span>
)}
</div>
{status.easytierBundled && status.easytierLastError && (
<p className="remote-warn" role="status">
EasyTier{status.easytierLastError}
</p>
)}
{status?.envHostOverride && (
<p className="remote-warn" role="status">
WEB2_HOST
</p>
)}
<div className="remote-row">
<label className="remote-toggle-label">
<input
type="checkbox"
className="remote-toggle-input"
checked={!!status?.remoteEnabled}
disabled={busy || !!status?.envHostOverride}
onChange={(e) => void onToggle(e.target.checked)}
/>
<span> / 访 0.0.0.0</span>
</label>
</div>
<div className="remote-row">
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => void onGenerateToken()}>
API
</button>
<span className={`remote-token-badge ${status?.hasToken ? 'on' : 'off'}`}>
{status?.hasToken ? '已配置密钥' : '未配置密钥(远程时强烈建议)'}
</span>
</div>
<div className="remote-row">
<label className="field-label" htmlFor="remoteEtIp">
IP /
</label>
<select
id="remoteEtIp"
className="select-process"
value={selectedIp}
onChange={(e) => setSelectedIp(e.target.value)}
disabled={!status?.candidates?.length}
>
{(status?.candidates?.length
? status.candidates
: [{ address: '', name: '(暂无候选,请用下方手动 IP 或点刷新)' }]
).map((c) => (
<option key={c.address || 'none'} value={c.address} disabled={!c.address}>
{c.address ? `${c.address}${c.name}` : c.name}
</option>
))}
</select>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => void load()}>
IP
</button>
</div>
<div className="remote-row">
<label className="field-label" htmlFor="remoteManualIp">
IP
</label>
<input
id="remoteManualIp"
type="text"
className="select-process"
placeholder="例如 192.168.1.10、10.126.126.1"
value={manualHost}
onChange={(e) => setManualHost(e.target.value)}
disabled={busy}
autoComplete="off"
/>
</div>
<div className="remote-row">
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void onGenerateQr()}>
</button>
<span className="remote-qr-hint"> React Native 访 IP</span>
</div>
{!status.remoteEnabled && (
<p className="remote-qr-hint">访 API </p>
)}
{status.remoteEnabled && bindPayload && qrImg && (
<div className="remote-qr-block">
<p className="remote-qr-hint">
React Native JSON <code className="remote-code">baseUrl</code><code className="remote-code">token</code>
<code className="remote-code">easytier</code>使
</p>
<img src={qrImg} alt="绑定信息二维码" className="remote-qr-img" width={224} height={224} />
<pre className="remote-qr-json" tabIndex={0}>
{JSON.stringify(bindPayload, null, 2)}
</pre>
<button
type="button"
className="btn btn-secondary"
onClick={() => void navigator.clipboard.writeText(JSON.stringify(bindPayload))}
>
JSON
</button>
</div>
)}
{status.remoteEnabled && !bindPayload && (
<p className="remote-qr-hint">
IP IP IP
</p>
)}
{msg && (
<p className="remote-error" role="alert">
{msg}
</p>
)}
</section>
)
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { apiUrl } from './api'
import { apiFetch, apiUrl } from './api'
type Metrics = {
ts?: number
@@ -32,7 +32,7 @@ export default function SystemMetrics() {
const load = useCallback(async () => {
try {
const res = await fetch(apiUrl('/system_metrics'))
const res = await apiFetch(apiUrl('/system_metrics'))
const json = (await res.json()) as Metrics
if (!res.ok) {
setErr(json.error || `HTTP ${res.status}`)

View File

@@ -1,6 +1,44 @@
const base = import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8001'
/** 由 Electron 主进程从 config/web2_api_token.txt 注入;无则走 VITE / localStorage */
let bearerFromMain: string | null = null
export function apiUrl(path: string): string {
const p = path.startsWith('/') ? path : `/${path}`
return `${base.replace(/\/$/, '')}${p}`
}
function getBearerToken(): string | null {
if (bearerFromMain) return bearerFromMain
const v = import.meta.env.VITE_WEB2_API_TOKEN
if (v && String(v).trim()) return String(v).trim()
try {
return localStorage.getItem('web2_api_token')
} catch {
return null
}
}
export async function initApiFromMain(): Promise<void> {
bearerFromMain = null
try {
const w = (window as Window & { web2Remote?: { getApiToken?: () => Promise<string | null> } }).web2Remote
const t = await w?.getApiToken?.()
if (t && String(t).trim()) bearerFromMain = String(t).trim()
} catch {
/* 非 Electron 或 preload 未注入 */
}
if (!bearerFromMain) {
const v = import.meta.env.VITE_WEB2_API_TOKEN
if (v && String(v).trim()) bearerFromMain = String(v).trim()
}
}
export function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const headers = new Headers(init?.headers)
const token = getBearerToken()
if (token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`)
}
return fetch(input, { ...init, headers })
}

View File

@@ -1,2 +1,5 @@
/** 为 true 时:下拉只展示 youtube*.py */
export const UI_DROPDOWN_YOUTUBE_ONLY = true
/** 为 true 时YouTube 卡片显示「Google 授权 / 拉取串流密钥」false 仅保留手动编辑配置(后端路由仍保留) */
export const UI_SHOW_YOUTUBE_GOOGLE_OAUTH = false

View File

@@ -1,10 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { initApiFromMain } from './api'
import App from './App'
import './App.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
void initApiFromMain().then(() => {
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
})

View File

@@ -2,6 +2,7 @@
interface ImportMetaEnv {
readonly VITE_API_BASE: string
readonly VITE_WEB2_API_TOKEN?: string
}
interface ImportMeta {
@@ -11,5 +12,26 @@ interface ImportMeta {
interface Window {
recorderConsole?: {
platform: string
openExternal?: (url: string) => Promise<void>
}
web2Remote?: {
getApiToken?: () => Promise<string | null>
}
remoteBinding?: {
getStatus: () => Promise<{
bindHost: string
remoteEnabled: boolean
port: number
hasToken: boolean
candidates: { address: string; name: string }[]
envHostOverride: boolean
easytierBundled: boolean
easytierRunning: boolean
easytierLastError: string
easytierNetworkName: string
}>
setRemoteEnabled: (enabled: boolean) => Promise<{ ok: true } | { ok: false; error: string }>
generateToken: () => Promise<{ ok: true } | { ok: false; error: string }>
getBindPayload: (selectedIp: string) => Promise<Record<string, unknown> | null>
}
}

View File

@@ -7,4 +7,7 @@ httpx[http2]>=0.28.1
PyExecJS>=1.5.1
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
psutil>=6.0.0
psutil>=6.0.0
google-auth>=2.27.0
google-auth-oauthlib>=1.2.0
google-api-python-client>=2.130.0

88
web2.py
View File

@@ -1,7 +1,9 @@
from fastapi import FastAPI, Query, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from pathlib import Path
import asyncio
import html
import os
from typing import Optional
from fastapi.middleware.cors import CORSMiddleware
@@ -17,6 +19,12 @@ from web2_supervisor import (
from web2_network import run_network_dashboard
from web2_proxy_config import read_proxy_config, write_proxy_config
from web2_system_metrics import snapshot as system_metrics_snapshot
from web2_youtube_oauth import (
fetch_stream_key_and_write_ini,
oauth_authorization_url,
oauth_exchange_code,
oauth_status as youtube_oauth_status_snapshot,
)
app = FastAPI(title="多进程录制控制台")
@@ -147,14 +155,36 @@ async def control_get_process_info(process: str) -> dict:
return await get_process_info_pm2(process, DEFAULT_LOG_DIR, strip_ansi_codes)
# CORS
@app.middleware("http")
async def web2_token_middleware(request: Request, call_next):
"""若设置环境变量 WEB2_API_TOKEN则除 /health 与 OpenAPI 外需携带 Bearer 或 X-API-Token。"""
# CORS 预检不带 Authorization若在外层先于 CORSMiddleware 拦截会缺少 Access-Control-* 头
if request.method == "OPTIONS":
return await call_next(request)
token = os.environ.get("WEB2_API_TOKEN", "").strip()
if not token:
return await call_next(request)
path = request.url.path
if path == "/health" or path.startswith("/docs") or path in ("/openapi.json", "/redoc"):
return await call_next(request)
auth = request.headers.get("authorization") or ""
xtok = (request.headers.get("x-api-token") or "").strip()
if auth.startswith("Bearer ") and auth[7:].strip() == token:
return await call_next(request)
if xtok == token:
return await call_next(request)
return JSONResponse({"error": "unauthorized"}, status_code=401)
# 最后注册,保证 CORSMiddleware 在最外层401 等响应仍带 CORS 头(供 Vite 等跨源调试)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET", "POST"],
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["*"],
)
# ---------------- 工具函数 ----------------
async def read_log_path(path: str, lines: int) -> str:
if not path:
@@ -361,6 +391,58 @@ async def system_metrics():
return JSONResponse({"error": str(e)}, status_code=500)
# ---------------- YouTube OAuth串流密钥 ----------------
@app.get("/youtube/oauth/start")
async def youtube_oauth_start():
"""返回 Google 授权页 URL前端应用浏览器打开。"""
try:
url, state = oauth_authorization_url()
return {"url": url, "state": state}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=400)
@app.get("/youtube/oauth/callback")
async def youtube_oauth_callback(code: str = Query(...), state: str = Query(...)):
"""Google 授权完成后的回调(须与 google_oauth.json 中 redirect_uri 一致)。"""
try:
oauth_exchange_code(code, state)
except Exception as e:
err = html.escape(str(e))
return HTMLResponse(
f"<!DOCTYPE html><html><head><meta charset='utf-8'><title>授权失败</title></head>"
f"<body style='font-family:system-ui;padding:24px;background:#1a1d24;color:#f56565;'>"
f"<p>授权失败:{err}</p><p style='color:#8b929e;font-size:13px;'>请关闭此页后重试。</p></body></html>",
status_code=400,
)
return HTMLResponse(
"<!DOCTYPE html><html><head><meta charset='utf-8'><title>授权成功</title></head>"
"<body style='font-family:system-ui;padding:24px;background:#1a1d24;color:#e8eaed;'>"
"<p>授权成功,可关闭此页并返回「直播录制助手」。</p>"
"<p style='color:#8b929e;font-size:13px;'>若浏览器未自动关闭,请手动关闭本标签页。</p>"
"</body></html>"
)
@app.get("/youtube/oauth/status")
async def youtube_oauth_status_route():
return youtube_oauth_status_snapshot()
@app.post("/youtube/stream/fetch_key")
async def youtube_stream_fetch_key(process: str = Query(..., description="进程名")):
"""调用 YouTube Live API 获取串流密钥并写入 config/youtube.ini。"""
if process not in VALID_PROCESSES:
return JSONResponse({"error": "无效进程名"}, status_code=400)
sc = script_for_pm2(process)
if not sc or not is_youtube_script(sc):
return JSONResponse({"error": "仅 youtube*.py 对应进程可使用此功能"}, status_code=400)
try:
return fetch_stream_key_and_write_ini()
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
# ---------------- 进程列表(供前端同步,由 discover_script_entries 自动扫描) ----------------
@app.get("/process_monitor")
async def process_monitor():

239
web2_youtube_oauth.py Normal file
View File

@@ -0,0 +1,239 @@
"""
YouTube OAuth 2.0 + Live Streaming API授权后拉取串流密钥并写入 config/youtube.ini。
需在 Google Cloud Console 为本 OAuth 客户端添加「已授权的重定向 URI」
http://127.0.0.1:8001/youtube/oauth/callback
(若本地服务端口不是 8001请同时改 redirect_uri 与 uvicorn 端口。)
"""
from __future__ import annotations
import json
import os
import time
from pathlib import Path
from typing import Any, Optional
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import Flow
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
BASE_DIR = Path(__file__).resolve().parent
CONFIG_DIR = BASE_DIR / "config"
GOOGLE_OAUTH_JSON = CONFIG_DIR / "google_oauth.json"
TOKEN_JSON = CONFIG_DIR / "youtube_oauth_token.json"
YOUTUBE_INI = CONFIG_DIR / "youtube.ini"
# 须与 Google 在换 token 时返回的 scope 集合一致,否则 oauthlib 会报
# “Scope has changed from … to …”。include_granted_scopes 会合并历史授权,也易触发该错误。
SCOPES = [
"https://www.googleapis.com/auth/youtube",
"https://www.googleapis.com/auth/youtube.force-ssl",
"https://www.googleapis.com/auth/youtubepartner",
]
# state -> (Flow, created_ts);回调必须用同一 Flow 实例换 token
_oauth_pending: dict[str, tuple[Flow, float]] = {}
STATE_TTL = 600.0
def _cleanup_pending() -> None:
now = time.time()
dead = [s for s, (_, t) in _oauth_pending.items() if now - t > STATE_TTL]
for s in dead:
_oauth_pending.pop(s, None)
def load_client_config_dict() -> tuple[dict[str, Any], str]:
if not GOOGLE_OAUTH_JSON.exists():
raise FileNotFoundError(
"缺少 config/google_oauth.json。请复制 config/google_oauth.example.json 并填入客户端 ID/Secret。"
)
data = json.loads(GOOGLE_OAUTH_JSON.read_text(encoding="utf-8"))
redirect_uri = data.get("redirect_uri") or "http://127.0.0.1:8001/youtube/oauth/callback"
if "web" in data:
cfg = {"web": data["web"]}
elif "installed" in data:
inst = data["installed"]
cfg = {
"web": {
"client_id": inst["client_id"],
"client_secret": inst["client_secret"],
"auth_uri": inst.get("auth_uri", "https://accounts.google.com/o/oauth2/auth"),
"token_uri": inst.get("token_uri", "https://oauth2.googleapis.com/token"),
"redirect_uris": [redirect_uri],
}
}
else:
raise ValueError("google_oauth.json 需包含 web 或 installed 字段")
return cfg, redirect_uri
def build_flow() -> tuple[Flow, str]:
cfg, redirect_uri = load_client_config_dict()
flow = Flow.from_client_config(cfg, scopes=SCOPES, redirect_uri=redirect_uri)
return flow, redirect_uri
def oauth_authorization_url() -> tuple[str, str]:
import secrets
_cleanup_pending()
flow, _ = build_flow()
state = secrets.token_urlsafe(32)
result = flow.authorization_url(
access_type="offline",
state=state,
prompt="consent",
)
authorization_url = result[0] if isinstance(result, (list, tuple)) else result
_oauth_pending[state] = (flow, time.time())
return authorization_url, state
def oauth_exchange_code(code: str, state: str) -> None:
_cleanup_pending()
if state not in _oauth_pending:
raise ValueError("授权会话无效或已过期请在本软件中重新点击「Google 授权」。")
flow, t0 = _oauth_pending.pop(state)
if time.time() - t0 > STATE_TTL:
raise ValueError("授权已超时请重新点击「Google 授权」。")
# oauthlib 要求令牌里的 scope 与客户端声明完全一致Google 常返回多枚 scope 或与声明略有差异
_prev_relax = os.environ.get("OAUTHLIB_RELAX_TOKEN_SCOPE")
os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1"
try:
flow.fetch_token(code=code)
finally:
if _prev_relax is None:
os.environ.pop("OAUTHLIB_RELAX_TOKEN_SCOPE", None)
else:
os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = _prev_relax
creds = flow.credentials
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
TOKEN_JSON.write_text(creds.to_json(), encoding="utf-8")
def load_credentials() -> Optional[Credentials]:
if not TOKEN_JSON.exists():
return None
return Credentials.from_authorized_user_file(str(TOKEN_JSON), SCOPES)
def ensure_valid_credentials() -> Credentials:
creds = load_credentials()
if not creds:
raise RuntimeError("尚未完成 Google 授权请先点击「Google 授权」。")
if creds.valid:
return creds
if creds.expired and creds.refresh_token:
creds.refresh(Request())
TOKEN_JSON.write_text(creds.to_json(), encoding="utf-8")
return creds
raise RuntimeError("授权已失效且无 refresh_token请重新授权勾选同意离线访问")
def oauth_status() -> dict[str, Any]:
creds = load_credentials()
if not creds:
return {"connected": False, "has_refresh": False}
return {
"connected": True,
"has_refresh": bool(creds.refresh_token),
"expired": bool(creds.expired),
}
def _pick_ingestion(items: list[dict[str, Any]]) -> Optional[dict[str, Any]]:
for it in items:
cdn = it.get("cdn") or {}
ing = cdn.get("ingestionInfo")
if ing and ing.get("streamName"):
return ing
return None
def fetch_stream_key_and_write_ini() -> dict[str, Any]:
"""
列出当前账号的直播流;若无则创建一路,将 streamName 写入 youtube.ini 的 key=。
"""
creds = ensure_valid_credentials()
youtube = build("youtube", "v3", credentials=creds, cache_discovery=False)
try:
resp = youtube.liveStreams().list(part="snippet,cdn,status", mine=True, maxResults=10).execute()
except HttpError as e:
raise RuntimeError(f"YouTube API 错误: {e}") from e
items = resp.get("items", [])
ing = _pick_ingestion(items)
if not ing:
body = {
"snippet": {"title": "直播录制助手"},
"cdn": {
"ingestionType": "rtmp",
"frameRate": "variable",
"resolution": "variable",
},
}
try:
created = youtube.liveStreams().insert(part="snippet,cdn", body=body).execute()
except HttpError as e:
raise RuntimeError(f"创建直播流失败: {e}") from e
ing = (created.get("cdn") or {}).get("ingestionInfo")
if not ing or not ing.get("streamName"):
raise RuntimeError("已创建直播流但未返回串流密钥,请到 YouTube 工作室检查。")
stream_name = str(ing["streamName"])
write_youtube_key_to_ini(stream_name)
rtmp = ing.get("ingestionAddress") or "rtmp://a.rtmp.youtube.com/live2"
rtmps = ing.get("rtmpsAddress") or "rtmps://a.rtmp.youtube.com/live2"
return {
"ok": True,
"stream_name": stream_name,
"ingestion_address": rtmp,
"rtmps_address": rtmps,
"message": "已写入 config/youtube.ini 的 key= 项。",
}
def write_youtube_key_to_ini(stream_key: str) -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
if not YOUTUBE_INI.exists():
YOUTUBE_INI.write_text(
"[youtube]\n"
"# 由 Google 授权自动写入,也可手动修改\n"
f"key = {stream_key}\n",
encoding="utf-8-sig",
)
return
raw = YOUTUBE_INI.read_text(encoding="utf-8-sig")
lines = raw.splitlines()
out: list[str] = []
replaced = False
for line in lines:
stripped = line.lstrip()
if stripped.startswith("#") or stripped.startswith(";"):
out.append(line)
continue
if "=" in line:
k = line.split("=", 1)[0].strip().lower()
if k == "key":
indent = line[: len(line) - len(line.lstrip())]
out.append(f"{indent}key = {stream_key}")
replaced = True
continue
out.append(line)
if not replaced:
inserted = False
final: list[str] = []
for line in out:
final.append(line)
if line.strip().lower() == "[youtube]" and not inserted:
final.append(f"key = {stream_key}")
inserted = True
out = final if inserted else out + ["", f"key = {stream_key}"]
YOUTUBE_INI.write_text("\n".join(out) + ("\n" if out and not out[-1].endswith("\n") else ""), encoding="utf-8-sig")