Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
372 lines
9.8 KiB
JavaScript
372 lines
9.8 KiB
JavaScript
const { spawnSync } = require('node:child_process')
|
||
const {
|
||
cpSync,
|
||
existsSync,
|
||
mkdirSync,
|
||
readFileSync,
|
||
readdirSync,
|
||
renameSync,
|
||
rmSync,
|
||
statSync,
|
||
writeFileSync
|
||
} = require('node:fs')
|
||
const { dirname, join, parse, resolve } = require('node:path')
|
||
|
||
const root = join(__dirname, '..')
|
||
const packageJson = JSON.parse(
|
||
readFileSync(join(root, 'package.json'), 'utf8')
|
||
)
|
||
const outputRoot = join(root, 'dist')
|
||
const stagingRoot = join(
|
||
outputRoot,
|
||
`.portable-stage-x64-${process.pid}`
|
||
)
|
||
const unpackedPath = join(stagingRoot, 'win-unpacked')
|
||
const portableName = 'GoodBuddy-windows-x64'
|
||
const portablePath = process.env.GOODBUDDY_OUT_DIR
|
||
? resolve(process.env.GOODBUDDY_OUT_DIR)
|
||
: join(outputRoot, portableName)
|
||
const markerName = '.goodbuddy-portable.json'
|
||
const portableLocales = new Set(['zh-CN.pak', 'en-US.pak'])
|
||
|
||
if (process.platform !== 'win32' || process.arch !== 'x64') {
|
||
throw new Error('Portable 目录当前必须在 Windows x64 上构建')
|
||
}
|
||
if (
|
||
portablePath === parse(portablePath).root ||
|
||
portablePath === root ||
|
||
portablePath === outputRoot
|
||
) {
|
||
throw new Error(
|
||
`拒绝使用不安全的输出目录:${portablePath}`
|
||
)
|
||
}
|
||
|
||
function assertOutputUnlocked(directory) {
|
||
if (!existsSync(directory)) {
|
||
return
|
||
}
|
||
const probe = `${directory}.build-lock-check-${process.pid}`
|
||
try {
|
||
renameSync(directory, probe)
|
||
renameSync(probe, directory)
|
||
} catch (error) {
|
||
if (!existsSync(directory) && existsSync(probe)) {
|
||
try {
|
||
renameSync(probe, directory)
|
||
} catch {
|
||
// The original lock error is more useful.
|
||
}
|
||
}
|
||
if (
|
||
error?.code === 'EPERM' ||
|
||
error?.code === 'EBUSY' ||
|
||
error?.code === 'EACCES'
|
||
) {
|
||
throw new Error(
|
||
`无法更新 ${directory},请先关闭其中正在运行的 GoodBuddy.exe`,
|
||
{ cause: error }
|
||
)
|
||
}
|
||
throw error
|
||
}
|
||
}
|
||
|
||
function ensureElectronRuntime() {
|
||
const electronRoot = join(root, 'node_modules', 'electron')
|
||
const electronDist = join(electronRoot, 'dist')
|
||
const executable = join(electronDist, 'electron.exe')
|
||
const versionFile = join(electronDist, 'version')
|
||
const installedPackagePath = join(electronRoot, 'package.json')
|
||
const lock = JSON.parse(
|
||
readFileSync(join(root, 'package-lock.json'), 'utf8')
|
||
)
|
||
const lockedVersion =
|
||
lock.packages?.['node_modules/electron']?.version
|
||
if (
|
||
!existsSync(installedPackagePath) ||
|
||
!existsSync(executable) ||
|
||
!existsSync(versionFile)
|
||
) {
|
||
throw new Error('缺少本地 Electron 运行时,请先运行 npm ci')
|
||
}
|
||
const installedVersion = JSON.parse(
|
||
readFileSync(installedPackagePath, 'utf8')
|
||
).version
|
||
const runtimeVersion = readFileSync(versionFile, 'utf8')
|
||
.trim()
|
||
.replace(/^v/u, '')
|
||
if (
|
||
!lockedVersion ||
|
||
installedVersion !== lockedVersion ||
|
||
runtimeVersion !== lockedVersion
|
||
) {
|
||
throw new Error(
|
||
`Electron 版本不一致(锁定 ${lockedVersion ?? '缺失'},依赖 ${installedVersion},运行时 ${runtimeVersion}),请运行 npm ci`
|
||
)
|
||
}
|
||
return electronDist
|
||
}
|
||
|
||
function copyDirectoryContents(source, destination) {
|
||
mkdirSync(destination, { recursive: true })
|
||
for (const entry of readdirSync(source, { withFileTypes: true })) {
|
||
cpSync(
|
||
join(source, entry.name),
|
||
join(destination, entry.name),
|
||
{ recursive: entry.isDirectory(), force: true }
|
||
)
|
||
}
|
||
}
|
||
|
||
function pruneLocales(directory) {
|
||
const localesPath = join(directory, 'locales')
|
||
if (!statSync(localesPath, { throwIfNoEntry: false })?.isDirectory()) {
|
||
throw new Error('Portable 目录缺少 Electron locales')
|
||
}
|
||
for (const name of readdirSync(localesPath)) {
|
||
if (!portableLocales.has(name)) {
|
||
rmSync(join(localesPath, name), { force: true })
|
||
}
|
||
}
|
||
for (const required of portableLocales) {
|
||
if (!statSync(join(localesPath, required), {
|
||
throwIfNoEntry: false
|
||
})?.isFile()) {
|
||
throw new Error(`Portable 目录缺少必要语言包:${required}`)
|
||
}
|
||
}
|
||
}
|
||
|
||
function portableRequiredPaths(directory) {
|
||
return [
|
||
join(directory, 'GoodBuddy.exe'),
|
||
join(directory, 'resources', 'app.asar'),
|
||
join(directory, 'resources', 'icon.ico'),
|
||
join(directory, 'resources', 'tray-icon.png'),
|
||
join(
|
||
directory,
|
||
'resources',
|
||
'runtimes',
|
||
'opencode',
|
||
'opencode.exe'
|
||
),
|
||
join(
|
||
directory,
|
||
'resources',
|
||
'runtimes',
|
||
'continue',
|
||
'package.json'
|
||
)
|
||
]
|
||
}
|
||
|
||
function assertPortableOutput(directory) {
|
||
const requiredPaths = portableRequiredPaths(directory)
|
||
const missing = requiredPaths.filter(
|
||
(filePath) => !statSync(filePath, { throwIfNoEntry: false })?.isFile()
|
||
)
|
||
if (missing.length > 0) {
|
||
throw new Error(
|
||
`Portable 目录缺少必要文件:${missing.join(', ')}`
|
||
)
|
||
}
|
||
}
|
||
|
||
function assertReplaceableOutput(directory) {
|
||
if (!existsSync(directory)) {
|
||
return
|
||
}
|
||
const entries = readdirSync(directory)
|
||
if (entries.every((entry) => entry === 'data')) {
|
||
return
|
||
}
|
||
try {
|
||
const marker = JSON.parse(
|
||
readFileSync(join(directory, markerName), 'utf8')
|
||
)
|
||
if (
|
||
marker.productName === 'GoodBuddy' &&
|
||
marker.formatVersion === 1
|
||
) {
|
||
return
|
||
}
|
||
} catch {
|
||
// Fall back to recognizing portable outputs created before markers.
|
||
}
|
||
if (
|
||
portableRequiredPaths(directory).every(
|
||
(filePath) =>
|
||
statSync(filePath, { throwIfNoEntry: false })?.isFile()
|
||
)
|
||
) {
|
||
return
|
||
}
|
||
throw new Error(
|
||
`拒绝覆盖未识别的目录:${directory}。请选择空目录或已有的 GoodBuddy portable 目录`
|
||
)
|
||
}
|
||
|
||
function writePortableMarker(directory) {
|
||
writeFileSync(
|
||
join(directory, markerName),
|
||
`${JSON.stringify({
|
||
formatVersion: 1,
|
||
productName: 'GoodBuddy',
|
||
version: packageJson.version
|
||
}, null, 2)}\n`,
|
||
'utf8'
|
||
)
|
||
}
|
||
|
||
function replacePortableOutput(source, destination) {
|
||
const replacement = `${destination}.replacement-${process.pid}`
|
||
const backup = `${destination}.previous-${process.pid}`
|
||
mkdirSync(dirname(destination), { recursive: true })
|
||
rmSync(replacement, { recursive: true, force: true })
|
||
rmSync(backup, { recursive: true, force: true })
|
||
copyDirectoryContents(source, replacement)
|
||
try {
|
||
writePortableMarker(replacement)
|
||
assertPortableOutput(replacement)
|
||
|
||
if (!existsSync(destination)) {
|
||
renameSync(replacement, destination)
|
||
return
|
||
}
|
||
|
||
assertReplaceableOutput(destination)
|
||
assertOutputUnlocked(destination)
|
||
renameSync(destination, backup)
|
||
try {
|
||
renameSync(replacement, destination)
|
||
const preservedData = join(backup, 'data')
|
||
if (existsSync(preservedData)) {
|
||
const destinationData = join(destination, 'data')
|
||
if (existsSync(destinationData)) {
|
||
throw new Error(
|
||
`新构建意外包含 data 目录,无法保留 ${preservedData}`
|
||
)
|
||
}
|
||
renameSync(preservedData, destinationData)
|
||
}
|
||
assertPortableOutput(destination)
|
||
} catch (error) {
|
||
try {
|
||
const movedData = join(destination, 'data')
|
||
const originalData = join(backup, 'data')
|
||
if (existsSync(movedData) && !existsSync(originalData)) {
|
||
renameSync(movedData, originalData)
|
||
}
|
||
if (existsSync(destination)) {
|
||
renameSync(destination, replacement)
|
||
}
|
||
if (existsSync(backup)) {
|
||
renameSync(backup, destination)
|
||
}
|
||
} catch (rollbackError) {
|
||
throw new AggregateError(
|
||
[error, rollbackError],
|
||
`更新失败且无法恢复原 portable 目录:${destination}`,
|
||
{ cause: rollbackError }
|
||
)
|
||
}
|
||
throw error
|
||
}
|
||
rmSync(backup, { recursive: true, force: true })
|
||
} finally {
|
||
rmSync(replacement, { recursive: true, force: true })
|
||
}
|
||
}
|
||
|
||
const electronDist = ensureElectronRuntime()
|
||
mkdirSync(outputRoot, { recursive: true })
|
||
rmSync(stagingRoot, { recursive: true, force: true })
|
||
|
||
for (const [label, script, args] of [
|
||
[
|
||
'Node 类型检查',
|
||
join(root, 'node_modules', 'typescript', 'bin', 'tsc'),
|
||
['--noEmit', '-p', 'tsconfig.node.json']
|
||
],
|
||
[
|
||
'Renderer 类型检查',
|
||
join(root, 'node_modules', 'typescript', 'bin', 'tsc'),
|
||
['--noEmit', '-p', 'tsconfig.web.json']
|
||
],
|
||
[
|
||
'Production bundle',
|
||
join(
|
||
root,
|
||
'node_modules',
|
||
'electron-vite',
|
||
'bin',
|
||
'electron-vite.js'
|
||
),
|
||
['build']
|
||
]
|
||
]) {
|
||
const buildResult = spawnSync(
|
||
process.execPath,
|
||
[script, ...args],
|
||
{
|
||
cwd: root,
|
||
env: process.env,
|
||
shell: false,
|
||
stdio: 'inherit',
|
||
windowsHide: true
|
||
}
|
||
)
|
||
if (buildResult.error) {
|
||
throw buildResult.error
|
||
}
|
||
if (buildResult.status !== 0) {
|
||
throw new Error(
|
||
`${label}失败(code ${buildResult.status ?? 1})`
|
||
)
|
||
}
|
||
}
|
||
|
||
const result = spawnSync(
|
||
process.execPath,
|
||
[
|
||
join(root, 'node_modules', 'electron-builder', 'cli.js'),
|
||
'--dir',
|
||
'--x64',
|
||
`--config.directories.output=${stagingRoot}`,
|
||
`--config.electronDist=${electronDist}`
|
||
],
|
||
{
|
||
cwd: root,
|
||
env: process.env,
|
||
shell: false,
|
||
stdio: 'inherit',
|
||
windowsHide: true
|
||
}
|
||
)
|
||
|
||
if (result.error) {
|
||
rmSync(stagingRoot, { recursive: true, force: true })
|
||
throw result.error
|
||
}
|
||
if (result.status !== 0) {
|
||
rmSync(stagingRoot, { recursive: true, force: true })
|
||
throw new Error(
|
||
`Electron Builder 构建失败(code ${result.status ?? 1})`
|
||
)
|
||
}
|
||
if (!statSync(unpackedPath, { throwIfNoEntry: false })?.isDirectory()) {
|
||
rmSync(stagingRoot, { recursive: true, force: true })
|
||
throw new Error('Electron Builder 未生成 portable 目录')
|
||
}
|
||
try {
|
||
pruneLocales(unpackedPath)
|
||
assertPortableOutput(unpackedPath)
|
||
replacePortableOutput(unpackedPath, portablePath)
|
||
} finally {
|
||
rmSync(stagingRoot, { recursive: true, force: true })
|
||
}
|
||
|
||
console.log(`Portable 目录构建完成:${portablePath}`)
|
||
console.log(`启动文件:${join(portablePath, 'GoodBuddy.exe')}`)
|