From aaf786b292fe97a63d6a495e4c49b0c906f132bb Mon Sep 17 00:00:00 2001 From: eric Date: Fri, 27 Mar 2026 00:23:17 -0500 Subject: [PATCH] 's' --- .gitignore | 5 + electron/package.json | 11 +- electron/scripts/prepare-runtime.mjs | 173 +++++++++++++++++++++ electron/scripts/stage-offline-runtime.mjs | 47 ++++++ electron/scripts/verify-runtime.mjs | 88 +++++++++++ electron/src/main/index.ts | 165 +++++++++++++++----- electron/src/preload/index.ts | 9 ++ electron/src/renderer/src/App.css | 52 +++++++ electron/src/renderer/src/App.tsx | 63 ++++++-- electron/src/renderer/src/vite-env.d.ts | 4 + tools/ffmpeg/懒人包说明.txt | 3 - 11 files changed, 564 insertions(+), 56 deletions(-) create mode 100644 electron/scripts/prepare-runtime.mjs create mode 100644 electron/scripts/stage-offline-runtime.mjs create mode 100644 electron/scripts/verify-runtime.mjs delete mode 100644 tools/ffmpeg/懒人包说明.txt diff --git a/.gitignore b/.gitignore index 613d518..ea07064 100644 --- a/.gitignore +++ b/.gitignore @@ -189,3 +189,8 @@ electron/resources/chrome-cft/NOTICE-ChromeForTesting.txt electron/node_modules/ electron/dist/ electron/out/ +electron/.tmp-prepare-runtime/ + +# 嵌入式 Python / FFmpeg(由 electron/scripts/prepare-runtime.mjs 生成,勿提交大文件) +/python/ +/tools/ffmpeg/bin/ diff --git a/electron/package.json b/electron/package.json index 418a4e6..66f9115 100644 --- a/electron/package.json +++ b/electron/package.json @@ -10,7 +10,10 @@ "build": "electron-vite build", "preview": "electron-vite preview", "gen:icons": "node ./scripts/generate-icons.mjs", - "dist": "pnpm run build && pnpm run gen:icons && electron-builder --win --publish never" + "stage:offline-runtime": "node ./scripts/stage-offline-runtime.mjs", + "prepare:runtime": "node ./scripts/prepare-runtime.mjs", + "verify:runtime": "node ./scripts/verify-runtime.mjs", + "dist": "pnpm run prepare:runtime && pnpm run build && pnpm run gen:icons && pnpm run verify:runtime && electron-builder --win --publish never" }, "dependencies": { "d3": "^7.9.0", @@ -55,7 +58,11 @@ "files": [ "out/**/*", "package.json", - "build/**/*" + "build/**/*", + "node_modules/sql.js/**/*" + ], + "asarUnpack": [ + "**/node_modules/sql.js/**/*.wasm" ], "extraResources": [ { diff --git a/electron/scripts/prepare-runtime.mjs b/electron/scripts/prepare-runtime.mjs new file mode 100644 index 0000000..7e8241d --- /dev/null +++ b/electron/scripts/prepare-runtime.mjs @@ -0,0 +1,173 @@ +/** + * 构建机专用:下载 Windows 嵌入式 Python + FFmpeg,安装 requirements.txt。 + * 产物写入仓库根目录 python/ 与 tools/ffmpeg/,随 Electron 打包分发;最终用户无需联网。 + */ +import fs from 'fs' +import path from 'path' +import { spawnSync } from 'child_process' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const electronRoot = path.resolve(__dirname, '..') +const backendRoot = path.resolve(electronRoot, '..') + +const PY_VER = '3.12.8' +const PYTHON_ZIP = `https://www.python.org/ftp/python/${PY_VER}/python-${PY_VER}-embed-amd64.zip` +const GET_PIP = 'https://bootstrap.pypa.io/get-pip.py' +/** BtbN 静态构建,单 zip 内含 bin/ffmpeg.exe */ +const FFMPEG_ZIP = + 'https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip' + +const runtimeDir = path.join(backendRoot, 'python') +const embedExe = path.join(runtimeDir, 'python.exe') +const toolsFfmpegBin = path.join(backendRoot, 'tools', 'ffmpeg', 'bin') +const ffmpegExe = path.join(toolsFfmpegBin, 'ffmpeg.exe') +const requirementsTxt = path.join(backendRoot, 'requirements.txt') +const tmpDir = path.join(electronRoot, '.tmp-prepare-runtime') + +function rmrf(p) { + if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true }) +} + +function downloadFile(url, dest) { + fs.mkdirSync(path.dirname(dest), { recursive: true }) + const u = url.replace(/'/g, "''") + const d = dest.replace(/'/g, "''") + const r = spawnSync( + 'powershell.exe', + [ + '-NoProfile', + '-Command', + `$ProgressPreference='SilentlyContinue'; Invoke-WebRequest -Uri '${u}' -OutFile '${d}' -UseBasicParsing`, + ], + { stdio: 'inherit', windowsHide: true }, + ) + if (r.status !== 0) throw new Error(`下载失败: ${url}`) +} + +function unzipWithPowerShell(zipPath, destDir) { + fs.mkdirSync(destDir, { recursive: true }) + const ps = spawnSync( + 'powershell.exe', + [ + '-NoProfile', + '-Command', + `Expand-Archive -LiteralPath '${zipPath.replace(/'/g, "''")}' -DestinationPath '${destDir.replace(/'/g, "''")}' -Force`, + ], + { stdio: 'inherit', windowsHide: true }, + ) + if (ps.status !== 0) throw new Error('Expand-Archive failed') +} + +function enableEmbedSitePackages() { + const pthFiles = fs.readdirSync(runtimeDir).filter((f) => f.endsWith('._pth')) + for (const f of pthFiles) { + const p = path.join(runtimeDir, f) + const base = f.replace(/\._pth$/, '') + const zipLine = `${base}.zip` + // 嵌入式解释器必须显式启用 import site,Lib\\site-packages 才会进 sys.path(pip 才能用) + fs.writeFileSync(p, `${zipLine}\n.\n\nimport site\n`, 'utf8') + } +} + +function hasWorkingEmbedPython() { + if (!fs.existsSync(embedExe)) return false + if (fs.existsSync(path.join(runtimeDir, 'pyvenv.cfg'))) return false + if (fs.existsSync(path.join(runtimeDir, 'Scripts', 'python.exe'))) return false + const r = spawnSync(embedExe, ['-c', 'import fastapi,uvicorn,requests,psutil'], { + cwd: backendRoot, + encoding: 'utf8', + windowsHide: true, + timeout: 60000, + }) + return r.status === 0 +} + +function hasFfmpeg() { + return fs.existsSync(ffmpegExe) +} + +function installEmbeddablePython() { + console.log('[prepare:runtime] 安装嵌入式 Python ' + PY_VER + ' …') + rmrf(tmpDir) + fs.mkdirSync(tmpDir, { recursive: true }) + const zipPath = path.join(tmpDir, 'python-embed.zip') + downloadFile(PYTHON_ZIP, zipPath) + rmrf(runtimeDir) + fs.mkdirSync(runtimeDir, { recursive: true }) + unzipWithPowerShell(zipPath, runtimeDir) + enableEmbedSitePackages() + + const getPipPath = path.join(tmpDir, 'get-pip.py') + downloadFile(GET_PIP, getPipPath) + let pip = spawnSync(embedExe, [getPipPath, '--no-warn-script-location'], { + cwd: runtimeDir, + stdio: 'inherit', + windowsHide: true, + env: { ...process.env, PYTHONPATH: '' }, + }) + if (pip.status !== 0) throw new Error('get-pip failed') + + pip = spawnSync(embedExe, ['-m', 'pip', 'install', '--upgrade', 'pip', 'setuptools', 'wheel'], { + cwd: backendRoot, + stdio: 'inherit', + windowsHide: true, + }) + if (pip.status !== 0) throw new Error('pip upgrade failed') + + if (!fs.existsSync(requirementsTxt)) throw new Error('Missing requirements.txt') + pip = spawnSync(embedExe, ['-m', 'pip', 'install', '-r', requirementsTxt], { + cwd: backendRoot, + stdio: 'inherit', + windowsHide: true, + }) + if (pip.status !== 0) throw new Error('pip install -r requirements.txt failed') + console.log('[prepare:runtime] 嵌入式 Python + 依赖已就绪。') +} + +function installFfmpeg() { + console.log('[prepare:runtime] 下载 FFmpeg(win64)…') + rmrf(tmpDir) + fs.mkdirSync(tmpDir, { recursive: true }) + const zipPath = path.join(tmpDir, 'ffmpeg.zip') + downloadFile(FFMPEG_ZIP, zipPath) + const extractRoot = path.join(tmpDir, 'ff') + unzipWithPowerShell(zipPath, extractRoot) + const dirs = fs.readdirSync(extractRoot) + const top = dirs.length === 1 ? path.join(extractRoot, dirs[0]) : extractRoot + const binSrc = path.join(top, 'bin') + if (!fs.existsSync(path.join(binSrc, 'ffmpeg.exe'))) { + throw new Error('ffmpeg zip layout unexpected: missing bin/ffmpeg.exe') + } + rmrf(path.join(backendRoot, 'tools', 'ffmpeg')) + fs.mkdirSync(toolsFfmpegBin, { recursive: true }) + for (const name of fs.readdirSync(binSrc)) { + fs.copyFileSync(path.join(binSrc, name), path.join(toolsFfmpegBin, name)) + } + console.log('[prepare:runtime] FFmpeg 已安装到 tools/ffmpeg/bin/') +} + +try { + if (!hasWorkingEmbedPython()) { + if (fs.existsSync(path.join(runtimeDir, 'pyvenv.cfg'))) { + console.log('[prepare:runtime] 移除旧 venv,改用嵌入式 Python …') + rmrf(runtimeDir) + } + installEmbeddablePython() + } else { + console.log('[prepare:runtime] 已存在可用的嵌入式 Python,跳过下载。') + } + + if (!hasFfmpeg()) { + installFfmpeg() + } else { + console.log('[prepare:runtime] 已存在 tools/ffmpeg/bin/ffmpeg.exe,跳过下载。') + } + + rmrf(tmpDir) + console.log('[prepare:runtime] 全部完成。') +} catch (e) { + console.error('[prepare:runtime] 失败:', e) + process.exit(1) +} diff --git a/electron/scripts/stage-offline-runtime.mjs b/electron/scripts/stage-offline-runtime.mjs new file mode 100644 index 0000000..22836bc --- /dev/null +++ b/electron/scripts/stage-offline-runtime.mjs @@ -0,0 +1,47 @@ +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const electronRoot = path.resolve(__dirname, '..') +const backendRoot = path.resolve(electronRoot, '..') +const outRoot = path.join(electronRoot, 'build', 'offline-runtime') + +function copyDirRecursiveSync(src, dest) { + fs.mkdirSync(dest, { recursive: true }) + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const s = path.join(src, entry.name) + const d = path.join(dest, entry.name) + if (entry.isDirectory()) copyDirRecursiveSync(s, d) + else if (entry.isFile()) fs.copyFileSync(s, d) + } +} + +function rmrfSync(p) { + if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true }) +} + +const pythonSrc = path.join(backendRoot, 'python') +const ffmpegCandidates = [ + path.join(backendRoot, 'ffmpeg'), + path.join(backendRoot, 'tools', 'ffmpeg'), +] +const ffmpegSrc = ffmpegCandidates.find((p) => fs.existsSync(path.join(p, 'ffmpeg.exe')) || fs.existsSync(path.join(p, 'bin', 'ffmpeg.exe'))) + +if (!fs.existsSync(pythonSrc)) { + console.error(`[stage:offline-runtime] 缺少目录: ${pythonSrc}`) + process.exit(1) +} +if (!ffmpegSrc) { + console.error('[stage:offline-runtime] 缺少 ffmpeg 目录(ffmpeg 或 tools/ffmpeg)') + process.exit(1) +} + +rmrfSync(outRoot) +fs.mkdirSync(outRoot, { recursive: true }) +copyDirRecursiveSync(pythonSrc, path.join(outRoot, 'python')) +copyDirRecursiveSync(ffmpegSrc, path.join(outRoot, 'ffmpeg')) + +console.log(`[stage:offline-runtime] staged to ${path.relative(electronRoot, outRoot)}`) + diff --git a/electron/scripts/verify-runtime.mjs b/electron/scripts/verify-runtime.mjs new file mode 100644 index 0000000..e9716d2 --- /dev/null +++ b/electron/scripts/verify-runtime.mjs @@ -0,0 +1,88 @@ +import fs from 'fs' +import path from 'path' +import { spawnSync } from 'child_process' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const electronRoot = path.resolve(__dirname, '..') +const backendRoot = path.resolve(electronRoot, '..') + +const pythonCandidates = [ + path.join(backendRoot, 'python', 'python.exe'), + path.join(backendRoot, 'python', 'Scripts', 'python.exe'), +] +const web2Py = path.join(backendRoot, 'web2.py') +const ffmpegCandidates = [ + path.join(backendRoot, 'ffmpeg', 'ffmpeg.exe'), + path.join(backendRoot, 'ffmpeg', 'bin', 'ffmpeg.exe'), + path.join(backendRoot, 'tools', 'ffmpeg', 'ffmpeg.exe'), + path.join(backendRoot, 'tools', 'ffmpeg', 'bin', 'ffmpeg.exe'), +] + +const errors = [] + +if (!fs.existsSync(web2Py)) { + errors.push(`缺少后端入口文件: ${web2Py}`) +} +const pythonExe = pythonCandidates.find((p) => fs.existsSync(p)) ?? null +if (!pythonExe) { + errors.push(`缺少内置 Python(任一路径即可):\n- ${pythonCandidates[0]}\n- ${pythonCandidates[1]}`) +} +// venv 结构在跨机器分发时可能依赖构建机本地 Python,不可作为懒人包运行时。 +if (fs.existsSync(path.join(backendRoot, 'python', 'pyvenv.cfg'))) { + errors.push( + `检测到 venv 运行时(${path.join( + backendRoot, + 'python', + 'pyvenv.cfg', + )})。该结构不保证跨机器可用,请改为可分发的独立 Python Runtime(嵌入式/便携式)。`, + ) +} + +const ffmpegExe = ffmpegCandidates.find((p) => fs.existsSync(p)) ?? null +if (!ffmpegExe) { + errors.push( + `缺少 ffmpeg.exe(以下任一路径都可):\n${ffmpegCandidates.map((p) => `- ${p}`).join('\n')}`, + ) +} + +if (pythonExe) { + const check = spawnSync( + pythonExe, + ['-c', 'import fastapi,uvicorn,requests,psutil;print("runtime_ok")'], + { + cwd: backendRoot, + encoding: 'utf8', + windowsHide: true, + timeout: 30000, + }, + ) + if (check.status !== 0) { + errors.push( + `内置 Python 缺少运行依赖(fastapi/uvicorn/requests/psutil)。\n请先在 ${path.join( + backendRoot, + 'python', + )} 环境安装 requirements.txt。\n\nstdout:\n${check.stdout || '(empty)'}\n\nstderr:\n${check.stderr || '(empty)'}`, + ) + } +} + +if (errors.length) { + console.error('[verify:runtime] 运行时检查失败:\n') + for (const e of errors) console.error(`- ${e}\n`) + process.exit(1) +} + +const st = spawnSync('node', [path.join(__dirname, 'stage-offline-runtime.mjs')], { + cwd: electronRoot, + stdio: 'inherit', +}) +if (st.status !== 0) { + console.error('[verify:runtime] 生成 offline-runtime 镜像失败') + process.exit(1) +} + +console.log('[verify:runtime] OK: 内置 Python/依赖/ffmpeg 已就绪,offline-runtime 已同步。') + diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts index 3f7558e..50fae2b 100644 --- a/electron/src/main/index.ts +++ b/electron/src/main/index.ts @@ -49,8 +49,49 @@ let _pythonExe: string | null = null /** 自启动参数 --nav= 传入的页面,首屏或二次实例时下发渲染进程 */ let pendingNav: string | null = null let tray: Tray | null = null +/** 本地服务 + 用户协议均完成后为 true,渲染进程据此解除启动遮罩 */ +let appUiReadyFlag = false let isQuitting = false +/** + * 从 asar 内 offline-runtime 解压到可写 resources/backend(仅当 extraResources 未带上完整运行时)。 + * 正常安装包下 resources/backend 已有 python/ffmpeg,此处立即返回,无拷贝开销。 + */ +async function ensureOfflineRuntimeAssetsAsync(backendRoot: string): Promise { + if (!app.isPackaged) return + try { + const backendPython = path.join(backendRoot, 'python') + const hasBackendPython = + fs.existsSync(path.join(backendPython, 'python.exe')) || + fs.existsSync(path.join(backendPython, 'Scripts', 'python.exe')) + const ffmpegTargets = [ + path.join(backendRoot, 'ffmpeg', 'ffmpeg.exe'), + path.join(backendRoot, 'ffmpeg', 'bin', 'ffmpeg.exe'), + path.join(backendRoot, 'tools', 'ffmpeg', 'ffmpeg.exe'), + path.join(backendRoot, 'tools', 'ffmpeg', 'bin', 'ffmpeg.exe'), + ] + const hasFfmpeg = ffmpegTargets.some((p) => fs.existsSync(p)) + if (hasBackendPython && hasFfmpeg) return + + const backupRoot = path.join(app.getAppPath(), 'build', 'offline-runtime') + const backupPython = path.join(backupRoot, 'python') + if (!hasBackendPython && fs.existsSync(backupPython)) { + await fs.promises.cp(backupPython, backendPython, { recursive: true, force: true }) + } + + const hasFfmpegAfter = ffmpegTargets.some((p) => fs.existsSync(p)) + const backupFfmpeg = path.join(backupRoot, 'ffmpeg') + if (!hasFfmpegAfter && fs.existsSync(backupFfmpeg)) { + await fs.promises.cp(backupFfmpeg, path.join(backendRoot, 'tools', 'ffmpeg'), { + recursive: true, + force: true, + }) + } + } catch (e) { + console.warn('[runtime restore]', e) + } +} + function resolveAppIconPath(): string | null { // 构建后 app.asar 内也会包含 build/icon.ico(见 package.json build.files) const devCandidate = path.resolve(__dirname, '../../build/icon.ico') @@ -121,6 +162,7 @@ function collectLanCandidateIPs(): { address: string; name: string }[] { async function restartWeb2Backend(): Promise<{ ok: true } | { ok: false; error: string }> { const br = _backendRoot ?? getBackendRoot() + await ensureOfflineRuntimeAssetsAsync(br) const py = _pythonExe ?? resolvePythonExe(br) web2Host = resolveWeb2Host() await killPythonProcessForRestart() @@ -218,8 +260,14 @@ function resolvePythonExe(backendRoot: string): string { if (process.env.PYTHON_EXE && fs.existsSync(process.env.PYTHON_EXE)) { return process.env.PYTHON_EXE } - const embedded = path.join(backendRoot, 'python', 'python.exe') - if (fs.existsSync(embedded)) return embedded + const bundledCandidates = [ + path.join(backendRoot, 'python', 'python.exe'), + path.join(backendRoot, 'python', 'Scripts', 'python.exe'), + ] + for (const p of bundledCandidates) { + if (fs.existsSync(p)) return p + } + // 发布包优先使用内置 Python;如异常缺失,最后回退系统 Python,尽量保证可启动。 return process.platform === 'win32' ? 'python' : 'python3' } @@ -238,6 +286,8 @@ function buildChildEnv(backendRoot: string): NodeJS.ProcessEnv { const prepend: string[] = [] const ffmpegDirs = [ + path.join(backendRoot, 'ffmpeg'), + path.join(backendRoot, 'ffmpeg', 'bin'), path.join(backendRoot, 'tools', 'ffmpeg'), path.join(backendRoot, 'tools', 'ffmpeg', 'bin'), ] @@ -250,7 +300,11 @@ function buildChildEnv(backendRoot: string): NodeJS.ProcessEnv { prepend.push(portablePython) const scripts = path.join(portablePython, 'Scripts') if (fs.existsSync(scripts)) prepend.push(scripts) - env.PYTHONHOME = portablePython + // 仅在嵌入式 Python 目录(python/python.exe)存在时设置 PYTHONHOME。 + // 若是 venv 结构(python/Scripts/python.exe),强设 PYTHONHOME 会破坏 venv 解析。 + if (fs.existsSync(path.join(portablePython, 'python.exe'))) { + env.PYTHONHOME = portablePython + } } if (prepend.length && env.PATH) { @@ -327,7 +381,7 @@ function waitForHealth(timeoutMs = 120000): Promise { }) req.on('error', () => req.destroy()) } - const iv = setInterval(tick, 400) + const iv = setInterval(tick, 120) tick() }) } @@ -419,6 +473,9 @@ async function createWindow(): Promise { mainWindow.once('ready-to-show', () => mainWindow?.show()) mainWindow.webContents.once('did-finish-load', () => { appendClientLog('info', 'main', '渲染界面已加载') + if (appUiReadyFlag) { + mainWindow?.webContents.send('app:ready') + } if (pendingNav && mainWindow && !mainWindow.isDestroyed()) { sendNavToWindow(mainWindow, pendingNav) pendingNav = null @@ -484,11 +541,35 @@ async function createWindow(): Promise { const gotLock = app.requestSingleInstanceLock() if (!gotLock) { - app.quit() + void app.whenReady().then(() => { + dialog.showMessageBoxSync({ + type: 'info', + title: '无人直播助手', + message: + '程序已在运行,请从任务栏或系统托盘打开窗口。\n若看不到窗口,请点击托盘中的「无人直播助手」图标。', + }) + app.quit() + }) } else { setAppUserModelIdEarly() pendingNav = parseNavFromArgv(process.argv) + process.on('uncaughtException', (err) => { + console.error('[uncaughtException]', err) + try { + dialog.showErrorBox( + '无人直播助手', + `启动异常:${err instanceof Error ? err.message : String(err)}`, + ) + } catch { + /* ignore */ + } + app.quit() + }) + process.on('unhandledRejection', (reason) => { + console.error('[unhandledRejection]', reason) + }) + app.on('second-instance', (_evt, commandLine) => { const argv = Array.isArray(commandLine) ? commandLine @@ -507,17 +588,19 @@ if (!gotLock) { 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) - } + /** 不阻塞首屏:窗口须先出现,sql.js 与 DB 在后台就绪 */ + void initClientDb() + .then(() => { + try { + syncRelayChannelsWithBackendConfig(path.join(getBackendRoot(), 'config')) + } catch (e) { + console.error('[relay sync]', e) + } + appendClientLog('info', 'main', '客户端主进程就绪') + }) + .catch((e) => { + console.error('[clientDb]', e) + }) ipcMain.removeHandler('workspace:log-live') ipcMain.handle('workspace:log-live', (_evt, payload: unknown) => { @@ -666,9 +749,19 @@ if (!gotLock) { return r }) + ipcMain.removeHandler('app:is-ready') + ipcMain.handle('app:is-ready', () => appUiReadyFlag) + web2Host = resolveWeb2Host() const backendRoot = getBackendRoot() + + /** 与 createWindow 并行:解压运行时与加载界面同时进行,缩短到「可交互」的总耗时 */ + const runtimePrep = ensureOfflineRuntimeAssetsAsync(backendRoot) + await Promise.all([createWindow(), runtimePrep]) + + appendClientLog('info', 'main', '主窗口与离线运行时已就绪,正在启动本地服务') + const pythonExe = resolvePythonExe(backendRoot) _backendRoot = backendRoot _pythonExe = pythonExe @@ -681,30 +774,28 @@ if (!gotLock) { app.quit() return } - startPythonBackend(backendRoot, pythonExe) - try { - await waitForHealth() - } catch (e) { - dialog.showErrorBox( - '无人直播助手', - `软件未能正常启动。\n\n${e instanceof Error ? e.message : String(e)}`, - ) - killPythonTree() - app.quit() - return - } + appendClientLog('info', 'main', '后台并行启动本地服务与用户协议') - appendClientLog('info', 'main', '本地服务已连接,准备打开界面') - - try { - await ensureUserAgreementAccepted(resolvePreload) - } catch { - return - } - - await createWindow() + void (async () => { + try { + await Promise.all([waitForHealth(), ensureUserAgreementAccepted(resolvePreload)]) + appUiReadyFlag = true + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('app:ready') + } + appendClientLog('info', 'main', '本地服务已就绪,界面可交互') + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + if (msg.includes('user refused') || msg.includes('agreement window closed')) { + return + } + dialog.showErrorBox('无人直播助手', `软件未能正常启动。\n\n${msg}`) + killPythonTree() + app.quit() + } + })() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) void createWindow() diff --git a/electron/src/preload/index.ts b/electron/src/preload/index.ts index 9e9f5f6..e16f355 100644 --- a/electron/src/preload/index.ts +++ b/electron/src/preload/index.ts @@ -42,6 +42,15 @@ contextBridge.exposeInMainWorld('userAgreement', { refuse: () => ipcRenderer.send('user-agreement:refuse'), }) +contextBridge.exposeInMainWorld('appBootstrap', { + isReady: (): Promise => ipcRenderer.invoke('app:is-ready'), + onReady: (cb: () => void): (() => void) => { + const fn = (): void => cb() + ipcRenderer.on('app:ready', fn) + return () => ipcRenderer.removeListener('app:ready', fn) + }, +}) + contextBridge.exposeInMainWorld('relayConfig', { syncConfig: () => ipcRenderer.invoke('relay:sync-config'), importFromConfig: () => ipcRenderer.invoke('relay:import-from-config'), diff --git a/electron/src/renderer/src/App.css b/electron/src/renderer/src/App.css index 3ab165e..99d3133 100644 --- a/electron/src/renderer/src/App.css +++ b/electron/src/renderer/src/App.css @@ -1007,6 +1007,58 @@ body.ready { word-break: break-word; } +/* 主窗口已显示、本地服务尚未就绪时的遮罩 */ +.app-startup-overlay { + position: fixed; + inset: 0; + z-index: 99999; + display: grid; + place-items: center; + padding: 24px; + background: rgba(10, 12, 18, 0.72); + backdrop-filter: blur(8px); +} + +.app-startup-overlay__panel { + text-align: center; + padding: 28px 32px; + border-radius: var(--radius-lg); + background: var(--bg-card); + border: 1px solid var(--border-card); + box-shadow: var(--shadow-md); + max-width: 380px; +} + +.app-startup-overlay__spinner { + width: 40px; + height: 40px; + margin: 0 auto 16px; + border-radius: 50%; + border: 3px solid rgba(91, 157, 255, 0.22); + border-top-color: rgba(91, 157, 255, 0.95); + animation: app-startup-spin 0.75s linear infinite; +} + +@keyframes app-startup-spin { + to { + transform: rotate(360deg); + } +} + +.app-startup-overlay__title { + margin: 0 0 8px; + font-size: 15px; + font-weight: 650; + color: var(--text); +} + +.app-startup-overlay__hint { + margin: 0; + font-size: 12px; + line-height: 1.55; + color: var(--text-muted); +} + /* 主内容 */ .app-main { display: flex; diff --git a/electron/src/renderer/src/App.tsx b/electron/src/renderer/src/App.tsx index f6bfab1..544637a 100644 --- a/electron/src/renderer/src/App.tsx +++ b/electron/src/renderer/src/App.tsx @@ -372,6 +372,8 @@ export default function App() { variant: 'loading', }) const [bootError, setBootError] = useState(null) + /** Electron:主进程已显示窗口但本地服务/协议未完成时为 false,避免误请求 API */ + const [shellReady, setShellReady] = useState(() => typeof window === 'undefined' || !window.appBootstrap) const [emptyHint, setEmptyHint] = useState(null) const [ytIniModel, setYtIniModel] = useState({ @@ -431,6 +433,25 @@ export default function App() { } const [proRelayStatus, setProRelayStatus] = useState(null) + useEffect(() => { + const b = window.appBootstrap + if (!b) { + setShellReady(true) + return + } + let cancelled = false + void b.isReady().then((r) => { + if (!cancelled && r) setShellReady(true) + }) + const off = b.onReady(() => { + if (!cancelled) setShellReady(true) + }) + return () => { + cancelled = true + off() + } + }, []) + useEffect(() => { document.documentElement.setAttribute('data-theme', theme) try { @@ -511,6 +532,7 @@ export default function App() { const isObs = ent ? isObsScript(script) : false useEffect(() => { + if (!shellReady) return let cancelled = false void apiFetch(apiUrl('/host/gpu_status')) .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))) @@ -527,7 +549,7 @@ export default function App() { return () => { cancelled = true } - }, []) + }, [shellReady]) useEffect(() => { if (!isYoutube || multiChannelPro) return @@ -967,6 +989,7 @@ export default function App() { }, [nav]) useEffect(() => { + if (!shellReady) return let cancelled = false ;(async () => { try { @@ -1003,7 +1026,7 @@ export default function App() { return () => { cancelled = true } - }, [loadProcessMonitor]) + }, [loadProcessMonitor, shellReady]) useEffect(() => { if (!entries.length || !currentProcess) return @@ -1060,9 +1083,9 @@ export default function App() { }, [isYoutube, multiChannelPro]) useEffect(() => { - if (!isYoutube || !multiChannelPro) return + if (!shellReady || !isYoutube || !multiChannelPro) return void reloadProChannelsList() - }, [isYoutube, multiChannelPro, reloadProChannelsList]) + }, [shellReady, isYoutube, multiChannelPro, reloadProChannelsList]) const submitNewChannelDraft = useCallback(async () => { if (!newChannelDraft) return @@ -1242,7 +1265,7 @@ export default function App() { ) useEffect(() => { - if (!isYoutube || !multiChannelPro) return + if (!shellReady || !isYoutube || !multiChannelPro) return let cancelled = false const poll = async () => { try { @@ -1260,7 +1283,7 @@ export default function App() { cancelled = true clearInterval(id) } - }, [isYoutube, multiChannelPro]) + }, [shellReady, isYoutube, multiChannelPro]) const channelIdsSig = useMemo( () => proChannelsList.map((c) => c.id).join('\0'), @@ -1268,7 +1291,7 @@ export default function App() { ) useEffect(() => { - if (!isYoutube || !multiChannelPro || !proChannelsList.length) { + if (!shellReady || !isYoutube || !multiChannelPro || !proChannelsList.length) { if (!multiChannelPro) setProChannelEditors({}) return } @@ -1311,11 +1334,11 @@ export default function App() { return () => { cancelled = true } - }, [isYoutube, multiChannelPro, channelIdsSig, proChannelsList]) + }, [shellReady, isYoutube, multiChannelPro, channelIdsSig, proChannelsList]) /** 唯一从服务端填充「直播地址列表」的路径(与全量密钥同步解耦) */ useEffect(() => { - if (!isYoutube || !multiChannelPro || !highlightedChannelId) return + if (!shellReady || !isYoutube || !multiChannelPro || !highlightedChannelId) return const cid = highlightedChannelId let cancelled = false void (async () => { @@ -1342,14 +1365,14 @@ export default function App() { return () => { cancelled = true } - }, [highlightedChannelId, isYoutube, multiChannelPro]) + }, [shellReady, highlightedChannelId, isYoutube, multiChannelPro]) useEffect(() => { - if (!UI_SHOW_YOUTUBE_GOOGLE_OAUTH || !isYoutube) return + if (!shellReady || !UI_SHOW_YOUTUBE_GOOGLE_OAUTH || !isYoutube) return void loadYtOauthStatus() const id = window.setInterval(() => void loadYtOauthStatus(), 5000) return () => clearInterval(id) - }, [isYoutube, loadYtOauthStatus]) + }, [shellReady, isYoutube, loadYtOauthStatus]) const douyinHints = useMemo(() => { if (!isYoutube && !isTiktok) return [] @@ -1368,7 +1391,7 @@ export default function App() { }, [isYoutube, multiChannelPro, highlightedChannelId, proChannelEditors, urlText]) useEffect(() => { - if (!isYoutube && !isTiktok) return + if (!shellReady || (!isYoutube && !isTiktok)) return const urls = extractDouyinLiveUrls(previewUrlText) if (!urls.length) { setDouyinPreviews([]) @@ -1387,7 +1410,7 @@ export default function App() { .catch(() => setDouyinPreviews([])) }, 450) return () => clearTimeout(t) - }, [previewUrlText, isYoutube, isTiktok]) + }, [shellReady, previewUrlText, isYoutube, isTiktok]) const openExternalUrl = useCallback((href: string) => { const open = window.recorderConsole?.openExternal @@ -2303,6 +2326,18 @@ export default function App() { + {!shellReady && ( +
+
+
+

正在准备本地服务

+

+ 界面与后台准备并行进行。若安装包未带齐运行时,首次会从内置包解压,可能稍久。 +

+
+
+ )} + void refuse: () => void } + appBootstrap?: { + isReady: () => Promise + onReady: (cb: () => void) => () => void + } relayConfig?: { syncConfig?: () => Promise<{ ok: true }> importFromConfig?: () => Promise<{ ok: true }> diff --git a/tools/ffmpeg/懒人包说明.txt b/tools/ffmpeg/懒人包说明.txt deleted file mode 100644 index ef72b6d..0000000 --- a/tools/ffmpeg/懒人包说明.txt +++ /dev/null @@ -1,3 +0,0 @@ -将 Windows 版 ffmpeg 解压后,把 ffmpeg.exe 放在本文件夹(或 bin 子文件夹)中。 -正式安装包打包时,electron-builder 会把整个项目根目录(排除 electron 等)拷入安装目录, -此处文件会随软件分发给用户,用户无需单独安装 ffmpeg。