feat: unify workspace design and portable packaging
Establish shared scoped UI primitives and make repeatable Windows builds preserve existing user data safely. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
e8dc4d03fd
commit
09e9fbf5e2
+244
-9
@@ -1,32 +1,263 @@
|
||||
const { spawnSync } = require('node:child_process')
|
||||
const {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync
|
||||
statSync,
|
||||
writeFileSync
|
||||
} = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
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')
|
||||
const stagingRoot = join(
|
||||
outputRoot,
|
||||
`.portable-stage-x64-${process.pid}`
|
||||
)
|
||||
const unpackedPath = join(stagingRoot, 'win-unpacked')
|
||||
const portableName = `GoodBuddy-${packageJson.version}-win-x64-portable`
|
||||
const portablePath = join(outputRoot, portableName)
|
||||
const portablePath = process.env.GOODBUDDY_OUT_DIR
|
||||
? resolve(process.env.GOODBUDDY_OUT_DIR)
|
||||
: join(outputRoot, portableName)
|
||||
const markerName = '.goodbuddy-portable.json'
|
||||
|
||||
if (process.platform !== 'win32' || process.arch !== 'x64') {
|
||||
throw new Error('Portable 目录当前必须在 Windows x64 上构建')
|
||||
}
|
||||
if (statSync(portablePath, { throwIfNoEntry: false })) {
|
||||
if (
|
||||
portablePath === parse(portablePath).root ||
|
||||
portablePath === root ||
|
||||
portablePath === outputRoot
|
||||
) {
|
||||
throw new Error(
|
||||
`输出目录已存在,请先移动或删除:${portablePath}`
|
||||
`拒绝使用不安全的输出目录:${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 portableRequiredPaths(directory) {
|
||||
return [
|
||||
join(directory, 'GoodBuddy.exe'),
|
||||
join(directory, 'resources', 'app.asar'),
|
||||
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 })
|
||||
|
||||
@@ -37,7 +268,7 @@ const result = spawnSync(
|
||||
'--dir',
|
||||
'--x64',
|
||||
`--config.directories.output=${stagingRoot}`,
|
||||
'--config.electronDist=node_modules/electron/dist'
|
||||
`--config.electronDist=${electronDist}`
|
||||
],
|
||||
{
|
||||
cwd: root,
|
||||
@@ -62,8 +293,12 @@ if (!statSync(unpackedPath, { throwIfNoEntry: false })?.isDirectory()) {
|
||||
rmSync(stagingRoot, { recursive: true, force: true })
|
||||
throw new Error('Electron Builder 未生成 portable 目录')
|
||||
}
|
||||
renameSync(unpackedPath, portablePath)
|
||||
rmSync(stagingRoot, { recursive: true, force: true })
|
||||
try {
|
||||
assertPortableOutput(unpackedPath)
|
||||
replacePortableOutput(unpackedPath, portablePath)
|
||||
} finally {
|
||||
rmSync(stagingRoot, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
console.log(`Portable 目录构建完成:${portablePath}`)
|
||||
console.log(`启动文件:${join(portablePath, 'GoodBuddy.exe')}`)
|
||||
|
||||
Reference in New Issue
Block a user