1.5.1版本,体验性修复、打包fpk、docker镜像、windows包
This commit is contained in:
1 parent
813407de30
commit
a6e45e0cb1
17 files changed
+3451
-15
No files matched your search
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user