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