Files
SpineParticlesWeb/scripts/build-connector-sea.mjs
T
2026-09-05 16:20:23 +08:00

153 lines
6.7 KiB
JavaScript

import { execFile } from 'node:child_process'
import { access, chmod, cp, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { constants } from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { build } from 'esbuild'
const run = promisify(execFile)
const scriptDir = dirname(fileURLToPath(import.meta.url))
const projectDir = resolve(scriptDir, '..')
const releaseRoot = join(projectDir, 'release', 'connector')
const platformName = process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : process.platform
const packageDir = join(releaseRoot, `${platformName}-${process.arch}`)
const executableName = process.platform === 'win32' ? 'SpineParticleConnector.exe' : 'SpineParticleConnector'
const executablePath = join(packageDir, executableName)
const launcherSource = join(projectDir, 'connector', 'launchers', platformName)
const nodeTarget = `node${process.versions.node.split('.')[0]}`
function seaUnavailable(error) {
return /single executable application is disabled/i.test(String(error?.stderr || '') + String(error?.stdout || '') + String(error))
}
async function downloadOfficialSeaNode(stagingDir) {
const version = process.versions.node
const architecture = process.arch === 'arm64' ? 'arm64' : process.arch === 'x64' ? 'x64' : null
if (!architecture || !['darwin', 'win32'].includes(process.platform)) {
throw new Error('当前系统的 Node 不支持 SEA。请安装 Node 官方发行版,或通过 SEA_NODE_BINARY 指定可用的 Node 可执行文件。')
}
const osName = process.platform === 'darwin' ? 'darwin' : 'win'
const extension = process.platform === 'darwin' ? 'tar.gz' : 'zip'
const archiveName = `node-v${version}-${osName}-${architecture}.${extension}`
const archivePath = join(stagingDir, archiveName)
const url = `https://nodejs.org/dist/v${version}/${archiveName}`
console.log(`当前 Node 禁用了 SEA,正在临时使用 Node 官方构建器:${url}`)
const response = await fetch(url, { signal: AbortSignal.timeout(120_000) })
if (!response.ok) throw new Error(`无法下载 Node 官方构建器(${response.status}):${url}`)
await writeFile(archivePath, Buffer.from(await response.arrayBuffer()))
if (process.platform === 'darwin') await run('tar', ['-xzf', archivePath, '-C', stagingDir])
else await run('powershell.exe', ['-NoProfile', '-Command', `Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' -DestinationPath '${stagingDir.replace(/'/g, "''")}' -Force`])
const binary = process.platform === 'darwin'
? join(stagingDir, `node-v${version}-${osName}-${architecture}`, 'bin', 'node')
: join(stagingDir, `node-v${version}-${osName}-${architecture}`, 'node.exe')
await access(binary, constants.X_OK)
return binary
}
async function buildSea(configPath, stagingDir) {
const configuredBinary = process.env.SEA_NODE_BINARY
const candidates = configuredBinary ? [configuredBinary, process.execPath] : [process.execPath]
let lastError
for (const binary of candidates) {
try {
await run(binary, ['--build-sea', configPath])
return
} catch (error) {
lastError = error
if (!seaUnavailable(error)) throw error
}
}
const officialBinary = await downloadOfficialSeaNode(stagingDir)
try {
await run(officialBinary, ['--build-sea', configPath])
} catch (error) {
throw new Error(`Node 官方构建器无法生成 SEA:${error instanceof Error ? error.message : error}`, { cause: lastError })
}
}
async function createPortableArchive() {
const archivePath = join(releaseRoot, `SpineParticleConnector-${platformName}-${process.arch}.zip`)
await rm(archivePath, { force: true })
if (process.platform === 'darwin') {
await run('zip', ['-q', '-r', archivePath, basename(packageDir)], { cwd: releaseRoot })
} else if (process.platform === 'win32') {
const escapedSource = packageDir.replace(/'/g, "''")
const escapedTarget = archivePath.replace(/'/g, "''")
await run('powershell.exe', ['-NoProfile', '-Command', `Compress-Archive -LiteralPath '${escapedSource}' -DestinationPath '${escapedTarget}' -Force`])
} else {
return null
}
return archivePath
}
async function main() {
const stagingDir = await mkdtemp(join(tmpdir(), 'spine-particle-sea-'))
try {
const bundlePath = join(stagingDir, 'spine-connector.cjs')
const configPath = join(stagingDir, 'sea-config.json')
await build({
entryPoints: [join(projectDir, 'connector', 'spine-connector.mjs')],
outfile: bundlePath,
bundle: true,
platform: 'node',
format: 'cjs',
target: nodeTarget,
legalComments: 'none',
})
// The release directory is generated output. Recreate this exact package folder
// so a rebuild cannot retain files from an older connector version.
await rm(packageDir, { recursive: true, force: true })
await mkdir(packageDir, { recursive: true })
await writeFile(configPath, `${JSON.stringify({
main: bundlePath,
mainFormat: 'commonjs',
output: executablePath,
disableExperimentalSEAWarning: true,
useCodeCache: false,
}, null, 2)}\n`, 'utf8')
await buildSea(configPath, stagingDir)
if (process.platform !== 'win32') await chmod(executablePath, 0o755)
if (process.platform === 'darwin') {
// SEA modifies a copy of Node. Re-sign it locally so macOS will execute it.
await run('codesign', ['--force', '--sign', '-', '--timestamp=none', executablePath])
}
await cp(launcherSource, packageDir, { recursive: true, force: true })
if (process.platform !== 'win32') {
await Promise.all([
chmod(join(packageDir, '启动Spine连接器.command'), 0o755),
chmod(join(packageDir, '停止Spine连接器.command'), 0o755),
])
}
await cp(join(projectDir, 'connector', 'DISTRIBUTION.md'), join(packageDir, '使用说明.md'), { force: true })
await writeFile(join(packageDir, 'package.json'), `${JSON.stringify({
name: 'SpineParticleConnector',
connectorVersion: '1.0.0',
platform: process.platform,
architecture: process.arch,
executable: basename(executablePath),
builtWithNode: process.version,
}, null, 2)}\n`, 'utf8')
const archivePath = await createPortableArchive()
console.log(`已生成独立连接器:${packageDir}`)
console.log(`可执行文件:${executablePath}`)
if (archivePath) console.log(`分发压缩包:${archivePath}`)
} finally {
await rm(stagingDir, { recursive: true, force: true })
}
}
main().catch((error) => {
console.error(`构建独立连接器失败:${error instanceof Error ? error.message : error}`)
process.exitCode = 1
})