1.5.1版本,体验性修复、打包fpk、docker镜像、windows包

This commit is contained in:
Yeomai committed 2026-10-18 15:32:56 +08:00
1 parent 813407de30
commit a6e45e0cb1
17 files changed
+3451 -15

No files matched your search

+177
View File
@@ -0,0 +1,177 @@
const { app, BrowserWindow, dialog, Menu, shell } = require('electron')
const { spawn } = require('node:child_process')
const fs = require('node:fs')
const net = require('node:net')
const path = require('node:path')
let serverProcess = null
let mainWindow = null
let serverUrl = ''
if (!app.requestSingleInstanceLock()) {
app.quit()
process.exit(0)
}
app.setAppUserModelId('com.yeomai.pichost')
app.setName('PicHost')
function runtimeRoot() {
return app.isPackaged
? path.join(process.resourcesPath, 'runtime')
: path.join(__dirname, '..', 'dist-windows', 'staging', 'runtime')
}
function dataDirectory() {
const base = process.env.LOCALAPPDATA || app.getPath('userData')
return path.join(base, 'PicHost', 'data')
}
function findFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer()
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
const { port } = server.address()
server.close(error => error ? reject(error) : resolve(port))
})
})
}
function waitForHealth(url, process) {
return new Promise((resolve, reject) => {
const startedAt = Date.now()
const timer = setInterval(async () => {
if (process.exitCode !== null) {
clearInterval(timer)
reject(new Error(`PicHost 服务已退出,代码 ${process.exitCode}`))
return
}
try {
const response = await fetch(`${url}/api/health`, {
signal: AbortSignal.timeout(1000)
})
if (response.ok) {
clearInterval(timer)
resolve()
return
}
} catch {
// 忽略轮询中的瞬时错误,继续等待
}
if (Date.now() - startedAt > 30000) {
clearInterval(timer)
reject(new Error('PicHost 服务启动超时'))
}
}, 500)
})
}
function stopServer() {
if (!serverProcess || serverProcess.exitCode !== null) return
spawn('taskkill', ['/pid', String(serverProcess.pid), '/T', '/F'], {
windowsHide: true,
stdio: 'ignore'
})
serverProcess = null
}
async function startServer() {
const root = runtimeRoot()
const appRoot = path.join(root, 'app')
const nodeExe = path.join(root, 'node', 'node.exe')
const ffmpegDir = path.join(root, 'ffmpeg')
const dataDir = dataDirectory()
const port = await findFreePort()
fs.mkdirSync(dataDir, { recursive: true })
const log = fs.createWriteStream(path.join(dataDir, 'desktop-server.log'), {
flags: 'a'
})
serverUrl = `http://127.0.0.1:${port}`
serverProcess = spawn(nodeExe, [path.join(appRoot, '.output', 'server', 'index.mjs')], {
cwd: appRoot,
env: {
...process.env,
DATA_DIR: dataDir,
NODE_ENV: 'production',
NITRO_PORT: String(port),
PORT: String(port),
NITRO_HOST: '127.0.0.1',
HOST: '127.0.0.1',
PATH: `${ffmpegDir};${process.env.PATH || ''}`,
PIC_HOST_DESKTOP: '1'
},
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe']
})
serverProcess.stdout.pipe(log, { end: false })
serverProcess.stderr.pipe(log, { end: false })
serverProcess.once('close', () => log.end())
await waitForHealth(serverUrl, serverProcess)
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1440,
height: 920,
minWidth: 980,
minHeight: 680,
show: false,
autoHideMenuBar: true,
backgroundColor: '#111827',
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true
}
})
mainWindow.once('ready-to-show', () => mainWindow.show())
mainWindow.on('closed', () => {
mainWindow = null
app.quit()
})
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith(serverUrl)) return { action: 'allow' }
shell.openExternal(url)
return { action: 'deny' }
})
mainWindow.webContents.on('will-navigate', (event, url) => {
if (!url.startsWith(serverUrl)) {
event.preventDefault()
shell.openExternal(url)
}
})
mainWindow.loadURL(serverUrl)
}
app.on('second-instance', () => {
if (!mainWindow) return
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
})
app.on('before-quit', stopServer)
app.on('window-all-closed', () => app.quit())
app.whenReady().then(async () => {
Menu.setApplicationMenu(null)
try {
await startServer()
createWindow()
} catch (error) {
dialog.showErrorBox('PicHost 启动失败', String(error?.message || error))
stopServer()
app.quit()
}
})
+166
View File
@@ -0,0 +1,166 @@
import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process'
import {
access,
copyFile,
cp,
mkdir,
readFile,
rm,
writeFile
} from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import sharp from 'sharp'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const outputRoot = path.join(root, 'dist-windows')
const stageRoot = path.join(outputRoot, 'staging')
const runtimeRoot = path.join(stageRoot, 'runtime')
const appRoot = path.join(runtimeRoot, 'app')
const nodeDir = path.join(runtimeRoot, 'node')
const ffmpegDir = path.join(runtimeRoot, 'ffmpeg')
const buildDir = path.join(stageRoot, 'build')
const cacheDir = path.join(outputRoot, 'cache')
const nodeVersion = '22.23.3'
function run(command, args, cwd = root) {
const result = spawnSync(command, args, {
cwd,
encoding: 'utf8',
stdio: 'inherit',
windowsHide: true,
shell: process.platform === 'win32' && /\.(?:cmd|bat)$/i.test(command)
})
if (result.error) throw result.error
if (result.status !== 0) {
throw new Error(`${command} ${args.join(' ')} 失败,退出代码 ${result.status}`)
}
}
async function exists(file) {
try {
await access(file)
return true
} catch {
return false
}
}
async function download(file, url) {
if (await exists(file)) return
await mkdir(path.dirname(file), { recursive: true })
console.log(`下载 ${url}`)
const response = await fetch(url)
if (!response.ok) throw new Error(`下载失败:${response.status} ${url}`)
await writeFile(file, Buffer.from(await response.arrayBuffer()))
}
async function prepareNode() {
const cacheFile = path.join(cacheDir, `node-v${nodeVersion}-win-x64.exe`)
const nodeExe = path.join(nodeDir, 'node.exe')
await download(
cacheFile,
`https://nodejs.org/dist/v${nodeVersion}/win-x64/node.exe`
)
const sums = await (await fetch(
`https://nodejs.org/dist/v${nodeVersion}/SHASUMS256.txt`
)).text()
const expected = sums
.split(/\r?\n/)
.map(line => line.trim().split(/\s+/))
.find(parts => parts[1] === 'win-x64/node.exe')?.[0]
const actual = createHash('sha256')
.update(await readFile(cacheFile))
.digest('hex')
if (!expected || actual !== expected) {
throw new Error('内置 Node.js 校验失败')
}
await mkdir(nodeDir, { recursive: true })
await copyFile(cacheFile, nodeExe)
run(nodeExe, ['--version'])
}
async function findFfmpegDir() {
const candidates = [
process.env.FFMPEG_DIR,
'C:\\ffmpeg-8.0.1-essentials_build\\bin'
].filter(Boolean)
for (const candidate of candidates) {
if (
await exists(path.join(candidate, 'ffmpeg.exe'))
&& await exists(path.join(candidate, 'ffprobe.exe'))
) {
return candidate
}
}
const located = spawnSync('where.exe', ['ffmpeg.exe'], { encoding: 'utf8' })
const first = located.stdout?.trim().split(/\r?\n/)[0]
if (first && await exists(path.join(path.dirname(first), 'ffprobe.exe'))) {
return path.dirname(first)
}
throw new Error('未找到 ffmpeg.exe 和 ffprobe.exe,请设置 FFMPEG_DIR')
}
async function main() {
run('npm.cmd', ['run', 'build'])
await rm(stageRoot, { recursive: true, force: true })
await Promise.all([
mkdir(appRoot, { recursive: true }),
mkdir(buildDir, { recursive: true })
])
await cp(path.join(root, '.output'), path.join(appRoot, '.output'), {
recursive: true,
dereference: true
})
await copyFile(path.join(root, 'package.json'), path.join(appRoot, 'package.json'))
await copyFile(
path.join(root, 'package-lock.json'),
path.join(appRoot, 'package-lock.json')
)
await cp(path.join(root, 'server', 'cli'), path.join(appRoot, 'server', 'cli'), {
recursive: true
})
console.log('安装 Windows 运行时生产依赖')
run(
'npm.cmd',
['ci', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund'],
appRoot
)
await prepareNode()
const sourceFfmpeg = await findFfmpegDir()
await mkdir(ffmpegDir, { recursive: true })
await copyFile(
path.join(sourceFfmpeg, 'ffmpeg.exe'),
path.join(ffmpegDir, 'ffmpeg.exe')
)
await copyFile(
path.join(sourceFfmpeg, 'ffprobe.exe'),
path.join(ffmpegDir, 'ffprobe.exe')
)
run(path.join(ffmpegDir, 'ffmpeg.exe'), ['-version'])
await sharp(path.join(root, 'app', 'assets', 'image', 'logo-light.png'))
.resize(512, 512, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.png()
.toFile(path.join(buildDir, 'icon.png'))
console.log(`Windows 运行时已准备到 ${runtimeRoot}`)
}
main().catch((error) => {
console.error(error)
process.exit(1)
})