From efcec61d364ef00f4bfb4ba70b1663d815504419 Mon Sep 17 00:00:00 2001 From: eric Date: Tue, 24 Mar 2026 14:47:03 -0500 Subject: [PATCH] 's' --- README.md | 42 + config/relay_pro/url_config.ch_default.ini | 3 + config/youtube_channels.example.json | 18 + config/youtube_channels.json | 12 + douyin_youtube_ffplay.py | 18 + electron/package.json | 7 +- electron/pnpm-lock.yaml | 611 ++ electron/src/main/appHost.ts | 224 + electron/src/main/clientDb.ts | 174 + electron/src/main/easytier.ts | 73 +- electron/src/main/index.ts | 73 +- electron/src/main/youtubeStudioSidecar.ts | 120 + electron/src/preload/index.ts | 34 + electron/src/renderer/index.html | 8 +- electron/src/renderer/src/App.css | 641 +- electron/src/renderer/src/App.tsx | 508 +- electron/src/renderer/src/AppHostControls.tsx | 226 + electron/src/renderer/src/SystemMetrics.tsx | 4 + .../src/renderer/src/WorkspaceDashboard.tsx | 309 +- .../renderer/src/WorkspaceDeskBackdrop.tsx | 312 + .../renderer/src/WorkspaceEventSparkline.tsx | 131 + electron/src/renderer/src/constants.ts | 3 + electron/src/renderer/src/vite-env.d.ts | 35 + .../src/renderer/src/youtubeConfigUtils.ts | 36 + mobile/.gitignore | 41 + mobile/.vscode/extensions.json | 1 + mobile/.vscode/settings.json | 7 + mobile/app.json | 48 + mobile/app/(tabs)/_layout.tsx | 64 + mobile/app/(tabs)/bind.tsx | 278 + mobile/app/(tabs)/desk.tsx | 76 + mobile/app/(tabs)/index.tsx | 410 + mobile/app/(tabs)/more.tsx | 177 + mobile/app/(tabs)/network.tsx | 98 + mobile/app/+html.tsx | 38 + mobile/app/+not-found.tsx | 40 + mobile/app/_layout.tsx | 58 + mobile/assets/fonts/SpaceMono-Regular.ttf | Bin 0 -> 93252 bytes .../assets/images/android-icon-background.png | Bin 0 -> 17549 bytes .../assets/images/android-icon-foreground.png | Bin 0 -> 78796 bytes .../assets/images/android-icon-monochrome.png | Bin 0 -> 4140 bytes mobile/assets/images/favicon.png | Bin 0 -> 1129 bytes mobile/assets/images/icon.png | Bin 0 -> 393493 bytes mobile/assets/images/splash-icon.png | Bin 0 -> 17547 bytes mobile/components/EditScreenInfo.tsx | 77 + mobile/components/ExternalLink.tsx | 24 + mobile/components/StyledText.tsx | 5 + mobile/components/Themed.tsx | 45 + mobile/components/useClientOnlyValue.ts | 4 + mobile/components/useClientOnlyValue.web.ts | 12 + mobile/components/useColorScheme.ts | 6 + mobile/components/useColorScheme.web.ts | 8 + mobile/constants/Colors.ts | 18 + mobile/constants/theme.ts | 13 + mobile/context/BindContext.tsx | 49 + mobile/hooks/useRecorder.ts | 416 + mobile/package-lock.json | 8908 +++++++++++++++++ mobile/package.json | 44 + mobile/pnpm-lock.yaml | 6869 +++++++++++++ mobile/tsconfig.json | 17 + third_party/srs/README.txt | 2 + web2.py | 105 +- web2_client_overview.py | 255 + web2_relay_pro.py | 439 + 64 files changed, 22242 insertions(+), 32 deletions(-) create mode 100644 config/relay_pro/url_config.ch_default.ini create mode 100644 config/youtube_channels.example.json create mode 100644 config/youtube_channels.json create mode 100644 electron/src/main/appHost.ts create mode 100644 electron/src/main/youtubeStudioSidecar.ts create mode 100644 electron/src/renderer/src/AppHostControls.tsx create mode 100644 electron/src/renderer/src/WorkspaceDeskBackdrop.tsx create mode 100644 electron/src/renderer/src/WorkspaceEventSparkline.tsx create mode 100644 electron/src/renderer/src/youtubeConfigUtils.ts create mode 100644 mobile/.gitignore create mode 100644 mobile/.vscode/extensions.json create mode 100644 mobile/.vscode/settings.json create mode 100644 mobile/app.json create mode 100644 mobile/app/(tabs)/_layout.tsx create mode 100644 mobile/app/(tabs)/bind.tsx create mode 100644 mobile/app/(tabs)/desk.tsx create mode 100644 mobile/app/(tabs)/index.tsx create mode 100644 mobile/app/(tabs)/more.tsx create mode 100644 mobile/app/(tabs)/network.tsx create mode 100644 mobile/app/+html.tsx create mode 100644 mobile/app/+not-found.tsx create mode 100644 mobile/app/_layout.tsx create mode 100644 mobile/assets/fonts/SpaceMono-Regular.ttf create mode 100644 mobile/assets/images/android-icon-background.png create mode 100644 mobile/assets/images/android-icon-foreground.png create mode 100644 mobile/assets/images/android-icon-monochrome.png create mode 100644 mobile/assets/images/favicon.png create mode 100644 mobile/assets/images/icon.png create mode 100644 mobile/assets/images/splash-icon.png create mode 100644 mobile/components/EditScreenInfo.tsx create mode 100644 mobile/components/ExternalLink.tsx create mode 100644 mobile/components/StyledText.tsx create mode 100644 mobile/components/Themed.tsx create mode 100644 mobile/components/useClientOnlyValue.ts create mode 100644 mobile/components/useClientOnlyValue.web.ts create mode 100644 mobile/components/useColorScheme.ts create mode 100644 mobile/components/useColorScheme.web.ts create mode 100644 mobile/constants/Colors.ts create mode 100644 mobile/constants/theme.ts create mode 100644 mobile/context/BindContext.tsx create mode 100644 mobile/hooks/useRecorder.ts create mode 100644 mobile/package-lock.json create mode 100644 mobile/package.json create mode 100644 mobile/pnpm-lock.yaml create mode 100644 mobile/tsconfig.json create mode 100644 third_party/srs/README.txt create mode 100644 web2_client_overview.py create mode 100644 web2_relay_pro.py diff --git a/README.md b/README.md index 8e0a31b..ad39e0d 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,48 @@ [![Latest Release](https://img.shields.io/github/v/release/ihmily/DouyinLiveRecorder)](https://github.com/ihmily/DouyinLiveRecorder/releases/latest) [![Downloads](https://img.shields.io/github/downloads/ihmily/DouyinLiveRecorder/total)](https://github.com/ihmily/DouyinLiveRecorder/releases/latest) +--- + +## 本分支扩展(Electron 控制台 · 抖音 → YouTube) + +在上游 **DouyinLiveRecorder** 能力之上,本仓库增加了 **Windows Electron 客户端**、**web2 FastAPI 控制台**、**EasyTier 远程访问**、**本地 SQLite 工作台**、**微信 Chatbot 桥接** 等。 + +### 架构概览 + +| 层级 | 说明 | +|------|------| +| Python / FFmpeg | 多平台录制主逻辑;`douyin_youtube_ffplay.py` 将抖音流推至 YouTube(RTMPS);`web2.py` 提供进程管理、配置读写、`/client/overview` 工作台数据、网络/代理/系统指标、YouTube OAuth 与微信代理 API。 | +| Electron 主进程 | 启动 uvicorn;内置 **EasyTier**(`easytier-core` + `easytier-cli`);**sql.js** 持久化 `client-data.sqlite`(直播事件、用户操作、运行日志、快照);**YouTube Studio 侧窗** 注入与油猴脚本 [YouTube Studio Auto Dismiss](https://greasyfork.org/en/scripts/557378-youtube-studio-auto-dismiss) 等价的自动 Dismiss 逻辑。 | +| 渲染进程 | Verge 风格 UI:录制、网络(远程绑定 + 测速)、代理、系统、微信、**工作台**(频道 / EasyTier / 微信 / 快照 / 事件表)。 | + +### 配置与数据路径 + +| 路径 | 用途 | +|------|------| +| `config/youtube.ini` | 单路串流密钥等(与上游一致)。 | +| `config/youtube_channels.json` | **多路一对一**:每条为 `douyinUrl` + `youtubeKey`(不同抖音 ↔ 不同 YouTube),见 example;**禁止**同一抖音绑多个 YouTube、**禁止**多路共用一个密钥。未使用该文件时退化为单路 `youtube.ini` + `URL_config.ini`。 | +| `config/easytier_network.json` | 组网名、密钥、peer 引导节点。 | +| `config/wechat_chatbot.json` | 微信代理 API 配置(与 openclaw-wechat 兼容)。 | +| `%AppData%`/应用用户数据目录下的 `client-data.sqlite` | Electron 工作台 SQLite。 | + +### 功能摘要 + +- **工作台**:展示 **YouTube 频道(名称 : 密钥预览)**、**EasyTier `easytier-cli peer list` 解析的节点数(含手机/RN 等)**、**微信是否已配置 / 昵称与 wcId / 代理在线状态**;直播事件表含 **relay_intent**(抖音房间 → YouTube key 后缀)、**relay_session**(转播时长秒数)等。 +- **多路 Pro**:多组并行「抖音直播间 → YouTube 串流」,每组**唯一配对**(禁止一抖音多推、禁止多抖音同密钥)。录制页开关仅存偏好;**多进程推流引擎仍在开发**。 +- **Tampermonkey**:默认用 Electron 内建注入实现 Studio 自动 Dismiss,无需强制安装油猴扩展;若需完整 Tampermonkey,可自行使用 `session.loadExtension` 加载扩展目录。 +- **SRS**:[SRS](https://github.com/ossrs/srs) 计划在 `third_party/srs` 集成,具体能力待定(占位说明见该目录 `README.txt`)。 + +### Electron 开发 + +```bash +cd electron +pnpm install +pnpm run prepare-easytier # 下载 Windows easytier 二进制(可选,见 package.json) +pnpm dev +``` + +--- + 一款**简易**的可循环值守的直播录制工具,基于FFmpeg实现多平台直播源录制,支持自定义配置录制以及直播状态推送。 diff --git a/config/relay_pro/url_config.ch_default.ini b/config/relay_pro/url_config.ch_default.ini new file mode 100644 index 0000000..76c0d05 --- /dev/null +++ b/config/relay_pro/url_config.ch_default.ini @@ -0,0 +1,3 @@ +# 软件已自动创建本文件。每行一个抖音直播间地址,例如: +# https://live.douyin.com/123456789 + diff --git a/config/youtube_channels.example.json b/config/youtube_channels.example.json new file mode 100644 index 0000000..15a6830 --- /dev/null +++ b/config/youtube_channels.example.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "comment": "多路转播:每条必须一对一——一个抖音直播间地址只对应一个 YouTube 串流密钥;禁止同一抖音向多个 YouTube 推流,也禁止多个抖音共用同一密钥。", + "channels": [ + { + "id": "ch_a", + "name": "路线A", + "douyinUrl": "https://live.douyin.com/123456789", + "youtubeKey": "xxxx-xxxx-xxxx-xxxx" + }, + { + "id": "ch_b", + "name": "路线B", + "douyinUrl": "https://live.douyin.com/987654321", + "youtubeKey": "yyyy-yyyy-yyyy-yyyy" + } + ] +} diff --git a/config/youtube_channels.json b/config/youtube_channels.json new file mode 100644 index 0000000..560c226 --- /dev/null +++ b/config/youtube_channels.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "autoManaged": true, + "channels": [ + { + "id": "ch_default", + "name": "默认线路", + "douyinUrl": "", + "youtubeKey": "" + } + ] +} \ No newline at end of file diff --git a/douyin_youtube_ffplay.py b/douyin_youtube_ffplay.py index c2c48db..81ea082 100644 --- a/douyin_youtube_ffplay.py +++ b/douyin_youtube_ffplay.py @@ -9,6 +9,7 @@ import subprocess import threading import re import os +import json import configparser from web2_proxy_config import merge_child_env @@ -158,6 +159,23 @@ def start_douyin_youtube_ffplay( clean_title = sanitize_title(title) stream_title = sanitize_title(f"{anchor_name}{('_' + clean_title) if clean_title else ''}_{timestamp_str}") print(f"[INFO] {rec_info} 开始推流到 YouTube: {stream_title}") + try: + print( + "[RELAY_META] " + + json.dumps( + { + "ts": int(time.time()), + "anchor": anchor_name, + "douyinHint": (real_url or record_name or "")[:400], + "streamTitle": stream_title, + "youtubeKeySuffix": (stream_key[-8:] if stream_key and len(stream_key) > 8 else stream_key), + }, + ensure_ascii=False, + ), + flush=True, + ) + except Exception: + pass # ====================== NVENC 检测(符合 YouTube 推荐比特率)====================== codec_v = "libx264" diff --git a/electron/package.json b/electron/package.json index fdc5ca9..ef10df6 100644 --- a/electron/package.json +++ b/electron/package.json @@ -13,16 +13,21 @@ "dist": "pnpm run build && electron-builder --win --publish never" }, "dependencies": { + "d3": "^7.9.0", + "echarts": "^6.0.0", "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", - "sql.js": "^1.14.1" + "sql.js": "^1.14.1", + "three": "^0.183.2" }, "devDependencies": { + "@types/d3": "^7.4.3", "@types/node": "^22.10.0", "@types/qrcode": "^1.5.6", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "@types/three": "^0.183.1", "@vitejs/plugin-react": "^4.3.4", "adm-zip": "^0.5.16", "electron": "^33.2.0", diff --git a/electron/pnpm-lock.yaml b/electron/pnpm-lock.yaml index b64bd71..44b87d8 100644 --- a/electron/pnpm-lock.yaml +++ b/electron/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + d3: + specifier: ^7.9.0 + version: 7.9.0 + echarts: + specifier: ^6.0.0 + version: 6.0.0 qrcode: specifier: ^1.5.4 version: 1.5.4 @@ -20,7 +26,13 @@ importers: sql.js: specifier: ^1.14.1 version: 1.14.1 + three: + specifier: ^0.183.2 + version: 0.183.2 devDependencies: + '@types/d3': + specifier: ^7.4.3 + version: 7.4.3 '@types/node': specifier: ^22.10.0 version: 22.19.15 @@ -33,6 +45,9 @@ importers: '@types/react-dom': specifier: ^18.3.1 version: 18.3.7(@types/react@18.3.28) + '@types/three': + specifier: ^0.183.1 + version: 0.183.1 '@vitejs/plugin-react': specifier: ^4.3.4 version: 4.7.0(vite@5.4.21(@types/node@22.19.15)) @@ -153,6 +168,9 @@ packages: resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} engines: {node: '>= 8.9.0'} + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@electron/asar@3.4.1': resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} engines: {node: '>=10.12.0'} @@ -502,6 +520,9 @@ packages: resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -517,6 +538,99 @@ packages: '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -526,6 +640,9 @@ packages: '@types/fs-extra@9.0.13': resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/http-cache-semantics@4.2.0': resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} @@ -561,9 +678,18 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + + '@types/three@0.183.1': + resolution: {integrity: sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==} + '@types/verror@1.10.11': resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -573,6 +699,9 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@webgpu/types@0.1.69': + resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} + '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} @@ -829,6 +958,10 @@ packages: resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} engines: {node: '>= 6'} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + compare-version@0.1.2: resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==} engines: {node: '>=0.10.0'} @@ -874,6 +1007,133 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -906,6 +1166,9 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -950,6 +1213,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + echarts@6.0.0: + resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==} + ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} @@ -1057,6 +1323,9 @@ packages: fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + filelist@1.0.6: resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} @@ -1246,6 +1515,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + ip-address@10.1.0: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} @@ -1396,6 +1669,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + meshoptimizer@1.0.1: + resolution: {integrity: sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -1704,11 +1980,17 @@ packages: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + rollup@4.60.0: resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -1849,6 +2131,9 @@ packages: temp-file@3.4.0: resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} + three@0.183.2: + resolution: {integrity: sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==} + tmp-promise@3.0.3: resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} @@ -1859,6 +2144,9 @@ packages: truncate-utf8-bytes@1.0.2: resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + type-fest@0.13.1: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} @@ -2010,6 +2298,9 @@ packages: resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} engines: {node: '>= 10'} + zrender@6.0.0: + resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==} + snapshots: 7zip-bin@5.2.0: {} @@ -2136,6 +2427,8 @@ snapshots: ajv: 6.14.0 ajv-keywords: 3.5.2(ajv@6.14.0) + '@dimforge/rapier3d-compat@0.12.0': {} + '@electron/asar@3.4.1': dependencies: commander: 5.1.0 @@ -2417,6 +2710,8 @@ snapshots: '@tootallnate/once@2.0.0': {} + '@tweenjs/tween.js@23.1.3': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.2 @@ -2445,6 +2740,123 @@ snapshots: '@types/node': 22.19.15 '@types/responselike': 1.0.3 + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -2455,6 +2867,8 @@ snapshots: dependencies: '@types/node': 22.19.15 + '@types/geojson@7946.0.16': {} + '@types/http-cache-semantics@4.2.0': {} '@types/keyv@3.1.4': @@ -2496,9 +2910,23 @@ snapshots: dependencies: '@types/node': 22.19.15 + '@types/stats.js@0.17.4': {} + + '@types/three@0.183.1': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + '@webgpu/types': 0.1.69 + fflate: 0.8.2 + meshoptimizer: 1.0.1 + '@types/verror@1.10.11': optional: true + '@types/webxr@0.5.24': {} + '@types/yauzl@2.10.3': dependencies: '@types/node': 22.19.15 @@ -2516,6 +2944,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@webgpu/types@0.1.69': {} + '@xmldom/xmldom@0.8.11': {} abbrev@1.1.1: {} @@ -2845,6 +3275,8 @@ snapshots: commander@5.1.0: {} + commander@7.2.0: {} + compare-version@0.1.2: {} compress-commons@4.1.2: @@ -2890,6 +3322,158 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -2920,6 +3504,10 @@ snapshots: object-keys: 1.1.1 optional: true + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + delayed-stream@1.0.0: {} delegates@1.0.0: {} @@ -2977,6 +3565,11 @@ snapshots: eastasianwidth@0.2.0: {} + echarts@6.0.0: + dependencies: + tslib: 2.3.0 + zrender: 6.0.0 + ejs@3.1.10: dependencies: jake: 10.9.4 @@ -3132,6 +3725,8 @@ snapshots: dependencies: pend: 1.2.0 + fflate@0.8.2: {} + filelist@1.0.6: dependencies: minimatch: 5.1.9 @@ -3377,6 +3972,8 @@ snapshots: inherits@2.0.4: {} + internmap@2.0.3: {} + ip-address@10.1.0: {} is-ci@3.0.1: @@ -3520,6 +4117,8 @@ snapshots: math-intrinsics@1.1.0: {} + meshoptimizer@1.0.1: {} + mime-db@1.52.0: {} mime-types@2.1.35: @@ -3817,6 +4416,8 @@ snapshots: sprintf-js: 1.1.3 optional: true + robust-predicates@3.0.3: {} + rollup@4.60.0: dependencies: '@types/estree': 1.0.8 @@ -3848,6 +4449,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.0 fsevents: 2.3.3 + rw@1.3.3: {} + safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} @@ -3994,6 +4597,8 @@ snapshots: async-exit-hook: 2.0.1 fs-extra: 10.1.0 + three@0.183.2: {} + tmp-promise@3.0.3: dependencies: tmp: 0.2.5 @@ -4004,6 +4609,8 @@ snapshots: dependencies: utf8-byte-length: 1.0.5 + tslib@2.3.0: {} + type-fest@0.13.1: optional: true @@ -4140,3 +4747,7 @@ snapshots: archiver-utils: 3.0.4 compress-commons: 4.1.2 readable-stream: 3.6.2 + + zrender@6.0.0: + dependencies: + tslib: 2.3.0 diff --git a/electron/src/main/appHost.ts b/electron/src/main/appHost.ts new file mode 100644 index 0000000..29a9604 --- /dev/null +++ b/electron/src/main/appHost.ts @@ -0,0 +1,224 @@ +/** + * Electron app 模块:开机启动、跳转列表、关于面板、日志路径、角标、优雅重启、指标等。 + * 文档:https://www.electronjs.org/docs/latest/api/app + */ +import { app, BrowserWindow, ipcMain, shell } from 'electron' +import fs from 'fs' +import path from 'path' + +const VALID_NAV = new Set(['desk', 'record', 'network', 'proxy', 'system', 'wechat']) + +export function parseNavFromArgv(argv: string[]): string | null { + for (const a of argv) { + const m = /^--nav=(.+)$/.exec(a) + if (!m) continue + const id = m[1].trim() + if (VALID_NAV.has(id)) return id + } + return null +} + +/** 须在 app.ready 之前调用(Windows Toast / 任务栏分组) */ +export function setAppUserModelIdEarly(): void { + if (process.platform !== 'win32') return + try { + app.setAppUserModelId('com.douyin.recorder.console') + } catch { + /* ignore */ + } +} + +function readPackageJsonField(field: 'description' | 'version'): string { + try { + const candidates = [ + path.join(app.getAppPath(), 'package.json'), + path.join(process.cwd(), 'package.json'), + ] + for (const p of candidates) { + if (fs.existsSync(p)) { + const j = JSON.parse(fs.readFileSync(p, 'utf-8')) as Record + const v = j[field] + if (typeof v === 'string') return v + } + } + } catch { + /* ignore */ + } + return '' +} + +/** ready 后立即调用 */ +export function applyAppHostBootstrap(getBackendRoot: () => string): void { + try { + const logDir = path.join(app.getPath('userData'), 'logs') + app.setAppLogsPath(logDir) + } catch { + /* ignore */ + } + + const ver = app.getVersion() + const desc = readPackageJsonField('description') || '直播录制与转播控制台' + app.setAboutPanelOptions({ + applicationName: app.getName(), + applicationVersion: ver, + copyright: `Copyright © ${new Date().getFullYear()}`, + credits: desc, + }) + + if (process.env.WEB2_SECURE_DNS === '1') { + try { + app.configureHostResolver({ secureDnsMode: 'automatic' }) + } catch { + /* ignore */ + } + } + + const proxy = process.env.WEB2_ELECTRON_GLOBAL_PROXY?.trim() + if (proxy) { + try { + void app.setProxy({ proxyRules: proxy }) + } catch { + /* ignore */ + } + } + + if (process.platform === 'win32') { + try { + app.setUserTasks([ + { + program: process.execPath, + arguments: '--nav=desk', + title: '工作台', + description: '打开工作台仪表盘', + iconPath: process.execPath, + iconIndex: 0, + }, + { + program: process.execPath, + arguments: '--nav=record', + title: '录制', + description: '打开录制与转播控制', + iconPath: process.execPath, + iconIndex: 0, + }, + { + program: process.execPath, + arguments: '--nav=network', + title: '网络', + description: '网络与远程绑定', + iconPath: process.execPath, + iconIndex: 0, + }, + ]) + } catch { + /* ignore */ + } + } +} + +export function registerAppHostIpcHandlers(getBackendRoot: () => string): void { + ipcMain.removeHandler('app:get-login-item') + ipcMain.handle('app:get-login-item', () => { + try { + return app.getLoginItemSettings() + } catch { + return { openAtLogin: false } + } + }) + + ipcMain.removeHandler('app:set-login-item') + ipcMain.handle('app:set-login-item', (_evt, openAtLogin: unknown) => { + if (typeof openAtLogin !== 'boolean') return { ok: false as const, error: '参数无效' } + try { + app.setLoginItemSettings({ + openAtLogin, + path: process.execPath, + args: [], + }) + return { ok: true as const } + } catch (e) { + return { ok: false as const, error: e instanceof Error ? e.message : String(e) } + } + }) + + ipcMain.removeHandler('app:relaunch') + ipcMain.handle('app:relaunch', () => { + app.relaunch() + app.exit(0) + }) + + ipcMain.removeHandler('app:get-metrics') + ipcMain.handle('app:get-metrics', () => { + try { + return app.getAppMetrics() + } catch { + return [] + } + }) + + ipcMain.removeHandler('app:show-about') + ipcMain.handle('app:show-about', () => { + try { + app.showAboutPanel() + } catch { + /* ignore */ + } + }) + + ipcMain.removeHandler('app:set-badge') + ipcMain.handle('app:set-badge', (_evt, count: unknown) => { + const n = typeof count === 'number' && Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0 + try { + if (process.platform === 'darwin' || process.platform === 'linux') { + app.setBadgeCount(n) + } + } catch { + /* Windows 无 dock 角标,忽略 */ + } + }) + + ipcMain.removeHandler('app:get-paths') + ipcMain.handle('app:get-paths', () => { + const br = getBackendRoot() + return { + userData: app.getPath('userData'), + logs: app.getPath('logs'), + temp: app.getPath('temp'), + backendRoot: br, + backendConfig: path.join(br, 'config'), + } + }) + + ipcMain.removeHandler('app:add-recent-config') + ipcMain.handle('app:add-recent-config', () => { + const cfg = path.join(getBackendRoot(), 'config') + try { + if (fs.existsSync(cfg)) { + app.addRecentDocument(cfg) + } + } catch { + /* ignore */ + } + }) + + ipcMain.removeHandler('app:open-logs-folder') + ipcMain.handle('app:open-logs-folder', async () => { + const p = app.getPath('logs') + try { + await fs.promises.mkdir(p, { recursive: true }) + } catch { + /* ignore */ + } + const err = await shell.openPath(p) + if (err) throw new Error(err) + }) +} + +export function sendNavToWindow(win: BrowserWindow, nav: string): void { + if (!VALID_NAV.has(nav)) return + try { + win.webContents.send('app:nav', nav) + } catch { + /* ignore */ + } +} diff --git a/electron/src/main/clientDb.ts b/electron/src/main/clientDb.ts index 8837d7b..c247daf 100644 --- a/electron/src/main/clientDb.ts +++ b/electron/src/main/clientDb.ts @@ -68,6 +68,20 @@ function runMigrations(): void { CREATE INDEX IF NOT EXISTS idx_live_ts ON live_events(ts DESC); CREATE INDEX IF NOT EXISTS idx_user_ts ON user_actions(ts DESC); CREATE INDEX IF NOT EXISTS idx_client_ts ON client_logs(ts DESC); + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + ts INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS relay_channels ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + youtube_key TEXT NOT NULL DEFAULT '', + douyin_url TEXT NOT NULL DEFAULT '', + url_list_text TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL + ); `) } @@ -224,3 +238,163 @@ export function getDashboardSnapshot(): { } return empty } + +// —— 应用设置(主题等)—— + +export function getAppSetting(key: string): string | null { + if (!db) return null + try { + const r = db.exec(`SELECT value FROM app_settings WHERE key = ${JSON.stringify(key)}`) + if (!r.length || !r[0].values.length) return null + return String(r[0].values[0][0] ?? '') + } catch { + return null + } +} + +export function setAppSetting(key: string, value: string): void { + if (!db) return + const ts = Date.now() + db.run(`INSERT OR REPLACE INTO app_settings (key, value, ts) VALUES (?, ?, ?)`, [key, value, ts]) + schedulePersist() +} + +// —— Pro 转播线路:SQLite 为源,导出到项目 config/ —— + +function relayChannelCount(): number { + if (!db) return 0 + try { + const r = db.exec(`SELECT COUNT(*) AS c FROM relay_channels`) + if (!r.length || !r[0].values.length) return 0 + return Number(r[0].values[0][0] ?? 0) + } catch { + return 0 + } +} + +function seedDefaultRelayChannel(): void { + if (!db) return + const ts = Date.now() + db.run( + `INSERT OR IGNORE INTO relay_channels (id, name, youtube_key, douyin_url, url_list_text, sort_order, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + 'ch_default', + '默认线路', + '', + '', + '# 软件已自动创建本文件。每行一个抖音直播间地址,例如:\n# https://live.douyin.com/123456789\n\n', + 0, + ts, + ], + ) + schedulePersist() +} + +function safeChannelFileId(id: string): string { + return id.replace(/[^a-zA-Z0-9_-]/g, '_') || 'default' +} + +function importRelayFromYoutubeJson(configDir: string): boolean { + if (!db) return false + const p = path.join(configDir, 'youtube_channels.json') + if (!fs.existsSync(p)) return false + let raw: { channels?: unknown[] } + try { + raw = JSON.parse(fs.readFileSync(p, 'utf-8')) as { channels?: unknown[] } + } catch { + return false + } + const arr = raw.channels + if (!Array.isArray(arr) || arr.length === 0) return false + db.run(`DELETE FROM relay_channels`) + const relayPro = path.join(configDir, 'relay_pro') + let order = 0 + for (const item of arr) { + if (!item || typeof item !== 'object') continue + const o = item as Record + const id = String(o.id ?? o.name ?? `ch_${order}`).trim() || `ch_${order}` + const name = String(o.name ?? '线路').trim() || '线路' + const yk = String(o.youtubeKey ?? o.key ?? '').trim() + const du = String(o.douyinUrl ?? o.douyin_url ?? '').trim() + let urlText = '' + const up = path.join(relayPro, `url_config.${safeChannelFileId(id)}.ini`) + if (fs.existsSync(up)) { + try { + urlText = fs.readFileSync(up, 'utf-8') + } catch { + urlText = '' + } + } + const ts = Date.now() + db.run( + `INSERT INTO relay_channels (id, name, youtube_key, douyin_url, url_list_text, sort_order, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + [id, name, yk, du, urlText, order, ts], + ) + order += 1 + } + schedulePersist() + return order > 0 +} + +export function exportRelayChannelsToConfigDir(configDir: string): void { + if (!db) return + const relayPro = path.join(configDir, 'relay_pro') + fs.mkdirSync(relayPro, { recursive: true }) + let r: ReturnType + try { + r = db.exec( + `SELECT id, name, youtube_key, douyin_url, url_list_text, sort_order FROM relay_channels ORDER BY sort_order, id`, + ) + } catch { + return + } + if (!r.length || !r[0].values.length) return + const cols = r[0].columns + const rows = r[0].values + const channels: Record[] = [] + for (const row of rows) { + const rec: Record = {} + cols.forEach((c, i) => { + rec[c] = row[i] + }) + const id = String(rec.id ?? '') + const name = String(rec.name ?? '') + const youtubeKey = String(rec.youtube_key ?? '') + const douyinUrl = String(rec.douyin_url ?? '') + const urlList = String(rec.url_list_text ?? '') + channels.push({ + id, + name, + douyinUrl, + youtubeKey, + }) + const uf = path.join(relayPro, `url_config.${safeChannelFileId(id)}.ini`) + fs.writeFileSync(uf, urlList || '# 每行一个抖音直播间地址\n', 'utf-8') + } + const doc = { + version: 1, + autoManaged: true, + channels, + } + fs.mkdirSync(configDir, { recursive: true }) + fs.writeFileSync(path.join(configDir, 'youtube_channels.json'), JSON.stringify(doc, null, 2), 'utf-8') +} + +/** 启动时:SQLite 优先;若为空则从磁盘 JSON 导入;仍空则种子;最后导出到 config/ */ +export function syncRelayChannelsWithBackendConfig(configDir: string): void { + if (!db) return + const abs = path.resolve(configDir) + if (relayChannelCount() === 0) { + if (!importRelayFromYoutubeJson(abs)) { + seedDefaultRelayChannel() + } + } + exportRelayChannelsToConfigDir(abs) +} + +/** 从磁盘重新读入 SQLite(在通过 Python API 保存文件后调用) */ +export function importRelayChannelsFromConfigDir(configDir: string): void { + if (!db) return + const abs = path.resolve(configDir) + importRelayFromYoutubeJson(abs) +} diff --git a/electron/src/main/easytier.ts b/electron/src/main/easytier.ts index 06274d8..2e8b46f 100644 --- a/electron/src/main/easytier.ts +++ b/electron/src/main/easytier.ts @@ -3,7 +3,8 @@ * 二进制来源:https://github.com/EasyTier/EasyTier (LGPL-3.0) */ import { app } from 'electron' -import { spawn, type ChildProcess } from 'child_process' +import { execFile, spawn, type ChildProcess } from 'child_process' +import { promisify } from 'util' import crypto from 'crypto' import fs from 'fs' import path from 'path' @@ -12,6 +13,8 @@ import { fileURLToPath } from 'url' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) +const execFileAsync = promisify(execFile) + export type EasyTierNetworkConfig = { networkName: string networkSecret: string @@ -44,6 +47,74 @@ export function getBundledEasyTierExePath(): string | null { return fs.existsSync(dev) ? dev : null } +/** 与 easytier-core 同目录分发的 CLI,用于 peer list 等工作台统计 */ +export function getBundledEasyTierCliPath(): string | null { + if (process.platform !== 'win32') return null + const exe = 'easytier-cli.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 type EasyTierPeerStats = { + ok: boolean + error?: string + totalPeers?: number + onlinePeers?: number + raw?: string +} + +function parsePeerListOutput(text: string): { totalPeers: number; onlinePeers: number } { + const lines = text + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l.length > 0) + const ipRe = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g + let withIp = 0 + let online = 0 + for (const line of lines) { + if (/^[-=│├└┌┐┘┬┴┼]+$/.test(line)) continue + if (/^peer\s|^id\s|^hostname\s/i.test(line) && line.length < 100) continue + const ips = line.match(ipRe) + if (!ips) continue + withIp += 1 + const low = line.toLowerCase() + if (!/offline|unreachable|disconnected|lost|timeout/.test(low)) online += 1 + } + if (withIp === 0) { + const guess = lines.filter((l) => l.length > 12 && !/^error|^usage/i.test(l)).length + const n = Math.max(0, guess > 0 ? guess - 1 : 0) + return { totalPeers: n, onlinePeers: n } + } + return { totalPeers: withIp, onlinePeers: online > 0 ? online : withIp } +} + +/** 调用 easytier-cli peer list;需 easytier-core 已运行且 RPC 端口可达 */ +export async function queryEasyTierPeerStats(): Promise { + const cli = getBundledEasyTierCliPath() + if (!cli) return { ok: false, error: '未找到内置 easytier-cli.exe' } + if (!getEasyTierRuntimeState().running) { + return { ok: false, error: 'easytier-core 未运行', totalPeers: 0, onlinePeers: 0 } + } + try { + const { stdout, stderr } = await execFileAsync(cli, ['peer', 'list'], { + cwd: path.dirname(cli), + timeout: 5000, + maxBuffer: 2 * 1024 * 1024, + windowsHide: true, + }) + const raw = `${stdout || ''}${stderr || ''}` + const { totalPeers, onlinePeers } = parsePeerListOutput(raw) + return { ok: true, totalPeers, onlinePeers, raw: raw.slice(0, 6000) } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + return { ok: false, error: msg, totalPeers: 0, onlinePeers: 0 } + } +} + export function readEasyTierNetworkName(backendRoot: string): string { const p = path.join(backendRoot, 'config', 'easytier_network.json') if (!fs.existsSync(p)) return '' diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts index 74f4c1d..89dbc6d 100644 --- a/electron/src/main/index.ts +++ b/electron/src/main/index.ts @@ -11,19 +11,32 @@ import { fileURLToPath } from 'url' import { getEasyTierRuntimeState, loadOrCreateNetworkConfig, + queryEasyTierPeerStats, readEasyTierNetworkName, startBundledEasyTier, stopBundledEasyTier, } from './easytier' +import { ensureYoutubeStudioWindow } from './youtubeStudioSidecar' import { appendClientLog, appendLiveEvent, appendUserAction, flushClientDb, + getAppSetting, getDashboardSnapshot, + importRelayChannelsFromConfigDir, initClientDb, saveDataSnapshot, + setAppSetting, + syncRelayChannelsWithBackendConfig, } from './clientDb' +import { + applyAppHostBootstrap, + parseNavFromArgv, + registerAppHostIpcHandlers, + sendNavToWindow, + setAppUserModelIdEarly, +} from './appHost' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -39,6 +52,8 @@ let pythonChild: ChildProcessWithoutNullStreams | null = null let stderrBuf = '' let _backendRoot: string | null = null let _pythonExe: string | null = null +/** 自启动参数 --nav= 传入的页面,首屏或二次实例时下发渲染进程 */ +let pendingNav: string | null = null /** * 供扫码绑定 baseUrl:优先虚拟网卡(EasyTier 等),其次 10.x,再补充 Wi‑Fi/以太网等局域网 IPv4, @@ -382,6 +397,10 @@ async function createWindow(): Promise { mainWindow.once('ready-to-show', () => mainWindow?.show()) mainWindow.webContents.once('did-finish-load', () => { appendClientLog('info', 'main', '渲染界面已加载') + if (pendingNav && mainWindow && !mainWindow.isDestroyed()) { + sendNavToWindow(mainWindow, pendingNav) + pendingNav = null + } }) const devUrl = process.env.ELECTRON_RENDERER_URL @@ -436,16 +455,33 @@ const gotLock = app.requestSingleInstanceLock() if (!gotLock) { app.quit() } else { - app.on('second-instance', () => { + setAppUserModelIdEarly() + pendingNav = parseNavFromArgv(process.argv) + + app.on('second-instance', (_evt, commandLine) => { + const argv = Array.isArray(commandLine) + ? commandLine + : typeof commandLine === 'string' + ? commandLine.split(/\s+/).filter(Boolean) + : [String(commandLine)] + const nav = parseNavFromArgv(argv) if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore() mainWindow.focus() + if (nav) sendNavToWindow(mainWindow, nav) } }) app.whenReady().then(async () => { + applyAppHostBootstrap(getBackendRoot) + registerAppHostIpcHandlers(getBackendRoot) try { await initClientDb() + try { + syncRelayChannelsWithBackendConfig(path.join(getBackendRoot(), 'config')) + } catch (e) { + console.error('[relay sync]', e) + } appendClientLog('info', 'main', '客户端主进程就绪') } catch (e) { console.error('[clientDb]', e) @@ -496,6 +532,41 @@ if (!gotLock) { ipcMain.removeHandler('workspace:get-dashboard') ipcMain.handle('workspace:get-dashboard', () => getDashboardSnapshot()) + ipcMain.removeHandler('app:get-setting') + ipcMain.handle('app:get-setting', (_evt, key: unknown) => { + if (typeof key !== 'string' || !key.trim()) return null + return getAppSetting(key.trim()) + }) + + ipcMain.removeHandler('app:set-setting') + ipcMain.handle('app:set-setting', (_evt, key: unknown, value: unknown) => { + if (typeof key !== 'string' || !key.trim()) return + if (typeof value !== 'string') return + setAppSetting(key.trim(), value) + }) + + ipcMain.removeHandler('relay:sync-config') + ipcMain.handle('relay:sync-config', () => { + const br = _backendRoot ?? getBackendRoot() + syncRelayChannelsWithBackendConfig(path.join(br, 'config')) + return { ok: true as const } + }) + + ipcMain.removeHandler('relay:import-from-config') + ipcMain.handle('relay:import-from-config', () => { + const br = _backendRoot ?? getBackendRoot() + importRelayChannelsFromConfigDir(path.join(br, 'config')) + return { ok: true as const } + }) + + ipcMain.removeHandler('easytier:get-peer-stats') + ipcMain.handle('easytier:get-peer-stats', () => queryEasyTierPeerStats()) + + ipcMain.removeHandler('youtube-studio:ensure') + ipcMain.handle('youtube-studio:ensure', (_evt, suffix: unknown) => { + ensureYoutubeStudioWindow(typeof suffix === 'string' ? suffix : undefined) + }) + ipcMain.removeHandler('open-external') ipcMain.handle('open-external', async (_evt, url: unknown) => { if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) { diff --git a/electron/src/main/youtubeStudioSidecar.ts b/electron/src/main/youtubeStudioSidecar.ts new file mode 100644 index 0000000..df468c4 --- /dev/null +++ b/electron/src/main/youtubeStudioSidecar.ts @@ -0,0 +1,120 @@ +/** + * YouTube Studio 直播后台侧窗:自动点击 Dismiss(对齐 GreasyFork「YouTube Studio Auto Dismiss」逻辑), + * 并可检测页面文本是否包含串流密钥后缀以便与本地配置对应。 + */ +import { BrowserWindow, app } from 'electron' +let studioWin: BrowserWindow | null = null + +const STUDIO_ENTRY = 'https://studio.youtube.com/livestreaming' + +/** 与油猴脚本等价的定时查找 Dismiss 按钮并点击(含 Shadow DOM) */ +const AUTO_DISMISS_JS = ` +(function(){ + if (window.__douyinRecorderYtDismiss) return; + window.__douyinRecorderYtDismiss = true; + function isVisible(el) { + if (!el) return false; + try { + var s = window.getComputedStyle(el); + return s.display !== 'none' && s.visibility !== 'hidden' && el.offsetParent !== null; + } catch (e) { return false; } + } + function findDismiss(root) { + root = root || document; + var buttons = root.querySelectorAll('button'); + for (var i = 0; i < buttons.length; i++) { + var el = buttons[i]; + try { + var text = (el.innerText || '').trim(); + if (text === 'Dismiss' && isVisible(el)) return el; + if (el.shadowRoot) { + var x = findDismiss(el.shadowRoot); + if (x) return x; + } + } catch (e) {} + } + return null; + } + function clickEl(el) { + if (!el) return; + el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); + } + function tick() { + var b = findDismiss(); + if (b) clickEl(b); + } + if (document.readyState === 'complete') tick(); + else window.addEventListener('load', tick); + setInterval(tick, 5000); +})(); +true; +` + +const CHECK_KEY_JS = (suffix: string) => ` +(function(){ + var s = (${JSON.stringify(suffix)} || '').trim(); + if (!s) return { ok: true, hasSuffix: true }; + try { + var t = document.body ? document.body.innerText : ''; + return { ok: true, hasSuffix: t.indexOf(s) >= 0 }; + } catch (e) { + return { ok: false, hasSuffix: false }; + } +})(); +` + +export function ensureYoutubeStudioWindow(streamKeySuffix: string | undefined): void { + const suffix = (streamKeySuffix || '').trim().slice(-12) + + const inject = (wc: Electron.WebContents): void => { + const u = wc.getURL() + if (!u.includes('studio.youtube.com')) return + void wc.executeJavaScript(AUTO_DISMISS_JS, true).catch(() => {}) + if (suffix) { + void wc.executeJavaScript(CHECK_KEY_JS(suffix), true).catch(() => {}) + } + } + + if (studioWin && !studioWin.isDestroyed()) { + studioWin.show() + studioWin.focus() + const wc = studioWin.webContents + const cur = wc.getURL() + if (cur.includes('studio.youtube.com')) { + inject(wc) + } else { + wc.once('did-finish-load', () => inject(wc)) + void wc.loadURL(STUDIO_ENTRY) + } + return + } + + studioWin = new BrowserWindow({ + width: 1100, + height: 720, + minWidth: 800, + minHeight: 560, + show: true, + title: 'YouTube Studio 直播', + autoHideMenuBar: true, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + /* 需登录 Google;sandbox 过严时可能导致 OAuth/ Cookie 异常 */ + sandbox: false, + partition: 'persist:yt-studio', + }, + }) + + if (app.isPackaged) { + studioWin.removeMenu() + } + + const wc = studioWin.webContents + wc.once('did-finish-load', () => inject(wc)) + void wc.loadURL(STUDIO_ENTRY) + + studioWin.on('closed', () => { + studioWin = null + }) +} diff --git a/electron/src/preload/index.ts b/electron/src/preload/index.ts index 1ac571a..a1f2c0d 100644 --- a/electron/src/preload/index.ts +++ b/electron/src/preload/index.ts @@ -24,3 +24,37 @@ contextBridge.exposeInMainWorld('workspace', { saveSnapshot: (key: string, data: unknown) => ipcRenderer.invoke('workspace:save-snapshot', key, data), getDashboard: () => ipcRenderer.invoke('workspace:get-dashboard'), }) + +/** 工作台 EasyTier / YouTube Studio 侧窗(无 preload 的独立窗口不暴露此 API) */ +contextBridge.exposeInMainWorld('deskBridge', { + getEasyTierPeers: () => ipcRenderer.invoke('easytier:get-peer-stats'), + ensureYoutubeStudio: (streamKeySuffix?: string) => + ipcRenderer.invoke('youtube-studio:ensure', streamKeySuffix ?? ''), +}) + +contextBridge.exposeInMainWorld('appTheme', { + get: () => ipcRenderer.invoke('app:get-setting', 'theme'), + set: (value: string) => ipcRenderer.invoke('app:set-setting', 'theme', value), +}) + +contextBridge.exposeInMainWorld('relayConfig', { + syncConfig: () => ipcRenderer.invoke('relay:sync-config'), + importFromConfig: () => ipcRenderer.invoke('relay:import-from-config'), +}) + +contextBridge.exposeInMainWorld('appHost', { + onNavigate: (cb: (nav: string) => void) => { + const listener = (_e: unknown, nav: string) => cb(nav) + ipcRenderer.on('app:nav', listener) + return () => ipcRenderer.removeListener('app:nav', listener) + }, + getLoginItemSettings: () => ipcRenderer.invoke('app:get-login-item'), + setLoginItemOpenAtLogin: (v: boolean) => ipcRenderer.invoke('app:set-login-item', v), + relaunch: () => ipcRenderer.invoke('app:relaunch'), + getMetrics: () => ipcRenderer.invoke('app:get-metrics'), + showAbout: () => ipcRenderer.invoke('app:show-about'), + setBadge: (n: number) => ipcRenderer.invoke('app:set-badge', n), + getPaths: () => ipcRenderer.invoke('app:get-paths'), + addRecentConfig: () => ipcRenderer.invoke('app:add-recent-config'), + openLogsFolder: () => ipcRenderer.invoke('app:open-logs-folder'), +}) diff --git a/electron/src/renderer/index.html b/electron/src/renderer/index.html index f166cf5..fc7d01b 100644 --- a/electron/src/renderer/index.html +++ b/electron/src/renderer/index.html @@ -3,8 +3,14 @@ - + 直播录制助手 +
diff --git a/electron/src/renderer/src/App.css b/electron/src/renderer/src/App.css index bd2dddd..9eafc2f 100644 --- a/electron/src/renderer/src/App.css +++ b/electron/src/renderer/src/App.css @@ -48,6 +48,45 @@ --ease: cubic-bezier(0.2, 0, 0, 1); --sidebar-width: 220px; + --bg-header: rgba(20, 22, 28, 0.85); +} + +:root[data-theme="light"] { + --bg-app-0: #eef1f8; + --bg-app-1: #e8ecf4; + --bg-sidebar: #ffffff; + --bg-sidebar-hover: #eef2f9; + --bg-workspace: #f3f5fb; + --bg-chrome: #ffffff; + --bg-card: #ffffff; + --bg-card-elevated: #fafbfd; + --bg-card-muted: #f0f4fa; + --bg-input: #ffffff; + --bg-log-0: #f8fafc; + --bg-log-1: #eef2f7; + --border-subtle: rgba(15, 23, 42, 0.08); + --border-card: rgba(15, 23, 42, 0.1); + --border-strong: rgba(15, 23, 42, 0.14); + --text: #0f172a; + --text-muted: #64748b; + --text-on-dark: #334155; + --accent: #2563eb; + --accent-2: #3b82f6; + --accent-dim: rgba(37, 99, 235, 0.12); + --accent-soft: rgba(37, 99, 235, 0.14); + --accent-glow: rgba(37, 99, 235, 0.22); + --accent-hover: #1d4ed8; + --success: #059669; + --success-soft: rgba(5, 150, 105, 0.1); + --danger: #dc2626; + --danger-soft: rgba(220, 38, 38, 0.1); + --warn: #d97706; + --warn-soft: rgba(217, 119, 6, 0.12); + --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06); + --shadow-md: 0 8px 28px rgba(15, 23, 42, 0.08); + --shadow-lg: 0 18px 48px rgba(15, 23, 42, 0.1); + --shadow-inset-top: inset 0 1px 0 rgba(255, 255, 255, 0.9); + --bg-header: rgba(255, 255, 255, 0.92); } *, @@ -57,6 +96,7 @@ } html { + height: 100%; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; } @@ -69,18 +109,31 @@ html { body { margin: 0; min-height: 100vh; + height: 100%; font-family: var(--font-ui); font-size: 14px; line-height: 1.55; color: var(--text); background-color: var(--bg-workspace); background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(91, 157, 255, 0.08), transparent 50%); + transition: + background-color 0.38s var(--ease), + color 0.28s var(--ease); +} + +:root[data-theme="light"] body { + background-image: radial-gradient(ellipse 85% 48% at 50% -12%, rgba(37, 99, 235, 0.12), transparent 52%); } body.ready { opacity: 1; } +#root { + height: 100%; + min-height: 100vh; +} + /* 根布局(旧顶栏已弃用,改侧栏) */ .app-frame { min-height: 100vh; @@ -91,17 +144,34 @@ body.ready { .app-frame--verge { flex-direction: row; min-height: 100vh; + height: 100vh; + max-height: 100vh; + overflow: hidden; + align-items: stretch; } -/* —— Clash Verge 风格侧栏 —— */ +/* —— Clash Verge 风格侧栏 ——(主区滚动时侧栏固定于视口) */ .app-sidebar { width: var(--sidebar-width); flex-shrink: 0; display: flex; flex-direction: column; + position: sticky; + top: 0; + align-self: flex-start; + height: 100vh; + max-height: 100vh; + overflow-x: hidden; + overflow-y: auto; background: linear-gradient(180deg, var(--bg-sidebar) 0%, #181c24 100%); border-right: 1px solid var(--border-subtle); box-shadow: 4px 0 24px rgba(0, 0, 0, 0.25); + transition: background 0.38s var(--ease), box-shadow 0.35s var(--ease); +} + +:root[data-theme="light"] .app-sidebar { + background: linear-gradient(180deg, #ffffff 0%, #f1f5f9 100%); + box-shadow: 4px 0 28px rgba(15, 23, 42, 0.07); } .app-sidebar__brand { @@ -112,6 +182,57 @@ body.ready { border-bottom: 1px solid var(--border-subtle); } +.app-sidebar__brand-main { + flex: 1; + min-width: 0; +} + +.app-sidebar__brand-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.app-sidebar__brand-title-row .app-sidebar__app-name { + flex: 1; + min-width: 0; +} + +.app-sidebar__theme-toggle { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + padding: 0; + border: none; + border-radius: var(--radius-pill); + background: var(--bg-card-muted); + color: var(--text-muted); + cursor: pointer; + transition: + background 0.22s var(--ease), + color 0.2s var(--ease), + transform 0.18s var(--ease), + box-shadow 0.2s var(--ease); + box-shadow: 0 0 0 1px var(--border-subtle); +} + +.app-sidebar__theme-toggle:hover { + color: var(--accent); + background: var(--accent-dim); +} + +.app-sidebar__theme-toggle:active { + transform: scale(0.94); +} + +.app-sidebar__theme-toggle svg { + display: block; +} + .app-sidebar__logo { width: 40px; height: 40px; @@ -135,6 +256,103 @@ body.ready { margin-top: 2px; } +.app-sidebar__theme { + display: flex; + gap: 8px; + justify-content: center; + padding: 0 12px 12px; + border-bottom: 1px solid var(--border-subtle); +} + +.app-sidebar__theme--icons { + gap: 0; + padding: 0 14px 14px; +} + +.app-sidebar__theme-bar { + display: inline-flex; + align-items: center; + padding: 4px; + border-radius: var(--radius-pill); + background: var(--bg-card-muted); + border: 1px solid var(--border-card); + box-shadow: var(--shadow-sm); + gap: 2px; +} + +.app-sidebar__theme-icon-btn { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 34px; + padding: 0; + border: none; + border-radius: var(--radius-pill); + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: + background 0.22s var(--ease), + color 0.2s var(--ease), + transform 0.2s var(--ease), + box-shadow 0.25s var(--ease); +} + +.app-sidebar__theme-icon-btn:hover { + color: var(--text); + background: var(--bg-sidebar-hover); +} + +.app-sidebar__theme-icon-btn.is-active { + color: var(--accent); + background: var(--accent-dim); + box-shadow: inset 0 0 0 1px var(--accent-soft); +} + +.app-sidebar__theme-icon-btn:active { + transform: scale(0.96); +} + +.app-sidebar__theme-icon-btn svg { + display: block; +} + +.app-sidebar__theme-btn { + flex: 1; + padding: 8px 10px; + border: 1px solid var(--border-card); + border-radius: var(--radius-md); + background: var(--bg-card-muted); + color: var(--text-muted); + font-family: var(--font-ui); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: + background 0.22s var(--ease), + color 0.2s var(--ease), + border-color 0.2s var(--ease), + box-shadow 0.25s var(--ease), + transform 0.18s var(--ease); +} + +.app-sidebar__theme-btn:hover { + color: var(--text); + border-color: var(--border-strong); +} + +.app-sidebar__theme-btn.is-active { + color: var(--accent); + background: var(--accent-dim); + border-color: rgba(91, 157, 255, 0.35); + box-shadow: 0 0 0 1px var(--accent-soft); +} + +:root[data-theme="light"] .app-sidebar__theme-btn.is-active { + border-color: rgba(37, 99, 235, 0.35); +} + .app-sidebar__nav { display: flex; flex-direction: column; @@ -191,6 +409,8 @@ body.ready { .app-workspace { flex: 1; min-width: 0; + min-height: 0; + height: 100%; display: flex; flex-direction: column; background: var(--bg-workspace); @@ -204,8 +424,9 @@ body.ready { gap: 16px; padding: 18px 24px 14px; border-bottom: 1px solid var(--border-subtle); - background: rgba(20, 22, 28, 0.85); + background: var(--bg-header); backdrop-filter: blur(12px); + transition: background 0.35s var(--ease), border-color 0.25s var(--ease); } .app-workspace__title { @@ -285,6 +506,39 @@ body.ready { min-width: min(100%, 300px); } +.app-toolbar__right { + display: flex; + align-items: center; + gap: 10px; +} + +.record-pro-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 600; + color: #e8eaed; + cursor: pointer; + user-select: none; +} + +.record-pro-toggle input { + width: 16px; + height: 16px; + accent-color: var(--accent, #6d9eff); +} + +.card--pro-head { + border-color: rgba(110, 168, 254, 0.35); + background: linear-gradient(165deg, rgba(110, 168, 254, 0.1) 0%, var(--bg-card) 100%); +} + +.select-process--pro { + max-width: min(100%, 520px); +} + + .field-label { font-size: 12px; font-weight: 600; @@ -1196,6 +1450,84 @@ body.ready { background: linear-gradient(165deg, rgba(110, 168, 254, 0.06) 0%, var(--bg-card) 100%); } +.ws-hero { + position: relative; + margin-bottom: 0.75rem; + border-radius: var(--radius-md); + overflow: hidden; + min-height: 176px; + border: 1px solid var(--border-subtle); + box-shadow: var(--shadow-sm); +} + +.ws-desk-backdrop { + position: relative; + width: 100%; + min-height: 176px; + border-radius: inherit; +} + +.ws-desk-backdrop__three { + position: absolute; + inset: 0; + border-radius: inherit; +} + +.ws-desk-backdrop__three canvas { + display: block; + width: 100% !important; + height: 100% !important; +} + +.ws-desk-backdrop__d3 { + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; +} + +.ws-desk-backdrop__fade { + position: absolute; + inset: 0; + z-index: 2; + pointer-events: none; + background: linear-gradient(180deg, transparent 0%, var(--bg-card) 92%); + opacity: 0.7; +} + +.ws-desk-backdrop__legend { + position: absolute; + left: 10px; + bottom: 8px; + z-index: 4; + max-width: min(520px, 94%); + font-size: 10px; + line-height: 1.5; + color: var(--text-muted); + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.65); + pointer-events: none; +} + +.ws-desk-backdrop__legend strong { + display: block; + color: var(--text); + font-size: 11px; + margin-bottom: 4px; +} + +.ws-desk-backdrop__legend span { + display: block; +} + +.ws-desk-backdrop__legend-sub { + margin-top: 3px; + opacity: 0.95; +} + +:root[data-theme="light"] .ws-desk-backdrop__legend { + text-shadow: 0 1px 2px rgba(255, 255, 255, 0.9); +} + .ws-head { display: flex; flex-wrap: wrap; @@ -1212,6 +1544,186 @@ body.ready { margin-bottom: 1rem; } +.ws-cards--wide { + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); +} + +.ws-kv-list { + margin: 0.35rem 0 0; + padding: 0 0 0 1rem; + font-size: 12px; + line-height: 1.55; + color: #c8cdd5; +} + +.ws-kv-list li { + margin: 0.2rem 0; + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.5rem; + align-items: baseline; +} + +.ws-kv-list__k { + color: var(--text-muted, #8b929e); + min-width: 3rem; +} + +.ws-kv-list__v { + font-size: 11px; + word-break: break-all; +} + +.ws-peer-stats { + margin: 0.35rem 0 0; + font-size: 13px; + color: #e8eaed; +} + +.ws-tag { + display: inline-block; + padding: 0.1rem 0.45rem; + border-radius: 6px; + font-size: 11px; + font-weight: 600; +} + +.ws-tag--on { + background: rgba(46, 160, 67, 0.2); + color: #7dffb3; +} + +.ws-tag--off { + background: rgba(255, 180, 80, 0.12); + color: #e8c48a; +} + +.ws-card__hint { + margin: 0.5rem 0 0; + font-size: 11px; + line-height: 1.45; + color: var(--text-muted, #8b929e); +} + +.ws-card__errors { + margin: 0 0 0.5rem; + padding: 0.5rem 0.65rem; + border-radius: 8px; + background: rgba(220, 80, 80, 0.12); + border: 1px solid rgba(220, 100, 100, 0.25); + font-size: 12px; + line-height: 1.45; + color: #f0b4b4; +} + +.ws-card__errors p { + margin: 0.2rem 0; +} + +.ws-card__notes { + margin: 0.5rem 0 0; + padding: 0.45rem 0.65rem; + border-radius: 8px; + background: rgba(100, 149, 237, 0.08); + border: 1px solid rgba(100, 149, 237, 0.2); + font-size: 11px; + line-height: 1.45; + color: #a8b8d0; +} + +.ws-card__notes p { + margin: 0.2rem 0; +} + +.ws-pro-live-desc { + margin: 0 0 0.75rem; + font-size: 12px; + color: var(--text-muted, #8b929e); +} + +.ws-card--live { + border-color: rgba(100, 200, 140, 0.22); +} + +.ws-live-row { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.ws-live-row:first-of-type { + margin-top: 0.35rem; + padding-top: 0; + border-top: none; +} + +.ws-live-row__title { + font-size: 14px; + font-weight: 600; + color: #e8eaed; + margin-bottom: 0.35rem; +} + +.ws-live-row__sub { + font-size: 11px; + font-weight: 400; + color: var(--text-muted, #8b929e); + margin-left: 0.35rem; +} + +.ws-live-row__line { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.5rem; + align-items: baseline; + font-size: 12px; + margin: 0.2rem 0; + line-height: 1.45; +} + +.ws-live-row__clip { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 720px) { + .ws-live-row__clip { + white-space: normal; + } +} + +.ws-kv-list--stack { + list-style: none; + padding-left: 0; +} + +.ws-kv-list--stack > li { + padding: 0.45rem 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + flex-direction: column; + align-items: stretch; +} + +.ws-kv-list--stack > li:last-child { + border-bottom: none; +} + +.ws-kv-list__row { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.5rem; + align-items: baseline; + margin: 0.1rem 0; +} + +.ws-kv-list__sub { + font-size: 11px; + color: var(--text-muted, #8b929e); + min-width: 4.5rem; +} + .ws-card { padding: 0.75rem 1rem; border-radius: 10px; @@ -1268,7 +1780,21 @@ body.ready { margin: 0 0 0.5rem; font-size: 14px; font-weight: 600; - color: #e8eaed; + color: var(--text); +} + +.ws-chart-wrap { + margin-bottom: 0.75rem; + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); + background: var(--bg-card-muted); + overflow: hidden; + box-shadow: var(--shadow-sm); +} + +.ws-chart { + height: 200px; + width: 100%; } .ws-table-wrap { @@ -1676,3 +2202,112 @@ body.ready { color: var(--text-muted); font-weight: 600; } + +/* Electron app 模块:系统页「客户端与系统集成」 */ +.app-host-card { + margin-top: 1rem; +} + +.app-host-grid { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.app-host-block { + padding: 0.65rem 0; + border-top: 1px solid var(--border-subtle); +} + +.app-host-block:first-of-type { + border-top: none; +} + +.app-host-block--wide { + padding-top: 0.75rem; +} + +.app-host-block__title { + font-size: 13px; + font-weight: 600; + margin-bottom: 0.45rem; + color: var(--text); +} + +.app-host-toggle { + display: flex; + align-items: flex-start; + gap: 0.5rem; + cursor: pointer; + font-size: 13px; + color: var(--text-muted); +} + +.app-host-toggle input { + margin-top: 3px; +} + +.app-host-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.app-host-hint { + margin: 0.45rem 0 0; + font-size: 11px; + color: var(--text-muted); + line-height: 1.45; +} + +.app-host-badge-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.app-host-badge-input { + width: 4rem; + padding: 0.35rem 0.5rem; + border-radius: var(--radius-md); + border: 1px solid var(--border-card); + background: var(--bg-input); + color: var(--text); + font-size: 13px; +} + +.app-host-paths { + margin: 0; + padding: 0; + list-style: none; + font-size: 11px; + line-height: 1.5; +} + +.app-host-paths li { + margin-bottom: 0.4rem; + word-break: break-all; +} + +.app-host-paths__k { + display: inline-block; + min-width: 5.5rem; + color: var(--text-muted); + margin-right: 0.35rem; +} + +.app-host-paths__v { + font-size: 10px; + color: var(--text); +} + +.metrics-table-wrap { + overflow: auto; + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); +} + +.app-host-metrics { + font-size: 11px; +} diff --git a/electron/src/renderer/src/App.tsx b/electron/src/renderer/src/App.tsx index 1208ca5..4217299 100644 --- a/electron/src/renderer/src/App.tsx +++ b/electron/src/renderer/src/App.tsx @@ -7,13 +7,86 @@ import SystemMetrics from './SystemMetrics' import WeChatChatbot from './WeChatChatbot' import WorkspaceDashboard from './WorkspaceDashboard' import { logLiveEvent, logUserAction } from './workspace' -import { UI_DROPDOWN_YOUTUBE_ONLY, UI_SHOW_YOUTUBE_GOOGLE_OAUTH } from './constants' +import { + STORAGE_MULTI_CHANNEL_PRO, + UI_DROPDOWN_YOUTUBE_ONLY, + UI_SHOW_YOUTUBE_GOOGLE_OAUTH, +} from './constants' +import { extractDouyinRoomHints, parseYoutubeStreamKeySuffix } from './youtubeConfigUtils' import { isObsScript, isTiktokScript, isYoutubeScript, pm2NameFromScript } from './scriptUtils' type Entry = { script: string; label: string; pm2: string } type NavId = 'desk' | 'record' | 'network' | 'proxy' | 'system' | 'wechat' +type AppTheme = 'light' | 'dark' + +function readInitialTheme(): AppTheme { + try { + const d = document.documentElement.getAttribute('data-theme') + if (d === 'light' || d === 'dark') return d + const x = localStorage.getItem('theme') + if (x === 'light' || x === 'dark') return x + } catch { + /* ignore */ + } + return 'dark' +} + +function ThemeIconSun() { + return ( + + ) +} + +function ThemeIconMoon() { + return ( + + ) +} + +function SidebarBrand({ + tag, + theme, + applyTheme, +}: { + tag: string + theme: AppTheme + applyTheme: (t: AppTheme) => void +}) { + return ( +
+
+
+
+ 直播录制助手 + +
+
{tag}
+
+
+ ) +} + const NAV_ITEMS: { id: NavId; label: string; hint: string }[] = [ { id: 'desk', label: '工作台', hint: '事件与综合仪表盘' }, { id: 'record', label: '录制', hint: '任务与推流控制' }, @@ -47,6 +120,8 @@ function statusPresentation(processStatus: string): { line: string; variant: Sta } export default function App() { + const [theme, setTheme] = useState(readInitialTheme) + const [entries, setEntries] = useState([]) const [currentProcess, setCurrentProcess] = useState('') const [statusMeta, setStatusMeta] = useState<{ line: string; variant: StatusVariant }>({ @@ -71,9 +146,57 @@ export default function App() { const [nav, setNav] = useState('record') const lastLiveStatusRef = useRef<{ process: string; status: string } | null>(null) + const relayOnlineSinceRef = useRef(null) + const [multiChannelPro, setMultiChannelPro] = useState(false) + const [proChannelId, setProChannelId] = useState('') + const [proChannelsList, setProChannelsList] = useState< + { id: string; name: string; keyPreview: string; douyinUrl?: string }[] + >([]) + const [proChannelsError, setProChannelsError] = useState(null) + const [proYoutubeKey, setProYoutubeKey] = useState('') + const [saveProKeyLoading, setSaveProKeyLoading] = useState(false) const logRef = useRef(null) + useEffect(() => { + document.documentElement.setAttribute('data-theme', theme) + try { + localStorage.setItem('theme', theme) + } catch { + /* ignore */ + } + }, [theme]) + + useEffect(() => { + const g = window.appTheme?.get + if (!g) return + void g().then((v) => { + if (v === 'light' || v === 'dark') setTheme(v) + }) + }, []) + + const applyTheme = useCallback((t: AppTheme) => { + setTheme(t) + void window.appTheme?.set?.(t) + }, []) + + useEffect(() => { + const h = window.appHost + if (!h?.onNavigate) return + return h.onNavigate((nav) => { + if ( + nav === 'desk' || + nav === 'record' || + nav === 'network' || + nav === 'proxy' || + nav === 'system' || + nav === 'wechat' + ) { + setNav(nav) + } + }) + }, []) + const scrollLog = useCallback(() => { const el = logRef.current if (el) el.scrollTop = el.scrollHeight @@ -163,6 +286,10 @@ export default function App() { }, []) const fetchYoutubeStreamKey = useCallback(async () => { + if (multiChannelPro) { + setYoutubeStatus('Pro 模式下请在各线路中编辑密钥;OAuth 拉取会写入 youtube.ini,保存后请对该线路再点一次同步或重新选择线路。') + return + } setFetchKeyLoading(true) try { const res = await apiFetch( @@ -178,7 +305,50 @@ export default function App() { } finally { setFetchKeyLoading(false) } - }, [currentProcess, loadConfig]) + }, [currentProcess, loadConfig, multiChannelPro]) + + const saveProChannelKey = useCallback(async () => { + if (!proChannelId) return + setSaveProKeyLoading(true) + try { + const res = await apiFetch(apiUrl('/relay_pro/channel_key'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ channelId: proChannelId, youtubeKey: proYoutubeKey }), + }) + const data = (await res.json()) as { error?: string; message?: string } + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`) + setYoutubeStatus(data.message || '密钥已保存') + void window.relayConfig?.importFromConfig?.() + } catch (err) { + setYoutubeStatus(`保存失败:${err instanceof Error ? err.message : String(err)}`) + } finally { + setSaveProKeyLoading(false) + } + }, [proChannelId, proYoutubeKey]) + + const saveProUrlConfig = useCallback(async () => { + if (!proChannelId) return + setSaveUrlLoading(true) + try { + const res = await apiFetch( + `${apiUrl('/relay_pro/url_config')}?channel_id=${encodeURIComponent(proChannelId)}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: urlText }), + }, + ) + const data = (await res.json()) as { error?: string; message?: string } + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`) + setUrlStatus(data.message || '已保存') + void window.relayConfig?.importFromConfig?.() + } catch (err) { + setUrlStatus(`保存失败:${err instanceof Error ? err.message : String(err)}`) + } finally { + setSaveUrlLoading(false) + } + }, [proChannelId, urlText]) const saveConfig = useCallback( async ( @@ -210,6 +380,16 @@ export default function App() { const isStatus = action === 'status' if (!isStatus) setLoadingAction(action) try { + if (action === 'start' && isYoutube && multiChannelPro) { + if (!proChannelId) throw new Error('请先在下拉框中选择一条转播线路') + const sync = await apiFetch( + `${apiUrl('/relay_pro/sync_active')}?channel_id=${encodeURIComponent(proChannelId)}`, + { method: 'POST' }, + ) + const sj = (await sync.json().catch(() => ({}))) as { error?: string } + if (!sync.ok) throw new Error(sj.error || '同步 youtube.ini / URL_config.ini 失败') + } + const res = await apiFetch(`${apiUrl(`/${action}`)}?process=${encodeURIComponent(currentProcess)}`) if (!res.ok) throw new Error('无法连接到本机服务') const data = await res.json() @@ -217,6 +397,23 @@ export default function App() { if (action === 'status') { const ps = data.process_status as string const prev = lastLiveStatusRef.current + const prevPs = prev?.process === currentProcess ? prev.status : undefined + if (prevPs !== 'online' && ps === 'online') { + relayOnlineSinceRef.current = Date.now() + } + if (prevPs === 'online' && (ps === 'stopped' || ps === 'errored')) { + const t0 = relayOnlineSinceRef.current + if (t0) { + const sec = Math.round((Date.now() - t0) / 1000) + logLiveEvent({ + eventType: 'relay_session', + process: currentProcess, + message: `转播结束,约 ${sec} 秒`, + detail: { durationSec: sec }, + }) + } + relayOnlineSinceRef.current = null + } if (!prev || prev.process !== currentProcess || prev.status !== ps) { lastLiveStatusRef.current = { process: currentProcess, status: ps } logLiveEvent({ eventType: 'process_status', process: currentProcess, status: ps }) @@ -263,6 +460,29 @@ export default function App() { message: `${actionLabel} 已执行`, detail: { action }, }) + if (action === 'start' && isYoutube) { + const keySrc = multiChannelPro ? proYoutubeKey : youtubeText + const suffix = parseYoutubeStreamKeySuffix(keySrc) + const rooms = extractDouyinRoomHints(urlText) + const desk = ( + window as Window & { + deskBridge?: { ensureYoutubeStudio?: (s?: string) => Promise } + } + ).deskBridge + void desk?.ensureYoutubeStudio?.(suffix) + logLiveEvent({ + eventType: 'relay_intent', + process: currentProcess, + message: `推流意图(须一对一:不同抖音↔不同 YouTube key):抖音 ${rooms.join('、') || '(未解析房间)'} → YouTube …${suffix || '?'}`, + detail: { + douyinRooms: rooms, + keySuffix: suffix, + multiChannelPro, + channelId: multiChannelPro ? proChannelId : undefined, + pairingPolicy: 'one_douyin_one_youtube', + }, + }) + } setLogText(`已对「${who}」执行:${actionLabel}\n\n${out}`) setTimeout(() => void runPm2Action('status'), 1500) } @@ -273,13 +493,40 @@ export default function App() { if (!isStatus) setLoadingAction(null) } }, - [currentProcess, getEntry], + [ + currentProcess, + getEntry, + isYoutube, + youtubeText, + urlText, + multiChannelPro, + proChannelId, + proYoutubeKey, + ], ) useEffect(() => { document.body.classList.add('ready') }, []) + useEffect(() => { + try { + const on = localStorage.getItem(STORAGE_MULTI_CHANNEL_PRO) === '1' + setMultiChannelPro(on) + document.body.classList.toggle('record-pro-mode', on) + } catch { + setMultiChannelPro(false) + } + }, []) + + useEffect(() => { + try { + document.body.classList.toggle('record-pro-mode', multiChannelPro) + } catch { + /* ignore */ + } + }, [multiChannelPro]) + useEffect(() => { logUserAction('navigate', nav) }, [nav]) @@ -328,9 +575,75 @@ export default function App() { useEffect(() => { if (!ent || !currentProcess) return + if (isYoutube && multiChannelPro) return if (isYoutube) void loadConfig('/get_config', setYoutubeText, setYoutubeStatus) if (isYoutube || isTiktok) void loadConfig('/get_url_config', setUrlText, setUrlStatus) - }, [ent, currentProcess, isYoutube, isTiktok, loadConfig]) + }, [ent, currentProcess, isYoutube, isTiktok, loadConfig, multiChannelPro]) + + useEffect(() => { + if (!isYoutube || !multiChannelPro) return + let cancelled = false + void (async () => { + try { + const r = await apiFetch(apiUrl('/relay_pro/channels')) + const d = (await r.json()) as { ok?: boolean; error?: string; channels?: typeof proChannelsList } + if (cancelled) return + if (!d.ok) { + setProChannelsError(d.error || '无法加载 Pro 频道') + setProChannelsList([]) + return + } + setProChannelsError(null) + setProChannelsList(d.channels || []) + setProChannelId((prev) => { + if (prev && (d.channels || []).some((c) => c.id === prev)) return prev + return d.channels?.[0]?.id ?? '' + }) + } catch (e) { + if (!cancelled) { + setProChannelsError(e instanceof Error ? e.message : String(e)) + setProChannelsList([]) + } + } + })() + return () => { + cancelled = true + } + }, [isYoutube, multiChannelPro]) + + useEffect(() => { + if (!isYoutube || !multiChannelPro || !proChannelId) return + let cancelled = false + void (async () => { + try { + const dr = await apiFetch( + apiUrl(`/relay_pro/channel_detail?channel_id=${encodeURIComponent(proChannelId)}`), + ) + if (!dr.ok) { + if (!cancelled) setYoutubeStatus('读取线路失败') + return + } + const cd = (await dr.json()) as { youtubeKey?: string } + if (cancelled) return + setProYoutubeKey(cd.youtubeKey || '') + const ur = await apiFetch( + apiUrl(`/relay_pro/url_config?channel_id=${encodeURIComponent(proChannelId)}`), + ) + if (ur.ok) { + const u = (await ur.json()) as { content?: string } + if (!cancelled) { + setUrlText(u.content || '') + setUrlStatus('已读取当前线路的地址文件(与其它线路去重校验在保存时进行)') + } + } + } catch (e) { + if (!cancelled) setUrlStatus(e instanceof Error ? e.message : String(e)) + } + })() + return () => { + cancelled = true + } + }, [isYoutube, multiChannelPro, proChannelId]) useEffect(() => { if (!UI_SHOW_YOUTUBE_GOOGLE_OAUTH || !isYoutube) return @@ -347,13 +660,7 @@ export default function App() { return (
@@ -377,13 +684,7 @@ export default function App() { return (
{ent && ( @@ -492,7 +813,154 @@ export default function App() { {!emptyHint && (
- {isYoutube && ( + {isYoutube && multiChannelPro && ( +
+

Pro 转播线路

+

+ 每条线路对应独立配置文件;下拉选择线路即选择该路的 YouTube 串流密钥。地址列表保存在{' '} + config/relay_pro/url_config.<id>.ini,保存时与其它线路去重。 + 点「开始录制」会先把当前选中线路同步到 youtube.ini 与{' '} + URL_config.ini 再启动(当前仍为单路 youtube 进程;多进程并行将在后续版本接入)。 +

+ {proChannelsError && ( +

+ {proChannelsError} +

+ )} + {!proChannelsError && proChannelsList.length > 0 && ( +
+ + +
+ )} + {!proChannelsError && proChannelsList.length === 0 && ( +

+ youtube_channels.json 中暂无有效条目。请至少配置一条线路(含 id、name、douyinUrl、youtubeKey)。 +

+ )} +
+ )} + + {isYoutube && multiChannelPro && !proChannelsError && proChannelsList.length > 0 && ( + <> +
+

YouTube 串流密钥(当前线路)

+

+ 仅保存到当前线路;多线路之间禁止重复使用同一密钥。 +

+ {UI_SHOW_YOUTUBE_GOOGLE_OAUTH && ( +
+ + {ytOauth?.connected ? '已连接 Google' : '未连接 Google'} + + + +
+ )} +
+ 当前线路 key(youtubeKey) +