From 33036b65fcf9ab807451bb05176cc3b245e433f3 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 21 Sep 2026 15:59:04 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20Windows=20=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E7=9A=84=20Tauri=20NSIS=20=E5=B7=A5=E5=85=B7=E9=93=BE?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E4=B8=8B=E8=BD=BD=E4=B8=8E=E6=88=AA=E6=96=AD?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tauri-bundler 打包时现场从 GitHub 下载 nsis-3.11.zip 与 nsis_tauri_utils.dll 且不重试, Checkout 的 git clean -fdx 又会删掉工作区内的 target/.tauri,于是每个构建都要重下, 响应一旦被截断只会抛 `io: unexpected end of file`,让发布在 Rust 编译数分钟后失败。 - Jenkins Checkout 的 git clean 增加 -e apps/ai-game-creator-shell/src-tauri/target/.tauri,只保留工具缓存 - 新增 nsis-toolset.mjs:按固定 SHA1 预置 target/.tauri/NSIS,带 4 次重试并把原始归档缓存到工作区外 - 新增 ensure-nsis-toolset.mjs 与 nsis:prepare 脚本,作为 Jenkins 预检入口 - buildRelease 在 Windows 目标打包前接入工具链预置,预置失败即失败关闭 - Jenkins 新增 Tauri NSIS toolchain 阶段,在编译前预置、跑回归测试并校验 makensis 可执行 - 新增 nsis-toolset.test.mjs 并补 3 项 build-release 预置接入测试 - 同步开发运维文档与 shared-memory 排障记录 --- apps/ai-game-creator-shell/package.json | 2 + .../scripts/build-release.mjs | 10 +- .../scripts/build-release.test.mjs | 65 ++++ .../scripts/ensure-nsis-toolset.mjs | 28 ++ .../scripts/nsis-toolset.mjs | 342 ++++++++++++++++++ .../scripts/nsis-toolset.test.mjs | 331 +++++++++++++++++ docs/project-memory/shared-memory/pitfalls.md | 8 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 + .../Jenkinsfile.ai-game-creator-shell-build | 26 +- package-lock.json | 1 + package.json | 1 + 11 files changed, 813 insertions(+), 3 deletions(-) create mode 100644 apps/ai-game-creator-shell/scripts/ensure-nsis-toolset.mjs create mode 100644 apps/ai-game-creator-shell/scripts/nsis-toolset.mjs create mode 100644 apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 931fec88c..385f52277 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -9,6 +9,7 @@ "dev-stack": "node scripts/start-dev-stack.mjs", "build": "node scripts/build-release.mjs", "release:upload": "node scripts/release-upload.mjs", + "nsis:prepare": "node scripts/ensure-nsis-toolset.mjs", "skill-pack:check": "node scripts/check-skill-pack.mjs", "skill-pack:sync": "node scripts/check-skill-pack.mjs --write", "skill-pack:test": "node --test scripts/check-skill-pack.test.mjs", @@ -75,6 +76,7 @@ "@types/react-dom": "^19.2.3", "@types/react-window": "^1.8.8", "@types/three": "^0.184.1", + "jszip": "^3.10.1", "tailwindcss": "^4.1.14", "typescript": "~5.8.2", "vitest": "^0.34.6" diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 92b719262..70cfbdd87 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -13,6 +13,7 @@ import { defaultEditorFeatures, withDefaultCargoFeatures, } from './cargo-features.mjs'; +import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs'; import { stageNodeRuntime } from './stage-node-runtime.mjs'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); @@ -877,14 +878,19 @@ export async function buildRelease( args = [], { prepareVersion = prepareReleaseVersion, + prepareToolset = prepareNsisToolsetForRelease, build = runTauriBuild, generateManifest = generateUpdateManifest, } = {}, ) { const context = resolveReleaseContext(args); - if (!args.includes('--no-bundle')) await prepareVersion(context); + const bundling = !args.includes('--no-bundle'); + if (bundling) await prepareVersion(context); + // Tauri bundler 下载 NSIS 工具链时不重试,网络截断会直接毁掉整次打包; + // 因此打包前先在 Windows 目标上预置(详见 nsis-toolset.mjs)。 + if (bundling) await prepareToolset(context, { bundling }); build(args, context); - if (!args.includes('--no-bundle')) return generateManifest(context); + if (bundling) return generateManifest(context); } if ( diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index 59e1a878b..15bcc11aa 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -590,6 +590,71 @@ test('no-bundle smoke skips version writes and manifest generation', async () => assert.deepEqual(steps, ['dev']); }); +test('Windows 打包在 Tauri 构建前预置 NSIS 工具链', async () => { + const events = []; + await buildRelease(['--target', windowsTarget], { + prepareVersion: () => { + events.push('version'); + }, + prepareToolset: (context, options) => { + events.push(`toolset:${context.target}:${options.bundling}`); + }, + build: () => { + events.push('build'); + }, + generateManifest: () => { + events.push('manifest'); + }, + }); + assert.deepEqual(events, [ + 'version', + `toolset:${windowsTarget}:true`, + 'build', + 'manifest', + ]); +}); + +test('NSIS 工具链预置失败即失败关闭,不进入 Tauri 构建', async () => { + const events = []; + await assert.rejects( + buildRelease(['--target', windowsTarget], { + prepareVersion: () => { + events.push('version'); + }, + prepareToolset: () => { + throw new Error('NSIS 工具链预置失败:下载 nsis-3.11.zip 失败'); + }, + build: () => { + events.push('build'); + }, + generateManifest: () => { + events.push('manifest'); + }, + }), + /NSIS 工具链预置失败/u, + ); + assert.deepEqual(events, ['version']); +}); + +test('--no-bundle 不预置 NSIS 工具链', async () => { + const steps = []; + await buildRelease(['--no-bundle', '--target', windowsTarget], { + prepareVersion: () => { + steps.push('version'); + }, + prepareToolset: () => { + steps.push('toolset'); + }, + build: () => { + steps.push('build'); + }, + generateManifest: () => { + steps.push('manifest'); + }, + }); + assert.deepEqual(steps, ['build']); +}); + test('release stages Node before Tauri and injects its resource mapping only for bundles', () => { const context = resolveReleaseContext(['--target', windowsTarget]); const events = []; diff --git a/apps/ai-game-creator-shell/scripts/ensure-nsis-toolset.mjs b/apps/ai-game-creator-shell/scripts/ensure-nsis-toolset.mjs new file mode 100644 index 000000000..a8f35a453 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/ensure-nsis-toolset.mjs @@ -0,0 +1,28 @@ +// Jenkins Windows 预检入口:在数分钟的 Rust 编译之前完成 NSIS 工具链预置。 +// +// 预置失败必须在此之前失败关闭,避免 bundler 用 `io: unexpected end of file` +// 把网络问题伪装成打包问题。 + +import { ensureNsisToolset, LOG_PREFIX } from './nsis-toolset.mjs'; + +// Jenkins 阶段用 `$ErrorActionPreference = 'Stop'` 执行 Powershell:重试告警走 +// stderr 时可能被 PowerShell 当成终止错误,因此重试与进度一律写 stdout,只有 +// 最终失败才写 stderr 并以退出码 1 失败关闭。 +const logger = { + log: (message) => console.log(message), + warn: (message) => console.log(`${message}(将重试)`), +}; + +try { + const result = await ensureNsisToolset({ logger }); + console.log(`${LOG_PREFIX} NSIS 工具链目录:${result.nsisDir}`); + console.log(`${LOG_PREFIX} NSIS 原始归档缓存:${result.cacheDir}`); + console.log( + result.reused + ? `${LOG_PREFIX} NSIS 工具链复用已有目录,未访问网络` + : `${LOG_PREFIX} NSIS 工具链本次预置:${result.downloaded.join('、')}`, + ); +} catch (error) { + console.error(`${LOG_PREFIX} NSIS 工具链预置失败:${error.message}`); + process.exit(1); +} diff --git a/apps/ai-game-creator-shell/scripts/nsis-toolset.mjs b/apps/ai-game-creator-shell/scripts/nsis-toolset.mjs new file mode 100644 index 000000000..d79c9f8a7 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/nsis-toolset.mjs @@ -0,0 +1,342 @@ +// Tauri Windows bundler 的 NSIS 工具链预置。 +// +// 背景:`tauri build` 打 Windows NSIS 包时会现场从 GitHub 下载 `nsis-3.11.zip` +// 与 `nsis_tauri_utils.dll`(见 tauri-bundler `bundle/windows/nsis/mod.rs`)。 +// 构建机每个检出(`git clean -fdx`)都会丢掉 `target/.tauri` 缓存,于是每次 +// 发布都要重新下载;响应一旦被截断,bundler 只会报 `io: unexpected end of file`, +// 整条流水线在 Rust 编译数分钟之后才失败。 +// +// 这里在打包前用固定哈希 + 重试预置同一份工具链目录:bundler 检查到必需文件齐全 +// 且 `nsis_tauri_utils.dll` 哈希一致后就不会再自行下载。原始归档(两个文件) +// 额外缓存在工作区之外,构建机重复构建时不再依赖 GitHub 连通性。 + +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import JSZip from 'jszip'; + +export const LOG_PREFIX = '[ai-game-creator-shell]'; + +/** bundler 把工具链解到 `/.tauri/NSIS`(`bundle.useLocalToolsDir: true`)。 */ +export const NSIS_TOOLSET_DIR_NAME = 'NSIS'; + +export const NSIS_ARCHIVE_ASSET_NAME = 'nsis-3.11.zip'; +export const NSIS_ARCHIVE_URL = + 'https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/nsis-3.11.zip'; +export const NSIS_ARCHIVE_SHA1 = 'ef7ff767e5cbd9edd22add3a32c9b8f4500bb10d'; +export const NSIS_ARCHIVE_TOP_LEVEL_DIR = 'nsis-3.11'; + +export const NSIS_TAURI_UTILS_ASSET_NAME = 'nsis_tauri_utils.dll'; +export const NSIS_TAURI_UTILS_URL = + 'https://github.com/tauri-apps/nsis-tauri-utils/releases/download/nsis_tauri_utils-v0.5.3/nsis_tauri_utils.dll'; +export const NSIS_TAURI_UTILS_SHA1 = '75197fee3c6a814fe035788d1c34ead39349b860'; +export const NSIS_TAURI_UTILS_REQUIRED_FILE = + 'Plugins/x86-unicode/additional/nsis_tauri_utils.dll'; + +/** + * 与 tauri-bundler 2.9.x 的 `NSIS_REQUIRED_FILES` 逐条对齐:少一条 bundler 就会 + * 删掉整个目录重新下载,等于预置失效。升级 `@tauri-apps/cli` 时要同步核对。 + */ +export const NSIS_REQUIRED_FILES = [ + 'makensis.exe', + 'Bin/makensis.exe', + 'Stubs/lzma-x86-unicode', + 'Stubs/lzma_solid-x86-unicode', + NSIS_TAURI_UTILS_REQUIRED_FILE, + 'Include/MUI2.nsh', + 'Include/FileFunc.nsh', + 'Include/x64.nsh', + 'Include/nsDialogs.nsh', + 'Include/WinMessages.nsh', + 'Include/Win/COM.nsh', + 'Include/Win/Propkey.nsh', + 'Include/Win/RestartManager.nsh', +]; + +/** 需要预置的原始归档;测试可注入同结构描述替换其中的地址与哈希。 */ +export const NSIS_ASSETS = [ + { + assetName: NSIS_ARCHIVE_ASSET_NAME, + url: NSIS_ARCHIVE_URL, + sha1: NSIS_ARCHIVE_SHA1, + }, + { + assetName: NSIS_TAURI_UTILS_ASSET_NAME, + url: NSIS_TAURI_UTILS_URL, + sha1: NSIS_TAURI_UTILS_SHA1, + }, +]; + +const DEFAULT_DOWNLOAD_ATTEMPTS = 4; +const DEFAULT_RETRY_DELAY_MS = 3000; +const DEFAULT_DOWNLOAD_TIMEOUT_MS = 180_000; + +export function defaultAppRoot() { + return fileURLToPath(new URL('..', import.meta.url)); +} + +export function resolveTauriToolsDir(appRoot = defaultAppRoot()) { + // 必须与 `src-tauri/tauri.windows.conf.json` 的 `bundle.useLocalToolsDir: true` + // 保持一致,否则预置的文件不在 bundler 的查找路径上。 + return path.join(appRoot, 'src-tauri', 'target', '.tauri'); +} + +export function resolveNsisCacheDir( + env = process.env, + platform = process.platform, +) { + const explicit = env.AGC_TAURI_NSIS_CACHE_DIR?.trim(); + if (explicit) return path.resolve(explicit); + // Jenkins Windows 节点以 SYSTEM 运行,ProgramData 稳定可写且不受工作区清理影响; + // 缓存里只有待解压的原始归档,不会从该目录执行任何程序。 + if (platform === 'win32') { + const programData = env.ProgramData?.trim() || 'C:\\ProgramData'; + return path.join(programData, 'genarrative', 'tauri-nsis-cache'); + } + return path.join(os.homedir(), '.cache', 'genarrative', 'tauri-nsis-cache'); +} + +/** 与 tauri-bundler 相同的镜像开关语义,便于构建机绕过不可达的 GitHub。 */ +export function resolveDownloadUrl(url, env = process.env) { + if (!url.startsWith('https://github.com/')) return url; + const template = env.TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE?.trim(); + const match = + /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/releases\/download\/([^/]+)\/(.+)$/u.exec( + url, + ); + if (template && match) { + return template + .replaceAll('', match[1]) + .replaceAll('', match[2]) + .replaceAll('', match[3]) + .replaceAll('', match[4]); + } + const base = env.TAURI_BUNDLER_TOOLS_GITHUB_MIRROR?.trim(); + if (base) return `${base.replace(/\/+$/u, '')}/${url}`; + return url; +} + +export function sha1Of(data) { + return createHash('sha1').update(data).digest('hex'); +} + +function sha1OfFile(filePath) { + try { + return sha1Of(fs.readFileSync(filePath)); + } catch { + return null; + } +} + +export function verifyNsisToolset( + nsisDir, + { utilsSha1 = NSIS_TAURI_UTILS_SHA1 } = {}, +) { + const missing = NSIS_REQUIRED_FILES.filter( + (relativePath) => !fs.existsSync(path.join(nsisDir, relativePath)), + ); + const hashMismatch = + missing.length === 0 && + sha1OfFile(path.join(nsisDir, NSIS_TAURI_UTILS_REQUIRED_FILE)) !== + utilsSha1; + return { ok: missing.length === 0 && !hashMismatch, missing, hashMismatch }; +} + +/** 解析 zip 条目落盘位置,并拒绝 `../` 这类越界路径。 */ +export function resolveArchiveEntryTarget(rootDir, entryName) { + const root = path.resolve(rootDir); + const target = path.resolve(root, entryName); + if (target !== root && !target.startsWith(`${root}${path.sep}`)) { + throw new Error(`NSIS 归档包含越界路径:${entryName}`); + } + return target; +} + +function sleep(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function downloadBuffer(url, { fetchImpl, timeoutMs }) { + const response = await fetchImpl(url, { + redirect: 'follow', + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status} ${response.statusText}`.trim()); + } + const data = Buffer.from(await response.arrayBuffer()); + if (data.length === 0) throw new Error('响应为空'); + return data; +} + +async function downloadVerifiedAsset({ + assetName, + url, + sha1, + env, + fetchImpl, + attempts, + retryDelayMs, + timeoutMs, + logger, +}) { + const downloadUrl = resolveDownloadUrl(url, env); + let lastError; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + const data = await downloadBuffer(downloadUrl, { fetchImpl, timeoutMs }); + const actual = sha1Of(data); + if (actual !== sha1) { + throw new Error(`SHA1 不匹配(期望 ${sha1},实际 ${actual})`); + } + logger.log( + `${LOG_PREFIX} NSIS 工具链:已下载 ${assetName}(${data.length} 字节,第 ${attempt} 次尝试)`, + ); + return data; + } catch (error) { + lastError = error; + logger.warn( + `${LOG_PREFIX} NSIS 工具链:下载 ${assetName} 失败(第 ${attempt}/${attempts} 次):${error.message}`, + ); + if (attempt < attempts) await sleep(retryDelayMs * attempt); + } + } + throw new Error( + `下载 ${assetName} 失败(已重试 ${attempts} 次):${lastError?.message ?? '未知错误'}\n` + + `下载地址:${downloadUrl}\n` + + `可先把该文件放入缓存目录(AGC_TAURI_NSIS_CACHE_DIR)或配置 ` + + `TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE 后重试。`, + ); +} + +async function ensureCachedAsset(options) { + const { assetName, sha1, cacheDir, logger } = options; + const cachePath = path.join(cacheDir, assetName); + if (sha1OfFile(cachePath) === sha1) { + logger.log(`${LOG_PREFIX} NSIS 工具链:命中缓存 ${cachePath}`); + return cachePath; + } + if (fs.existsSync(cachePath)) { + logger.warn( + `${LOG_PREFIX} NSIS 工具链:缓存文件校验失败,重新下载 ${cachePath}`, + ); + } + const data = await downloadVerifiedAsset(options); + fs.mkdirSync(cacheDir, { recursive: true }); + const tempPath = `${cachePath}.tmp-${process.pid}`; + fs.writeFileSync(tempPath, data); + fs.rmSync(cachePath, { force: true }); + fs.renameSync(tempPath, cachePath); + return cachePath; +} + +export async function extractNsisArchive(archivePath, destinationDir) { + const archive = await JSZip.loadAsync(fs.readFileSync(archivePath)); + for (const [entryName, entry] of Object.entries(archive.files)) { + if (entry.dir) continue; + const target = resolveArchiveEntryTarget(destinationDir, entryName); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, await entry.async('nodebuffer')); + } +} + +/** + * 预置 `target/.tauri/NSIS`:已就绪时零网络直接返回,否则用缓存或重试下载补齐。 + * 返回结构用于测试与日志,不参与发布产物。 + */ +export async function ensureNsisToolset({ + appRoot = defaultAppRoot(), + toolsDir = resolveTauriToolsDir(appRoot), + cacheDir = resolveNsisCacheDir(process.env), + env = process.env, + fetchImpl = globalThis.fetch, + assets = NSIS_ASSETS, + attempts = DEFAULT_DOWNLOAD_ATTEMPTS, + retryDelayMs = DEFAULT_RETRY_DELAY_MS, + timeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS, + logger = console, +} = {}) { + const nsisDir = path.join(toolsDir, NSIS_TOOLSET_DIR_NAME); + // 生产路径下这里恒等于 bundler 固定的 `nsis_tauri_utils.dll` SHA1;测试注入 + // 自己的归档描述时,校验口径必须与被注入的资产一致。 + const missingAsset = [ + NSIS_ARCHIVE_ASSET_NAME, + NSIS_TAURI_UTILS_ASSET_NAME, + ].find((assetName) => !assets.some((asset) => asset.assetName === assetName)); + if (missingAsset) throw new Error(`NSIS 资产描述缺少 ${missingAsset}`); + const utilsSha1 = + assets.find((asset) => asset.assetName === NSIS_TAURI_UTILS_ASSET_NAME) + ?.sha1 ?? NSIS_TAURI_UTILS_SHA1; + const existing = verifyNsisToolset(nsisDir, { utilsSha1 }); + if (existing.ok) { + logger.log(`${LOG_PREFIX} NSIS 工具链已就绪:${nsisDir}`); + return { nsisDir, toolsDir, cacheDir, reused: true, downloaded: [] }; + } + logger.log( + `${LOG_PREFIX} NSIS 工具链需要预置:${nsisDir}` + + (existing.missing.length > 0 + ? `(缺少 ${existing.missing.length} 个文件)` + : '(哈希不符)'), + ); + + const downloadOptions = { + env, + fetchImpl, + attempts, + retryDelayMs, + timeoutMs, + cacheDir, + logger, + }; + const assetPaths = {}; + for (const asset of assets) { + assetPaths[asset.assetName] = await ensureCachedAsset({ + ...asset, + ...downloadOptions, + }); + } + + fs.rmSync(nsisDir, { recursive: true, force: true }); + await extractNsisArchive(assetPaths[NSIS_ARCHIVE_ASSET_NAME], toolsDir); + const extractedDir = path.join(toolsDir, NSIS_ARCHIVE_TOP_LEVEL_DIR); + if (!fs.existsSync(extractedDir)) { + throw new Error( + `NSIS 归档结构不符合预期:${assetPaths[NSIS_ARCHIVE_ASSET_NAME]} 未解出 ${NSIS_ARCHIVE_TOP_LEVEL_DIR}`, + ); + } + fs.renameSync(extractedDir, nsisDir); + + const utilsTarget = path.join(nsisDir, NSIS_TAURI_UTILS_REQUIRED_FILE); + fs.mkdirSync(path.dirname(utilsTarget), { recursive: true }); + fs.copyFileSync(assetPaths[NSIS_TAURI_UTILS_ASSET_NAME], utilsTarget); + + const installed = verifyNsisToolset(nsisDir, { utilsSha1 }); + if (!installed.ok) { + throw new Error( + `NSIS 工具链预置不完整:缺少 ${installed.missing.join(', ') || '无'};` + + `哈希不符=${installed.hashMismatch}`, + ); + } + logger.log(`${LOG_PREFIX} NSIS 工具链预置完成:${nsisDir}`); + return { + nsisDir, + toolsDir, + cacheDir, + reused: false, + downloaded: assets.map((asset) => asset.assetName), + }; +} + +/** `buildRelease` 用:只在 Windows 目标且需要打包时预置 NSIS 工具链。 */ +export async function prepareNsisToolsetForRelease( + context, + { bundling = true, ...deps } = {}, +) { + if (!bundling || !context.target.includes('windows')) return null; + return ensureNsisToolset(deps); +} diff --git a/apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs b/apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs new file mode 100644 index 000000000..26faca27e --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs @@ -0,0 +1,331 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; + +import JSZip from 'jszip'; + +import { + ensureNsisToolset, + extractNsisArchive, + NSIS_ARCHIVE_ASSET_NAME, + NSIS_ARCHIVE_TOP_LEVEL_DIR, + NSIS_REQUIRED_FILES, + NSIS_TAURI_UTILS_ASSET_NAME, + NSIS_TOOLSET_DIR_NAME, + prepareNsisToolsetForRelease, + resolveArchiveEntryTarget, + resolveDownloadUrl, + resolveNsisCacheDir, + resolveTauriToolsDir, + sha1Of, + verifyNsisToolset, +} from './nsis-toolset.mjs'; + +const appRoot = path.resolve( + path.dirname(new URL(import.meta.url).pathname), + '..', +); +const silentLogger = { log() {}, warn() {} }; + +function createSandbox() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'agc-nsis-toolset-')); +} + +/** 与真实归档同构的最小 zip:只保留 bundler 必需文件。 */ +async function createArchiveFixture(extraEntries = {}) { + const zip = new JSZip(); + for (const relativePath of NSIS_REQUIRED_FILES) { + zip.file( + `${NSIS_ARCHIVE_TOP_LEVEL_DIR}/${relativePath}`, + `fixture:${relativePath}`, + ); + } + for (const [name, contents] of Object.entries(extraEntries)) { + zip.file(name, contents); + } + return zip.generateAsync({ type: 'nodebuffer' }); +} + +function fixtureAssets({ archive, utils }) { + return [ + { + assetName: NSIS_ARCHIVE_ASSET_NAME, + url: `https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/${NSIS_ARCHIVE_ASSET_NAME}`, + sha1: sha1Of(archive), + }, + { + assetName: NSIS_TAURI_UTILS_ASSET_NAME, + url: `https://github.com/tauri-apps/nsis-tauri-utils/releases/download/nsis_tauri_utils-v0.5.3/${NSIS_TAURI_UTILS_ASSET_NAME}`, + sha1: sha1Of(utils), + }, + ]; +} + +function fixtureFetch({ archive, utils, failures = 0 }) { + let remainingFailures = failures; + const calls = []; + const fetchImpl = async (url) => { + calls.push(url); + if (remainingFailures > 0) { + remainingFailures -= 1; + throw new Error('network truncated'); + } + const body = url.includes(NSIS_TAURI_UTILS_ASSET_NAME) ? utils : archive; + return { + ok: true, + status: 200, + statusText: 'OK', + arrayBuffer: async () => body, + }; + }; + return { fetchImpl, calls }; +} + +test('NSIS 工具链目录与 Tauri useLocalToolsDir 配置保持一致', () => { + const config = JSON.parse( + fs.readFileSync( + path.join(appRoot, 'src-tauri', 'tauri.windows.conf.json'), + 'utf8', + ), + ); + assert.equal(config.bundle.useLocalToolsDir, true); + assert.equal( + resolveTauriToolsDir(appRoot), + path.join(appRoot, 'src-tauri', 'target', '.tauri'), + ); +}); + +test('缓存目录默认落在工作区之外并支持环境变量覆盖', () => { + assert.equal( + resolveNsisCacheDir( + { AGC_TAURI_NSIS_CACHE_DIR: '/tmp/agc-cache' }, + 'linux', + ), + '/tmp/agc-cache', + ); + assert.equal( + resolveNsisCacheDir({ ProgramData: 'D:\\ProgramData' }, 'win32'), + path.join('D:\\ProgramData', 'genarrative', 'tauri-nsis-cache'), + ); + assert.ok( + resolveNsisCacheDir({}, 'linux').endsWith( + path.join('.cache', 'genarrative', 'tauri-nsis-cache'), + ), + ); +}); + +test('下载地址支持 tauri bundler 的两套 GitHub 镜像开关', () => { + const url = + 'https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/nsis-3.11.zip'; + assert.equal(resolveDownloadUrl(url, {}), url); + assert.equal( + resolveDownloadUrl(url, { + TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE: + 'https://mirror.example.com////', + }), + 'https://mirror.example.com/tauri-apps/binary-releases/nsis-3.11/nsis-3.11.zip', + ); + assert.equal( + resolveDownloadUrl(url, { + TAURI_BUNDLER_TOOLS_GITHUB_MIRROR: 'https://mirror.example.com/', + }), + `https://mirror.example.com/${url}`, + ); +}); + +test('工具链已就绪时零下载复用', async () => { + const sandbox = createSandbox(); + const toolsDir = path.join(sandbox, '.tauri'); + const cacheDir = path.join(sandbox, 'cache'); + const archive = await createArchiveFixture(); + const utils = Buffer.from('nsis-tauri-utils-dll'); + const assets = fixtureAssets({ archive, utils }); + + await ensureNsisToolset({ + toolsDir, + cacheDir, + assets, + fetchImpl: fixtureFetch({ archive, utils }).fetchImpl, + logger: silentLogger, + }); + + let fetchCalls = 0; + const reused = await ensureNsisToolset({ + toolsDir, + cacheDir, + assets, + fetchImpl: async () => { + fetchCalls += 1; + throw new Error('工具链已就绪时不应访问网络'); + }, + logger: silentLogger, + }); + assert.equal(reused.reused, true); + assert.deepEqual(reused.downloaded, []); + assert.equal(fetchCalls, 0); + assert.equal(reused.nsisDir, path.join(toolsDir, NSIS_TOOLSET_DIR_NAME)); +}); + +test('冷启动时下载、校验、解压并落缓存,重跑走缓存', async () => { + const sandbox = createSandbox(); + const toolsDir = path.join(sandbox, '.tauri'); + const cacheDir = path.join(sandbox, 'cache'); + const archive = await createArchiveFixture(); + const utils = Buffer.from('nsis-tauri-utils-dll'); + const assets = fixtureAssets({ archive, utils }); + const { fetchImpl, calls } = fixtureFetch({ archive, utils }); + + const result = await ensureNsisToolset({ + toolsDir, + cacheDir, + assets, + fetchImpl, + logger: silentLogger, + }); + assert.deepEqual(calls.length, 2); + assert.deepEqual(result.downloaded, [ + NSIS_ARCHIVE_ASSET_NAME, + NSIS_TAURI_UTILS_ASSET_NAME, + ]); + assert.equal( + verifyNsisToolset(path.join(toolsDir, NSIS_TOOLSET_DIR_NAME), { + utilsSha1: sha1Of(utils), + }).ok, + true, + ); + assert.equal( + fs.readFileSync( + path.join( + toolsDir, + NSIS_TOOLSET_DIR_NAME, + 'Plugins/x86-unicode/additional/nsis_tauri_utils.dll', + ), + 'utf8', + ), + 'nsis-tauri-utils-dll', + ); + + // 第二次构建:清空工作区工具目录后仍应零下载恢复(模拟 Jenkins git clean -fdx)。 + fs.rmSync(path.join(toolsDir, NSIS_TOOLSET_DIR_NAME), { + recursive: true, + force: true, + }); + const offline = await ensureNsisToolset({ + toolsDir, + cacheDir, + assets, + fetchImpl: async () => { + throw new Error('命中缓存时不应访问网络'); + }, + logger: silentLogger, + }); + assert.equal( + verifyNsisToolset(offline.nsisDir, { utilsSha1: sha1Of(utils) }).ok, + true, + ); +}); + +test('下载失败按次数重试,最终成功', async () => { + const sandbox = createSandbox(); + const archive = await createArchiveFixture(); + const utils = Buffer.from('dll'); + const { fetchImpl, calls } = fixtureFetch({ archive, utils, failures: 2 }); + const result = await ensureNsisToolset({ + toolsDir: path.join(sandbox, '.tauri'), + cacheDir: path.join(sandbox, 'cache'), + assets: fixtureAssets({ archive, utils }), + fetchImpl, + attempts: 3, + retryDelayMs: 1, + logger: silentLogger, + }); + assert.equal(result.downloaded.length, 2); + assert.equal(calls.length, 4); +}); + +test('哈希不匹配时报错并给出可操作提示', async () => { + const sandbox = createSandbox(); + const { fetchImpl } = fixtureFetch({ + archive: Buffer.from('corrupted'), + utils: Buffer.from('corrupted'), + }); + await assert.rejects( + ensureNsisToolset({ + toolsDir: path.join(sandbox, '.tauri'), + cacheDir: path.join(sandbox, 'cache'), + assets: [ + { + assetName: NSIS_ARCHIVE_ASSET_NAME, + url: 'https://github.com/a/b/releases/download/1/n.zip', + sha1: 'deadbeef', + }, + { + assetName: NSIS_TAURI_UTILS_ASSET_NAME, + url: 'https://github.com/a/b/releases/download/1/n.dll', + sha1: 'deadbeef', + }, + ], + fetchImpl, + attempts: 2, + retryDelayMs: 1, + logger: silentLogger, + }), + /SHA1 不匹配/u, + ); + assert.equal( + fs.existsSync(path.join(sandbox, 'cache', NSIS_ARCHIVE_ASSET_NAME)), + false, + ); +}); + +test('归档越界路径与缺失必需文件都会失败关闭', async () => { + const sandbox = createSandbox(); + assert.throws( + () => + resolveArchiveEntryTarget(path.join(sandbox, 'extract'), '../escape.txt'), + /越界路径/u, + ); + assert.equal( + resolveArchiveEntryTarget(path.join(sandbox, 'extract'), 'nsis-3.11/a/b'), + path.resolve(sandbox, 'extract', 'nsis-3.11/a/b'), + ); + + const emptyArchive = new JSZip() + .file(`${NSIS_ARCHIVE_TOP_LEVEL_DIR}/makensis.exe`, 'only-one') + .generateAsync({ type: 'nodebuffer' }); + const emptyPath = path.join(sandbox, 'incomplete.zip'); + fs.writeFileSync(emptyPath, await emptyArchive); + const toolsDir = path.join(sandbox, 'incomplete-tools'); + await extractNsisArchive(emptyPath, toolsDir); + const status = verifyNsisToolset( + path.join(toolsDir, NSIS_ARCHIVE_TOP_LEVEL_DIR), + ); + assert.equal(status.ok, false); + assert.ok(status.missing.includes('Bin/makensis.exe')); +}); + +test('非 Windows 目标或 --no-bundle 不预置工具链', async () => { + let called = 0; + const deps = { + ensure: async () => { + called += 1; + }, + }; + assert.equal( + await prepareNsisToolsetForRelease( + { target: 'x86_64-pc-windows-msvc' }, + { bundling: false, ...deps }, + ), + null, + ); + assert.equal( + await prepareNsisToolsetForRelease( + { target: 'aarch64-apple-darwin' }, + deps, + ), + null, + ); + assert.equal(called, 0); +}); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index bd9198a62..3eb32e228 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5425,6 +5425,14 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 处理:Windows 专用 Tauri 配置设置 `bundle.useLocalToolsDir: true`,把工具缓存到 `src-tauri/target/.tauri/NSIS`;Jenkins 预检验证实际用户、项目工具目录可写,并在构建失败时打印实际缓存路径和绝对路径执行结果。 - 验证:不要把 PATH 中 `makensis` 可发现当作 Tauri bundler 工具可执行的充分证据;需要在 Windows Agent 上检查 `target/.tauri/NSIS/makensis.exe`、ACL、EDR/Defender 和直接 `-VERSION` 结果。 +## Tauri NSIS 工具链必须在打包前预置并重试(2026-09-21) + +- 现象:AGC Windows 发布构建已完成 Rust release,Tauri 依次打印 `Downloading .../nsis-3.11.zip`、`Info extracting NSIS`、`Downloading .../nsis_tauri_utils.dll` 之后,直接以 ``failed to bundle project `io: unexpected end of file` `` 失败(退出码 1),安装包不会产出。 +- 原因:tauri-bundler 的 `download_and_verify` 现场从 GitHub 取 NSIS 工具链,只有一次机会、没有重试;响应体被截断即报 `io: unexpected end of file`,看起来像打包错误其实是网络问题。Checkout 阶段的 `git clean -fdx` 每次都会清掉 `target/.tauri`,所以每个构建都要重新下载,在受限网络下必然反复失败。 +- 处理:新增 `apps/ai-game-creator-shell/scripts/nsis-toolset.mjs` 与 `ensure-nsis-toolset.mjs`,在 `buildRelease`(Windows 目标且需要打包时)与 Jenkins `Tauri NSIS toolchain` 阶段按固定 SHA1 预置 `target/.tauri/NSIS`:原始归档带 4 次重试,缓存在工作区外的 `%ProgramData%\genarrative\tauri-nsis-cache`(可用 `AGC_TAURI_NSIS_CACHE_DIR` 覆盖),镜像开关沿用 bundler 的 `TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE` / `TAURI_BUNDLER_TOOLS_GITHUB_MIRROR`。该阶段同时执行 `makensis.exe -VERSION`,把 2026-09-02 记录的缓存目录不可执行问题也提前到编译之前暴露。Checkout 阶段改为 `git clean -fdx -e apps/ai-game-creator-shell/src-tauri/target/.tauri`:Tauri 的工具缓存位于工作区内,裸 `git clean -fdx` 会连它一起删,排除后同一节点的稳态构建不再需要联网,只有冷缓存(新节点、工作区重建)才下载。 +- 验证:`node --test apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs`(已就绪零下载复用、缓存离线还原、失败重试、哈希不符与归档越界失败关闭)与 `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs`;真实节点上该阶段必须早于 Rust 编译失败关闭。 +- 注意:不要改回 `bundle.useLocalToolsDir: false` 去用 `%LOCALAPPDATA%`,也不要依赖 PATH 里预装的 `makensis`;升级 `@tauri-apps/cli` 时同步核对归档 URL、SHA1 与必需文件清单。 + ## AGC 登录态续期必须同步本地运行时 - 模型目录 HTTP 请求与 DirectProject 的 Rust/app-server 使用同一账号,但凭据分别保存在 WebView 与 Rust / Runner;续期应复用 `requestPlatformSessionRefresh` 完成用户核验及本地会话安装,不能只写 localStorage。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index c7916f2c8..ca6d7ec82 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -147,6 +147,8 @@ revision 变化时,调度管线把同一个完整 commit 通过 `COMMIT_HASH` 手工发布入口 `Genarrative-Manual-Build-And-Deploy` 的 `DEPLOY_TARGET` 与 AGC 更新渠道是两个独立维度。手工入口必须把 `release` 映射为 `AGC_UPDATE_CHANNEL=release`、把 `development` 映射为 `dev`,并把该参数同时透传给 `Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build`;只传 `AGC_RELEASE_VERSION` 时下游会落回各自默认 `dev`。统一号 `0.1.95` 的 release 包应分别位于 `agc/release-win/0.1.95/` 与 `agc/release-mac/0.1.95/`,渠道清单为对应目录下的 `latest.json`,不存在 `agc/release/` 这一层。补发本轮已烧号的版本时,直接以相同 `AGC_RELEASE_VERSION` 重跑两条 AGC Job,不重新发号。 +`Genarrative-Agc-Windows-Build` 的 `Tauri NSIS toolchain` 阶段必须在 Rust 编译前预置 NSIS 工具链并失败关闭:tauri-bundler 打包时现场从 GitHub 下载 `nsis-3.11.zip` 与 `nsis_tauri_utils.dll` 且不重试,构建机每次检出都会重下,响应一旦被截断就只能抛 `io: unexpected end of file`,让发布在编译数分钟后才失败。该阶段先跑 `node apps/ai-game-creator-shell/scripts/ensure-nsis-toolset.mjs`(固定 SHA1 校验、4 次重试、解压到 `target/.tauri/NSIS`),再执行 `makensis.exe -VERSION` 验证可执行性;Checkout 的 `git clean -fdx` 必须带 `-e apps/ai-game-creator-shell/src-tauri/target/.tauri`,只保留这份工具缓存、其余 `target/` 内容照常清空,否则工作区内缓存会被每个构建删掉,退回到「每次从 GitHub 重下」(实测裸 `git clean -fdx` 会输出 `Would remove apps/ai-game-creator-shell/src-tauri/target/`);原始归档缓存在工作区外的 `%ProgramData%\genarrative\tauri-nsis-cache`(可用 `AGC_TAURI_NSIS_CACHE_DIR` 覆盖),因此同一节点只有冷缓存才需要联网,离线补缓存时把这两个文件放进缓存目录即可;构建机确实无法访问 GitHub 时使用 bundler 自带的 `TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE` / `TAURI_BUNDLER_TOOLS_GITHUB_MIRROR` 指向可达镜像。升级 `@tauri-apps/cli` 时必须同步核对 `nsis-toolset.mjs` 里的归档地址、SHA1 与必需文件清单(与 tauri-bundler 的 `NSIS_REQUIRED_FILES` 逐条对齐),否则预置会被 bundler 判为不完整。 + 调度状态是调度 Job 工作区里的 `.jenkins-last-triggered-revision`,构建描述同时回显本次 revision 与结果。工作区被清理(例如 `Wipe Out Workspace`)或状态文件缺失时,下一次运行按“版本变化”处理并触发一次,之后恢复稳定;需要重建同一版本时勾选 `FORCE_TRIGGER`。Job 按仓库内 `jenkins/scheduled-revision-trigger-job-config.xml` 创建:`scriptPath=jenkins/Jenkinsfile.scheduled-revision-trigger`、Git 入口 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git`、凭据 `genarrative-local-gitea-ssh`、`` 留空(定时器写在 Jenkinsfile 里)。推送后必须让三个 live Job 各自加载一次新 Jenkinsfile,并只读核对 `config.xml`:Full 与 AGC 不再有 cron,定时只来自新调度 Job;只改 Jenkinsfile 而不确认 live 配置时,旧 cron 仍会继续触发。 Full Job 通过 `EXIT_MAINTENANCE_MODE_AFTER_COMPLETION` 明确选择完整发布成功后是否退出维护,默认勾选以保持历史行为。Full 对 Stdb Publish 和 API Deploy 两个下游阶段都固定传 `KEEP_MAINTENANCE_MODE=true`,让 maintenance marker 持续覆盖 Stdb → API → Web 整段发布;Web Deploy 成功后才进入独立 `Exit Maintenance` 阶段。该阶段只能通过 `agent none` 和显式 `node(...)` 分配目标机,直接执行 `/opt/genarrative/current/scripts/deploy/maintenance-off.sh`;目标机不得 checkout Git、挂载 Git SSH 凭据或依赖 Jenkins workspace 源码。取消勾选时跳过最终退出阶段,便于内网验收完成后人工恢复公网。`Genarrative-Api-Deploy` 也单独暴露 `KEEP_MAINTENANCE_MODE` 参数,并转换为随发布包脚本的 `--keep-maintenance-mode`;失败路径仍按既有 current 切换边界保留或退出维护,不受成功态选项覆盖。外部生成 queue 的 `warning` 由 API/worker 固化为可直接展示的完整文案,Web 不再补前缀,因此 API/worker 与 Web 必须在同一维护窗口按同一版本协调发布;分开运行 Job 时先保持维护态完成 API/worker,再发布 Web,二者完成后才能恢复公网,不得在公网可用期间只滚动其中一侧。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-build b/jenkins/Jenkinsfile.ai-game-creator-shell-build index d48f8d670..11ce61f3b 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-build @@ -41,7 +41,11 @@ pipeline { if (Test-Path '.git') { & $git fetch --tags --force --prune origin "+refs/heads/$($env:SOURCE_BRANCH):refs/remotes/origin/$($env:SOURCE_BRANCH)" if ($LASTEXITCODE -ne 0) { throw 'Git fetch 失败' } - & $git clean -fdx + # Tauri 的 NSIS 工具缓存位于工作区内的 target/.tauri:裸 git clean -fdx + # 会把它一起删掉,导致每个构建都重新从 GitHub 下载工具链(下载无重试, + # 响应截断即 `io: unexpected end of file`)。这里只排除该缓存目录, + # 其余 target 内容照常清空。 + & $git clean -fdx -e 'apps/ai-game-creator-shell/src-tauri/target/.tauri' & $git reset --hard "origin/$($env:SOURCE_BRANCH)" } else { & $git clone --no-single-branch $env:GIT_REMOTE_URL . @@ -144,6 +148,26 @@ pipeline { } } + stage('Tauri NSIS toolchain') { + steps { + withEnv(["PATH=${env.AGC_WINDOWS_PATH}"]) { + // 必须早于 Rust 编译与打包:Tauri bundler 自己下载 NSIS 工具链时不重试, + // 响应截断只会报 `io: unexpected end of file`,让发布在数分钟后才失败。 + powershell ''' + $ErrorActionPreference = 'Stop' + node --test apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs + if ($LASTEXITCODE -ne 0) { throw "NSIS 工具链回归测试失败,退出码=$LASTEXITCODE" } + node apps/ai-game-creator-shell/scripts/ensure-nsis-toolset.mjs + if ($LASTEXITCODE -ne 0) { throw "NSIS 工具链预置失败,退出码=$LASTEXITCODE" } + $nsis = Join-Path $PWD 'apps/ai-game-creator-shell/src-tauri/target/.tauri/NSIS/makensis.exe' + if (-not (Test-Path $nsis)) { throw "NSIS makensis.exe 缺失:$nsis" } + & $nsis -VERSION + if ($LASTEXITCODE -ne 0) { throw "NSIS makensis.exe 无法执行,退出码=$LASTEXITCODE" } + ''' + } + } + } + stage('Build and upload') { steps { script { diff --git a/package-lock.json b/package-lock.json index 391c714be..c5257d6f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -138,6 +138,7 @@ "@types/react-dom": "^19.2.3", "@types/react-window": "^1.8.8", "@types/three": "^0.184.1", + "jszip": "^3.10.1", "tailwindcss": "^4.1.14", "typescript": "~5.8.2", "vitest": "^0.34.6" diff --git a/package.json b/package.json index 7436a415f..57405379c 100644 --- a/package.json +++ b/package.json @@ -178,6 +178,7 @@ "ai-game-creator-shell:dev-server": "npm --prefix apps/ai-game-creator-shell run dev-server", "ai-game-creator-shell:build": "npm --prefix apps/ai-game-creator-shell run build --", "ai-game-creator-shell:release:upload": "npm --prefix apps/ai-game-creator-shell run release:upload", + "ai-game-creator-shell:nsis:prepare": "npm --prefix apps/ai-game-creator-shell run nsis:prepare", "ai-game-creator-shell:llm-status": "npm --prefix apps/ai-game-creator-shell run llm-status --", "ai-game-creator-shell:agent-task": "npm --prefix apps/ai-game-creator-shell run agent-task --", "agc:chat": "npm --prefix apps/ai-game-creator-shell run chat --",