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 })