's'
This commit is contained in:
10
.gitignore
vendored
10
.gitignore
vendored
@@ -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/
|
||||
|
||||
5
config/easytier_network.example.json
Normal file
5
config/easytier_network.example.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"networkName": "lr_自动生成",
|
||||
"networkSecret": "自动生成请勿手填",
|
||||
"peerUrls": []
|
||||
}
|
||||
10
config/google_oauth.example.json
Normal file
10
config/google_oauth.example.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
1
config/web2_api_token.example.txt
Normal file
1
config/web2_api_token.example.txt
Normal file
@@ -0,0 +1 @@
|
||||
changeme_copy_to_web2_api_token_txt_and_replace_with_random_secret
|
||||
1
config/web2_bind_host.example.txt
Normal file
1
config/web2_bind_host.example.txt
Normal file
@@ -0,0 +1 @@
|
||||
127.0.0.1
|
||||
72
docs/p2p-remote.md
Normal file
72
docs/p2p-remote.md
Normal 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`(若需)。
|
||||
@@ -8,6 +8,14 @@ export default defineConfig({
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
format: 'cjs',
|
||||
entryFileNames: 'index.js',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
resolve: {
|
||||
|
||||
@@ -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
162
electron/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
1
electron/resources/easytier/.easytier_bundle_ok
Normal file
1
electron/resources/easytier/.easytier_bundle_ok
Normal file
@@ -0,0 +1 @@
|
||||
2026-03-24T17:49:18.788Z
|
||||
3
electron/resources/easytier/NOTICE-EasyTier.txt
Normal file
3
electron/resources/easytier/NOTICE-EasyTier.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
EasyTier 以 LGPL-3.0 授权发布。
|
||||
源码: https://github.com/EasyTier/EasyTier
|
||||
本程序随官方 GitHub Release 附带 easytier-core.exe 及同目录依赖,未修改二进制。
|
||||
BIN
electron/resources/easytier/Packet.dll
Normal file
BIN
electron/resources/easytier/Packet.dll
Normal file
Binary file not shown.
BIN
electron/resources/easytier/easytier-cli.exe
Normal file
BIN
electron/resources/easytier/easytier-cli.exe
Normal file
Binary file not shown.
BIN
electron/resources/easytier/easytier-web-embed.exe
Normal file
BIN
electron/resources/easytier/easytier-web-embed.exe
Normal file
Binary file not shown.
BIN
electron/resources/easytier/easytier-web.exe
Normal file
BIN
electron/resources/easytier/easytier-web.exe
Normal file
Binary file not shown.
BIN
electron/resources/easytier/wintun.dll
Normal file
BIN
electron/resources/easytier/wintun.dll
Normal file
Binary file not shown.
103
electron/scripts/download-easytier.mjs
Normal file
103
electron/scripts/download-easytier.mjs
Normal file
@@ -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)
|
||||
158
electron/src/main/easytier.ts
Normal file
158
electron/src/main/easytier.ts
Normal file
@@ -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<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
|
||||
}
|
||||
@@ -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<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.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<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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 客户端添加重定向 URI:http://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"
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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),
|
||||
|
||||
345
electron/src/renderer/src/RemoteBinding.tsx
Normal file
345
electron/src/renderer/src/RemoteBinding.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
})
|
||||
|
||||
22
electron/src/renderer/src/vite-env.d.ts
vendored
22
electron/src/renderer/src/vite-env.d.ts
vendored
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
88
web2.py
@@ -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
239
web2_youtube_oauth.py
Normal 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")
|
||||
Reference in New Issue
Block a user