's'
This commit is contained in:
@@ -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": [
|
||||
{
|
||||
|
||||
173
electron/scripts/prepare-runtime.mjs
Normal file
173
electron/scripts/prepare-runtime.mjs
Normal 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 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)
|
||||
}
|
||||
47
electron/scripts/stage-offline-runtime.mjs
Normal file
47
electron/scripts/stage-offline-runtime.mjs
Normal 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)}`)
|
||||
|
||||
88
electron/scripts/verify-runtime.mjs
Normal file
88
electron/scripts/verify-runtime.mjs
Normal 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 已同步。')
|
||||
|
||||
@@ -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<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 {
|
||||
// 构建后 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<void> {
|
||||
})
|
||||
req.on('error', () => req.destroy())
|
||||
}
|
||||
const iv = setInterval(tick, 400)
|
||||
const iv = setInterval(tick, 120)
|
||||
tick()
|
||||
})
|
||||
}
|
||||
@@ -419,6 +473,9 @@ async function createWindow(): Promise<void> {
|
||||
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<void> {
|
||||
|
||||
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()
|
||||
|
||||
@@ -42,6 +42,15 @@ contextBridge.exposeInMainWorld('userAgreement', {
|
||||
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', {
|
||||
syncConfig: () => ipcRenderer.invoke('relay:sync-config'),
|
||||
importFromConfig: () => ipcRenderer.invoke('relay:import-from-config'),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -372,6 +372,8 @@ export default function App() {
|
||||
variant: 'loading',
|
||||
})
|
||||
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 [ytIniModel, setYtIniModel] = useState<YoutubeIniModel>({
|
||||
@@ -431,6 +433,25 @@ export default function App() {
|
||||
}
|
||||
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(() => {
|
||||
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() {
|
||||
</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
|
||||
open={pbAuthModalOpen}
|
||||
onClose={handlePbAuthModalClose}
|
||||
|
||||
4
electron/src/renderer/src/vite-env.d.ts
vendored
4
electron/src/renderer/src/vite-env.d.ts
vendored
@@ -53,6 +53,10 @@ interface Window {
|
||||
accept: () => void
|
||||
refuse: () => void
|
||||
}
|
||||
appBootstrap?: {
|
||||
isReady: () => Promise<boolean>
|
||||
onReady: (cb: () => void) => () => void
|
||||
}
|
||||
relayConfig?: {
|
||||
syncConfig?: () => Promise<{ ok: true }>
|
||||
importFromConfig?: () => Promise<{ ok: true }>
|
||||
|
||||
Reference in New Issue
Block a user