This commit is contained in:
eric
2026-03-27 00:23:17 -05:00
parent 082bcc8805
commit aaf786b292
11 changed files with 564 additions and 56 deletions

5
.gitignore vendored
View File

@@ -189,3 +189,8 @@ electron/resources/chrome-cft/NOTICE-ChromeForTesting.txt
electron/node_modules/ electron/node_modules/
electron/dist/ electron/dist/
electron/out/ electron/out/
electron/.tmp-prepare-runtime/
# 嵌入式 Python / FFmpeg由 electron/scripts/prepare-runtime.mjs 生成,勿提交大文件)
/python/
/tools/ffmpeg/bin/

View File

@@ -10,7 +10,10 @@
"build": "electron-vite build", "build": "electron-vite build",
"preview": "electron-vite preview", "preview": "electron-vite preview",
"gen:icons": "node ./scripts/generate-icons.mjs", "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": { "dependencies": {
"d3": "^7.9.0", "d3": "^7.9.0",
@@ -55,7 +58,11 @@
"files": [ "files": [
"out/**/*", "out/**/*",
"package.json", "package.json",
"build/**/*" "build/**/*",
"node_modules/sql.js/**/*"
],
"asarUnpack": [
"**/node_modules/sql.js/**/*.wasm"
], ],
"extraResources": [ "extraResources": [
{ {

View File

@@ -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 siteLib\\site-packages 才会进 sys.pathpip 才能用)
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] 下载 FFmpegwin64…')
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)
}

View File

@@ -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)}`)

View File

@@ -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 已同步。')

View File

@@ -49,8 +49,49 @@ let _pythonExe: string | null = null
/** 自启动参数 --nav= 传入的页面,首屏或二次实例时下发渲染进程 */ /** 自启动参数 --nav= 传入的页面,首屏或二次实例时下发渲染进程 */
let pendingNav: string | null = null let pendingNav: string | null = null
let tray: Tray | null = null let tray: Tray | null = null
/** 本地服务 + 用户协议均完成后为 true渲染进程据此解除启动遮罩 */
let appUiReadyFlag = false
let isQuitting = false let isQuitting = false
/**
* 从 asar 内 offline-runtime 解压到可写 resources/backend仅当 extraResources 未带上完整运行时)。
* 正常安装包下 resources/backend 已有 python/ffmpeg此处立即返回无拷贝开销。
*/
async function ensureOfflineRuntimeAssetsAsync(backendRoot: string): Promise<void> {
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 { function resolveAppIconPath(): string | null {
// 构建后 app.asar 内也会包含 build/icon.ico见 package.json build.files // 构建后 app.asar 内也会包含 build/icon.ico见 package.json build.files
const devCandidate = path.resolve(__dirname, '../../build/icon.ico') 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 }> { async function restartWeb2Backend(): Promise<{ ok: true } | { ok: false; error: string }> {
const br = _backendRoot ?? getBackendRoot() const br = _backendRoot ?? getBackendRoot()
await ensureOfflineRuntimeAssetsAsync(br)
const py = _pythonExe ?? resolvePythonExe(br) const py = _pythonExe ?? resolvePythonExe(br)
web2Host = resolveWeb2Host() web2Host = resolveWeb2Host()
await killPythonProcessForRestart() await killPythonProcessForRestart()
@@ -218,8 +260,14 @@ function resolvePythonExe(backendRoot: string): string {
if (process.env.PYTHON_EXE && fs.existsSync(process.env.PYTHON_EXE)) { if (process.env.PYTHON_EXE && fs.existsSync(process.env.PYTHON_EXE)) {
return process.env.PYTHON_EXE return process.env.PYTHON_EXE
} }
const embedded = path.join(backendRoot, 'python', 'python.exe') const bundledCandidates = [
if (fs.existsSync(embedded)) return embedded 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' return process.platform === 'win32' ? 'python' : 'python3'
} }
@@ -238,6 +286,8 @@ function buildChildEnv(backendRoot: string): NodeJS.ProcessEnv {
const prepend: string[] = [] const prepend: string[] = []
const ffmpegDirs = [ const ffmpegDirs = [
path.join(backendRoot, 'ffmpeg'),
path.join(backendRoot, 'ffmpeg', 'bin'),
path.join(backendRoot, 'tools', 'ffmpeg'), path.join(backendRoot, 'tools', 'ffmpeg'),
path.join(backendRoot, 'tools', 'ffmpeg', 'bin'), path.join(backendRoot, 'tools', 'ffmpeg', 'bin'),
] ]
@@ -250,7 +300,11 @@ function buildChildEnv(backendRoot: string): NodeJS.ProcessEnv {
prepend.push(portablePython) prepend.push(portablePython)
const scripts = path.join(portablePython, 'Scripts') const scripts = path.join(portablePython, 'Scripts')
if (fs.existsSync(scripts)) prepend.push(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) { if (prepend.length && env.PATH) {
@@ -327,7 +381,7 @@ function waitForHealth(timeoutMs = 120000): Promise<void> {
}) })
req.on('error', () => req.destroy()) req.on('error', () => req.destroy())
} }
const iv = setInterval(tick, 400) const iv = setInterval(tick, 120)
tick() tick()
}) })
} }
@@ -419,6 +473,9 @@ async function createWindow(): Promise<void> {
mainWindow.once('ready-to-show', () => mainWindow?.show()) mainWindow.once('ready-to-show', () => mainWindow?.show())
mainWindow.webContents.once('did-finish-load', () => { mainWindow.webContents.once('did-finish-load', () => {
appendClientLog('info', 'main', '渲染界面已加载') appendClientLog('info', 'main', '渲染界面已加载')
if (appUiReadyFlag) {
mainWindow?.webContents.send('app:ready')
}
if (pendingNav && mainWindow && !mainWindow.isDestroyed()) { if (pendingNav && mainWindow && !mainWindow.isDestroyed()) {
sendNavToWindow(mainWindow, pendingNav) sendNavToWindow(mainWindow, pendingNav)
pendingNav = null pendingNav = null
@@ -484,11 +541,35 @@ async function createWindow(): Promise<void> {
const gotLock = app.requestSingleInstanceLock() const gotLock = app.requestSingleInstanceLock()
if (!gotLock) { if (!gotLock) {
app.quit() void app.whenReady().then(() => {
dialog.showMessageBoxSync({
type: 'info',
title: '无人直播助手',
message:
'程序已在运行,请从任务栏或系统托盘打开窗口。\n若看不到窗口请点击托盘中的「无人直播助手」图标。',
})
app.quit()
})
} else { } else {
setAppUserModelIdEarly() setAppUserModelIdEarly()
pendingNav = parseNavFromArgv(process.argv) 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) => { app.on('second-instance', (_evt, commandLine) => {
const argv = Array.isArray(commandLine) const argv = Array.isArray(commandLine)
? commandLine ? commandLine
@@ -507,17 +588,19 @@ if (!gotLock) {
app.whenReady().then(async () => { app.whenReady().then(async () => {
applyAppHostBootstrap(getBackendRoot) applyAppHostBootstrap(getBackendRoot)
registerAppHostIpcHandlers(getBackendRoot) registerAppHostIpcHandlers(getBackendRoot)
try { /** 不阻塞首屏窗口须先出现sql.js 与 DB 在后台就绪 */
await initClientDb() void initClientDb()
try { .then(() => {
syncRelayChannelsWithBackendConfig(path.join(getBackendRoot(), 'config')) try {
} catch (e) { syncRelayChannelsWithBackendConfig(path.join(getBackendRoot(), 'config'))
console.error('[relay sync]', e) } catch (e) {
} console.error('[relay sync]', e)
appendClientLog('info', 'main', '客户端主进程就绪') }
} catch (e) { appendClientLog('info', 'main', '客户端主进程就绪')
console.error('[clientDb]', e) })
} .catch((e) => {
console.error('[clientDb]', e)
})
ipcMain.removeHandler('workspace:log-live') ipcMain.removeHandler('workspace:log-live')
ipcMain.handle('workspace:log-live', (_evt, payload: unknown) => { ipcMain.handle('workspace:log-live', (_evt, payload: unknown) => {
@@ -666,9 +749,19 @@ if (!gotLock) {
return r return r
}) })
ipcMain.removeHandler('app:is-ready')
ipcMain.handle('app:is-ready', () => appUiReadyFlag)
web2Host = resolveWeb2Host() web2Host = resolveWeb2Host()
const backendRoot = getBackendRoot() const backendRoot = getBackendRoot()
/** 与 createWindow 并行:解压运行时与加载界面同时进行,缩短到「可交互」的总耗时 */
const runtimePrep = ensureOfflineRuntimeAssetsAsync(backendRoot)
await Promise.all([createWindow(), runtimePrep])
appendClientLog('info', 'main', '主窗口与离线运行时已就绪,正在启动本地服务')
const pythonExe = resolvePythonExe(backendRoot) const pythonExe = resolvePythonExe(backendRoot)
_backendRoot = backendRoot _backendRoot = backendRoot
_pythonExe = pythonExe _pythonExe = pythonExe
@@ -681,30 +774,28 @@ if (!gotLock) {
app.quit() app.quit()
return return
} }
startPythonBackend(backendRoot, pythonExe) startPythonBackend(backendRoot, pythonExe)
try { appendClientLog('info', 'main', '后台并行启动本地服务与用户协议')
await waitForHealth()
} catch (e) {
dialog.showErrorBox(
'无人直播助手',
`软件未能正常启动。\n\n${e instanceof Error ? e.message : String(e)}`,
)
killPythonTree()
app.quit()
return
}
appendClientLog('info', 'main', '本地服务已连接,准备打开界面') void (async () => {
try {
try { await Promise.all([waitForHealth(), ensureUserAgreementAccepted(resolvePreload)])
await ensureUserAgreementAccepted(resolvePreload) appUiReadyFlag = true
} catch { if (mainWindow && !mainWindow.isDestroyed()) {
return mainWindow.webContents.send('app:ready')
} }
appendClientLog('info', 'main', '本地服务已就绪,界面可交互')
await createWindow() } 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', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) void createWindow() if (BrowserWindow.getAllWindows().length === 0) void createWindow()

View File

@@ -42,6 +42,15 @@ contextBridge.exposeInMainWorld('userAgreement', {
refuse: () => ipcRenderer.send('user-agreement:refuse'), refuse: () => ipcRenderer.send('user-agreement:refuse'),
}) })
contextBridge.exposeInMainWorld('appBootstrap', {
isReady: (): Promise<boolean> => 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', { contextBridge.exposeInMainWorld('relayConfig', {
syncConfig: () => ipcRenderer.invoke('relay:sync-config'), syncConfig: () => ipcRenderer.invoke('relay:sync-config'),
importFromConfig: () => ipcRenderer.invoke('relay:import-from-config'), importFromConfig: () => ipcRenderer.invoke('relay:import-from-config'),

View File

@@ -1007,6 +1007,58 @@ body.ready {
word-break: break-word; 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 { .app-main {
display: flex; display: flex;

View File

@@ -372,6 +372,8 @@ export default function App() {
variant: 'loading', variant: 'loading',
}) })
const [bootError, setBootError] = useState<string | null>(null) const [bootError, setBootError] = useState<string | null>(null)
/** Electron主进程已显示窗口但本地服务/协议未完成时为 false避免误请求 API */
const [shellReady, setShellReady] = useState(() => typeof window === 'undefined' || !window.appBootstrap)
const [emptyHint, setEmptyHint] = useState<string | null>(null) const [emptyHint, setEmptyHint] = useState<string | null>(null)
const [ytIniModel, setYtIniModel] = useState<YoutubeIniModel>({ const [ytIniModel, setYtIniModel] = useState<YoutubeIniModel>({
@@ -431,6 +433,25 @@ export default function App() {
} }
const [proRelayStatus, setProRelayStatus] = useState<ProRelayStatusPayload | null>(null) const [proRelayStatus, setProRelayStatus] = useState<ProRelayStatusPayload | null>(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(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', theme) document.documentElement.setAttribute('data-theme', theme)
try { try {
@@ -511,6 +532,7 @@ export default function App() {
const isObs = ent ? isObsScript(script) : false const isObs = ent ? isObsScript(script) : false
useEffect(() => { useEffect(() => {
if (!shellReady) return
let cancelled = false let cancelled = false
void apiFetch(apiUrl('/host/gpu_status')) void apiFetch(apiUrl('/host/gpu_status'))
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))) .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
@@ -527,7 +549,7 @@ export default function App() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, []) }, [shellReady])
useEffect(() => { useEffect(() => {
if (!isYoutube || multiChannelPro) return if (!isYoutube || multiChannelPro) return
@@ -967,6 +989,7 @@ export default function App() {
}, [nav]) }, [nav])
useEffect(() => { useEffect(() => {
if (!shellReady) return
let cancelled = false let cancelled = false
;(async () => { ;(async () => {
try { try {
@@ -1003,7 +1026,7 @@ export default function App() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [loadProcessMonitor]) }, [loadProcessMonitor, shellReady])
useEffect(() => { useEffect(() => {
if (!entries.length || !currentProcess) return if (!entries.length || !currentProcess) return
@@ -1060,9 +1083,9 @@ export default function App() {
}, [isYoutube, multiChannelPro]) }, [isYoutube, multiChannelPro])
useEffect(() => { useEffect(() => {
if (!isYoutube || !multiChannelPro) return if (!shellReady || !isYoutube || !multiChannelPro) return
void reloadProChannelsList() void reloadProChannelsList()
}, [isYoutube, multiChannelPro, reloadProChannelsList]) }, [shellReady, isYoutube, multiChannelPro, reloadProChannelsList])
const submitNewChannelDraft = useCallback(async () => { const submitNewChannelDraft = useCallback(async () => {
if (!newChannelDraft) return if (!newChannelDraft) return
@@ -1242,7 +1265,7 @@ export default function App() {
) )
useEffect(() => { useEffect(() => {
if (!isYoutube || !multiChannelPro) return if (!shellReady || !isYoutube || !multiChannelPro) return
let cancelled = false let cancelled = false
const poll = async () => { const poll = async () => {
try { try {
@@ -1260,7 +1283,7 @@ export default function App() {
cancelled = true cancelled = true
clearInterval(id) clearInterval(id)
} }
}, [isYoutube, multiChannelPro]) }, [shellReady, isYoutube, multiChannelPro])
const channelIdsSig = useMemo( const channelIdsSig = useMemo(
() => proChannelsList.map((c) => c.id).join('\0'), () => proChannelsList.map((c) => c.id).join('\0'),
@@ -1268,7 +1291,7 @@ export default function App() {
) )
useEffect(() => { useEffect(() => {
if (!isYoutube || !multiChannelPro || !proChannelsList.length) { if (!shellReady || !isYoutube || !multiChannelPro || !proChannelsList.length) {
if (!multiChannelPro) setProChannelEditors({}) if (!multiChannelPro) setProChannelEditors({})
return return
} }
@@ -1311,11 +1334,11 @@ export default function App() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [isYoutube, multiChannelPro, channelIdsSig, proChannelsList]) }, [shellReady, isYoutube, multiChannelPro, channelIdsSig, proChannelsList])
/** 唯一从服务端填充「直播地址列表」的路径(与全量密钥同步解耦) */ /** 唯一从服务端填充「直播地址列表」的路径(与全量密钥同步解耦) */
useEffect(() => { useEffect(() => {
if (!isYoutube || !multiChannelPro || !highlightedChannelId) return if (!shellReady || !isYoutube || !multiChannelPro || !highlightedChannelId) return
const cid = highlightedChannelId const cid = highlightedChannelId
let cancelled = false let cancelled = false
void (async () => { void (async () => {
@@ -1342,14 +1365,14 @@ export default function App() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [highlightedChannelId, isYoutube, multiChannelPro]) }, [shellReady, highlightedChannelId, isYoutube, multiChannelPro])
useEffect(() => { useEffect(() => {
if (!UI_SHOW_YOUTUBE_GOOGLE_OAUTH || !isYoutube) return if (!shellReady || !UI_SHOW_YOUTUBE_GOOGLE_OAUTH || !isYoutube) return
void loadYtOauthStatus() void loadYtOauthStatus()
const id = window.setInterval(() => void loadYtOauthStatus(), 5000) const id = window.setInterval(() => void loadYtOauthStatus(), 5000)
return () => clearInterval(id) return () => clearInterval(id)
}, [isYoutube, loadYtOauthStatus]) }, [shellReady, isYoutube, loadYtOauthStatus])
const douyinHints = useMemo(() => { const douyinHints = useMemo(() => {
if (!isYoutube && !isTiktok) return [] if (!isYoutube && !isTiktok) return []
@@ -1368,7 +1391,7 @@ export default function App() {
}, [isYoutube, multiChannelPro, highlightedChannelId, proChannelEditors, urlText]) }, [isYoutube, multiChannelPro, highlightedChannelId, proChannelEditors, urlText])
useEffect(() => { useEffect(() => {
if (!isYoutube && !isTiktok) return if (!shellReady || (!isYoutube && !isTiktok)) return
const urls = extractDouyinLiveUrls(previewUrlText) const urls = extractDouyinLiveUrls(previewUrlText)
if (!urls.length) { if (!urls.length) {
setDouyinPreviews([]) setDouyinPreviews([])
@@ -1387,7 +1410,7 @@ export default function App() {
.catch(() => setDouyinPreviews([])) .catch(() => setDouyinPreviews([]))
}, 450) }, 450)
return () => clearTimeout(t) return () => clearTimeout(t)
}, [previewUrlText, isYoutube, isTiktok]) }, [shellReady, previewUrlText, isYoutube, isTiktok])
const openExternalUrl = useCallback((href: string) => { const openExternalUrl = useCallback((href: string) => {
const open = window.recorderConsole?.openExternal const open = window.recorderConsole?.openExternal
@@ -2303,6 +2326,18 @@ export default function App() {
</div> </div>
</div> </div>
{!shellReady && (
<div className="app-startup-overlay" role="status" aria-live="polite">
<div className="app-startup-overlay__panel">
<div className="app-startup-overlay__spinner" aria-hidden />
<p className="app-startup-overlay__title"></p>
<p className="app-startup-overlay__hint">
</p>
</div>
</div>
)}
<PbAuthModal <PbAuthModal
open={pbAuthModalOpen} open={pbAuthModalOpen}
onClose={handlePbAuthModalClose} onClose={handlePbAuthModalClose}

View File

@@ -53,6 +53,10 @@ interface Window {
accept: () => void accept: () => void
refuse: () => void refuse: () => void
} }
appBootstrap?: {
isReady: () => Promise<boolean>
onReady: (cb: () => void) => () => void
}
relayConfig?: { relayConfig?: {
syncConfig?: () => Promise<{ ok: true }> syncConfig?: () => Promise<{ ok: true }>
importFromConfig?: () => Promise<{ ok: true }> importFromConfig?: () => Promise<{ ok: true }>

View File

@@ -1,3 +0,0 @@
将 Windows 版 ffmpeg 解压后,把 ffmpeg.exe 放在本文件夹(或 bin 子文件夹)中。
正式安装包打包时electron-builder 会把整个项目根目录(排除 electron 等)拷入安装目录,
此处文件会随软件分发给用户,用户无需单独安装 ffmpeg。