合并 master 并接入 DirectProject 新聊天架构
- 合并 origin/master(304 个提交:DirectProject 聊天容器重构、Project Supervisor 退役、策划附件导入、CI 隔离编译缓存等)。 - 接受 master 对 ProjectSupervisorView / SupervisorChatOnlyView 的退役与预览快捷测试收敛;发布入口改由 DirectProject 聊天头承载。 - DirectProjectChatHeader 新增「发布到游戏广场」入口(无回调不渲染、回合忙态禁用),DirectProjectChatView 透传 onRequestGamePublish。 - App.tsx 继续由工作台壳持有试玩包导出与 GameDistributionPublishPanel,沿用 project.export_package 权限确认队列;check-config 把该命令从 native-only 清单移回 App invoke。 - 后台游戏审核 API / 类型 / 路由测试与 master 新增的 AGC 模板管理按双方保留合并,并修掉拼接造成的接口与用例闭合缺陷。 - 修正 master 自带的 viteProxyConfig 断言:/api/creation-entry 属退役路由,测试改为断言不进入代理。 - 记录合并踩坑:语法结构内部的冲突不能简单按「双方保留」拼接,必须按某一侧骨架重建并跑 tsc 与单文件测试。 - 验证:全量 vitest 393 文件 / 4374 用例通过,root / AGC / admin-web 三端 typecheck,cargo check 与游戏分发 Rust 测试,encoding、doc-index、rustfmt、SpacetimeDB schema guard。
This commit is contained in:
@@ -185,7 +185,12 @@ test('nextVersion 只在 patch 位递增', () => {
|
||||
|
||||
test('ossutil 参数默认使用 v1 签名,并可按需带 region 与 v4', () => {
|
||||
const base = {
|
||||
args: ['cp', '--force', '/tmp/a.json', 'oss://agc-dev/agc/global-version.json'],
|
||||
args: [
|
||||
'cp',
|
||||
'--force',
|
||||
'/tmp/a.json',
|
||||
'oss://agc-dev/agc/global-version.json',
|
||||
],
|
||||
endpoint: 'oss-rg-china-mainland.aliyuncs.com',
|
||||
accessKeyId: 'id',
|
||||
accessKeySecret: 'secret',
|
||||
|
||||
@@ -20,7 +20,10 @@ import { createInterface } from 'node:readline/promises';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { inflateSync } from 'node:zlib';
|
||||
|
||||
export const appIdentifier = 'world.genarrative.ai-game-creator';
|
||||
import { AGC_APP_IDENTIFIER } from './channel-identity.mjs';
|
||||
|
||||
// 联调工具驱动的始终是默认渠道客户端:安装身份取渠道基线,不跟随发布渠道。
|
||||
export const appIdentifier = AGC_APP_IDENTIFIER;
|
||||
export const configFileName = 'game-creator.config.json';
|
||||
export const localConfigFileName = 'game-creator.config.local.json';
|
||||
export const runnerEndpointFileName = 'agent-runner.endpoint.json';
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
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 {
|
||||
generateUpdateManifest,
|
||||
prepareReleaseVersion,
|
||||
resolveManifestPlatformKeys,
|
||||
resolveReleaseContext,
|
||||
resolveReleasePartition,
|
||||
runTauriBuild,
|
||||
} from './build-release.mjs';
|
||||
import { resolveChannelInstallIdentity } from './channel-identity.mjs';
|
||||
import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs';
|
||||
import {
|
||||
readUpdaterPubkey,
|
||||
verifyUpdaterSignature,
|
||||
} from './verify-updater-signature.mjs';
|
||||
|
||||
/**
|
||||
* AGC macOS 分区(`<channel>-mac`)发布入口:构建 arm64 单架构包 → arm64 隔离 smoke → 生成 arm64 DMG
|
||||
* → 生成分区清单 latest.json → 用产物内烘焙的公钥验签 → 按 dry-run 决定是否上传 OSS。
|
||||
*
|
||||
* 边界:
|
||||
* - 只出 Apple Silicon(arm64)单架构:清单只登记 `darwin-aarch64`。Intel 侧要可用,前提是随包 Node
|
||||
* 也能按架构各带一份(`stage-node-runtime.mjs` 对 universal 目标失败关闭);在实现之前**不得**
|
||||
* 把 arm64 产物登记成 `darwin-x86_64`,否则 Intel 客户端会装到跑不起来的包。
|
||||
* - Apple 签名与公证暂缺:本入口剥离 `APPLE_*` 凭据让 Tauri 跳过 Apple 签名,但**不能传
|
||||
* `--no-sign`** —— 该标志同时会跳过 updater 的 minisign 签名,产物就没有 `.sig`;
|
||||
* 未签名 + 未公证必须显式记录而非静默通过;
|
||||
* - 更新包签名(TAURI_SIGNING_PRIVATE_KEY,minisign)是硬需求:缺了客户端一律拒绝安装,
|
||||
* 因此构建前要求凭据存在,构建后用内置公钥复核 `.sig` 才允许继续上传;
|
||||
* - 未通过验签绝不写 OSS:上传顺序为更新包、签名、首装包,全部成功后才覆盖渠道清单指针。
|
||||
*/
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
const repoRoot = path.resolve(appRoot, '../..');
|
||||
|
||||
/**
|
||||
* 产品名只从渠道安装身份派生(渠道身份由构建期 `--config` 注入 Tauri 配置):
|
||||
* 它同时决定 `*.app` 目录名、updater 归档名与 DMG 卷名。写死会在改名或换渠道后
|
||||
* 让入口静默找错对象(清理、打包、归档三处一起失效)。
|
||||
*/
|
||||
function resolveProductName(channel) {
|
||||
const { productName } = resolveChannelInstallIdentity(channel);
|
||||
assert.ok(
|
||||
typeof productName === 'string' && productName.trim().length > 0,
|
||||
'渠道安装身份缺少 productName',
|
||||
);
|
||||
return productName;
|
||||
}
|
||||
|
||||
assert.equal(process.platform, 'darwin', '只能在 macOS Agent 执行');
|
||||
assert.equal(
|
||||
process.env.JENKINS_URL?.length > 0,
|
||||
true,
|
||||
'此入口仅用于 Jenkins 独立工作区',
|
||||
);
|
||||
assert.equal(
|
||||
fs.realpathSync(process.env.WORKSPACE || '.'),
|
||||
fs.realpathSync(repoRoot),
|
||||
'必须在 Jenkins workspace 根目录执行',
|
||||
);
|
||||
const space = fs.statfsSync(repoRoot);
|
||||
assert.ok(
|
||||
space.bavail * space.bsize >= 8 * 1024 ** 3,
|
||||
'构建前至少需要 8 GiB 可用空间;禁止自动清理开发缓存',
|
||||
);
|
||||
|
||||
// 仅剥离 Apple 签名/公证变量:本节点没有证书,误用只会让构建失败;
|
||||
// 更新包签名与 OSS 凭据必须保留,它们是本入口发布能力的组成部分。
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (/^APPLE_/u.test(key)) delete process.env[key];
|
||||
}
|
||||
assert.ok(
|
||||
process.env.TAURI_SIGNING_PRIVATE_KEY?.length > 0 ||
|
||||
process.env.TAURI_SIGNING_PRIVATE_KEY_PATH?.length > 0,
|
||||
'缺少更新包签名私钥(TAURI_SIGNING_PRIVATE_KEY / _PATH):无签名的更新包会被客户端拒绝,禁止继续',
|
||||
);
|
||||
|
||||
const bucket = process.env.AGC_OSS_BUCKET?.trim() || 'agc-dev';
|
||||
const endpoint =
|
||||
process.env.AGC_OSS_ENDPOINT?.trim() || 'oss-rg-china-mainland.aliyuncs.com';
|
||||
if (!/^[a-z0-9][a-z0-9.-]{1,62}$/u.test(bucket) || /[\r\n\0]/u.test(endpoint)) {
|
||||
throw new Error('OSS bucket 或 endpoint 配置无效');
|
||||
}
|
||||
process.env.AGC_UPDATE_OSS_BASE_URL ||= `https://${bucket}.${endpoint}/agc`;
|
||||
const dryRun = readReleaseDryRun();
|
||||
|
||||
process.env.CARGO_TARGET_DIR = path.join(appRoot, 'src-tauri/target');
|
||||
// 单架构目标:清单侧 `resolveManifestPlatformKeys` 只为它登记 darwin-aarch64。
|
||||
const macTarget = 'aarch64-apple-darwin';
|
||||
const context = resolveReleaseContext([`--target=${macTarget}`]);
|
||||
const partition = resolveReleasePartition(context.channel, context.target);
|
||||
const productName = resolveProductName(context.channel);
|
||||
const appBundleName = `${productName}.app`;
|
||||
const updaterArtifactName = `${productName}.app.tar.gz`;
|
||||
const version = await prepareReleaseVersion(context);
|
||||
// 首装包名必须让清单侧的单架构分支唯一匹配:`<产品名>_<版本>_<架构>.dmg`,
|
||||
// 架构段用 Tauri 的 aarch64 口径(不是 updater 平台键的 arm64 / x86_64)。
|
||||
const firstInstallName = `${productName}_${version}_aarch64.dmg`;
|
||||
|
||||
// 幂等边界:workspace 会保留上一轮产物。先删掉本次将要写出的对象,否则
|
||||
// 1) hdiutil 会因同名 DMG 已存在直接失败(首次实跑即命中);
|
||||
// 2) 上一轮遗留的 `.sig` 会让验签门禁把「本轮其实没签」判成通过。
|
||||
// 只删本次要写出的确切路径,不动其它版本产物与编译缓存。
|
||||
const macosBundle = path.join(context.bundleRoot, 'macos');
|
||||
for (const stale of [
|
||||
path.join(macosBundle, updaterArtifactName),
|
||||
path.join(macosBundle, `${updaterArtifactName}.sig`),
|
||||
path.join(macosBundle, `${firstInstallName}`),
|
||||
path.join(macosBundle, `${firstInstallName}.sha256`),
|
||||
path.join(context.bundleRoot, 'latest.json'),
|
||||
path.join(context.bundleRoot, 'release-notes.txt'),
|
||||
]) {
|
||||
fs.rmSync(stale, { force: true });
|
||||
}
|
||||
|
||||
const args = [
|
||||
`--target=${macTarget}`,
|
||||
'--bundles',
|
||||
'app',
|
||||
'--ci',
|
||||
// 刻意不传 `--no-sign`:它会连带跳过 updater 签名,而客户端强制校验更新包签名。
|
||||
// Apple 侧改为剥离 APPLE_* 凭据,未配置身份时 Tauri 不签名也不失败。
|
||||
// 基础配置已开启;这里显式声明,避免被其它配置来源关掉后静默失去更新能力。
|
||||
'--config',
|
||||
'{"bundle":{"createUpdaterArtifacts":true}}',
|
||||
];
|
||||
const command = (binary, argv, options = {}) =>
|
||||
execFileSync(binary, argv, { cwd: repoRoot, stdio: 'inherit', ...options });
|
||||
runTauriBuild(args, context);
|
||||
|
||||
const app = path.join(context.bundleRoot, 'macos', appBundleName);
|
||||
command(process.execPath, [
|
||||
path.join(appRoot, 'scripts/check-macos-bundle.mjs'),
|
||||
app,
|
||||
'arm64',
|
||||
]);
|
||||
|
||||
// DMG 放在 bundle 根目录下:渠道清单的首装包选择会扫描该目录,命名必须匹配 `_<version>_aarch64.dmg`。
|
||||
const dmgDirectory = path.join(context.bundleRoot, 'macos');
|
||||
fs.mkdirSync(dmgDirectory, { recursive: true });
|
||||
const dmg = path.join(dmgDirectory, firstInstallName);
|
||||
const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-ci-dmg-'));
|
||||
try {
|
||||
command('ditto', [app, path.join(stage, appBundleName)]);
|
||||
fs.symlinkSync('/Applications', path.join(stage, 'Applications'));
|
||||
command('hdiutil', [
|
||||
'create',
|
||||
// 前面已删除同名对象;这里再要求显式覆盖,避免残留文件让构建以「文件已存在」失败。
|
||||
'-ov',
|
||||
'-volname',
|
||||
productName,
|
||||
'-srcfolder',
|
||||
stage,
|
||||
'-format',
|
||||
'UDZO',
|
||||
dmg,
|
||||
]);
|
||||
command('hdiutil', ['verify', dmg]);
|
||||
} finally {
|
||||
fs.rmSync(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const release = await generateUpdateManifest(context);
|
||||
assert.equal(
|
||||
path.resolve(release.downloadArtifact),
|
||||
path.resolve(dmg),
|
||||
'首装包必须锁定本次生成的 arm64 DMG',
|
||||
);
|
||||
|
||||
// 上传前门禁:用产物里烘焙的公钥复核更新包签名。验不过就停在这里,绝不写 OSS。
|
||||
const signature = verifyUpdaterSignature({
|
||||
artifactPath: release.artifact,
|
||||
signaturePath: `${release.artifact}.sig`,
|
||||
pubkey: readUpdaterPubkey(),
|
||||
});
|
||||
console.log(
|
||||
`[agc-macos] 更新包签名校验通过:alg=${signature.algorithm},keyId=${signature.keyId}`,
|
||||
);
|
||||
|
||||
const artifacts = path.join(repoRoot, 'artifacts');
|
||||
// 只清理本 Job 的归档输出,不能把上次 DMG 当成本次成功产物。
|
||||
fs.rmSync(artifacts, { recursive: true, force: true });
|
||||
fs.mkdirSync(artifacts, { recursive: true });
|
||||
const sha256 = (file) => {
|
||||
const hash = createHash('sha256');
|
||||
hash.update(fs.readFileSync(file));
|
||||
return hash.digest('hex');
|
||||
};
|
||||
const dmgHash = sha256(dmg);
|
||||
fs.writeFileSync(`${dmg}.sha256`, `${dmgHash} ${path.basename(dmg)}\n`);
|
||||
|
||||
const uploadPlan = uploadReleaseArtifacts(release, {
|
||||
bucket,
|
||||
endpoint,
|
||||
binary: process.env.OSSUTIL_BIN?.trim() || 'ossutil',
|
||||
accessKeyId: process.env.AGC_OSS_ACCESS_KEY_ID?.trim(),
|
||||
accessKeySecret: process.env.AGC_OSS_ACCESS_KEY_SECRET,
|
||||
dryRun,
|
||||
});
|
||||
|
||||
const archived = [
|
||||
dmg,
|
||||
`${dmg}.sha256`,
|
||||
release.manifestPath,
|
||||
release.notesPath,
|
||||
`${release.artifact}.sig`,
|
||||
];
|
||||
for (const file of archived) {
|
||||
fs.copyFileSync(file, path.join(artifacts, path.basename(file)));
|
||||
}
|
||||
|
||||
const commit = execFileSync('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
// Apple 签名状态必须实测:剥离 APPLE_* 后 Tauri 通常跳过签名,但节点若装了 Developer ID
|
||||
// 证书仍可能签上,硬编码 appleSigned=false 会把「其实签了」写成假事实。
|
||||
const signatureProbe = spawnSync('codesign', ['-dv', '--verbose=2', app], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const signatureText = `${signatureProbe.stdout ?? ''}${signatureProbe.stderr ?? ''}`;
|
||||
const appleSigned = /Authority=Developer ID Application/u.test(signatureText);
|
||||
const appleSignatureKind = appleSigned
|
||||
? 'developer-id'
|
||||
: /Signature=adhoc/u.test(signatureText)
|
||||
? 'adhoc'
|
||||
: 'unsigned';
|
||||
fs.writeFileSync(
|
||||
path.join(artifacts, 'build-manifest.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version,
|
||||
commit,
|
||||
target: context.target,
|
||||
channel: context.channel,
|
||||
// Apple 签名与公证暂缺:显式记录为未验证项,不静默通过。
|
||||
appleSigned,
|
||||
appleSignatureKind,
|
||||
notarized: false,
|
||||
dryRun,
|
||||
uploaded: !dryRun,
|
||||
updaterSignature: {
|
||||
algorithm: signature.algorithm,
|
||||
keyId: signature.keyId,
|
||||
verified: true,
|
||||
},
|
||||
oss: {
|
||||
bucket,
|
||||
endpoint,
|
||||
partition,
|
||||
latest: `oss://${bucket}/agc/${partition}/latest.json`,
|
||||
objects: uploadPlan.map(({ destination }) => destination),
|
||||
},
|
||||
artifacts: {
|
||||
updater: path.basename(release.artifact),
|
||||
updaterSha256: sha256(release.artifact),
|
||||
updaterBytes: fs.statSync(release.artifact).size,
|
||||
updaterSignature: path.basename(`${release.artifact}.sig`),
|
||||
firstInstall: path.basename(dmg),
|
||||
firstInstallSha256: dmgHash,
|
||||
manifest: 'latest.json',
|
||||
},
|
||||
// 单架构发布:只跑 arm64 隔离 smoke;Intel 未支持(清单里没有 darwin-x86_64 键)。
|
||||
smokes: ['arm64'],
|
||||
manifestPlatformKeys: resolveManifestPlatformKeys(context.target),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
console.log(
|
||||
dryRun
|
||||
? `[agc-macos] dry-run 完成:${partition} 分区产物与清单已生成,未写入 OSS`
|
||||
: `[agc-macos] ${partition} 分区更新包、签名、首装包与清单已上传 OSS`,
|
||||
);
|
||||
@@ -13,6 +13,12 @@ import {
|
||||
defaultEditorFeatures,
|
||||
withDefaultCargoFeatures,
|
||||
} from './cargo-features.mjs';
|
||||
import {
|
||||
resolveChannelInstallIdentity,
|
||||
resolveReleaseChannel,
|
||||
} from './channel-identity.mjs';
|
||||
import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs';
|
||||
import { stageNodeRuntime } from './stage-node-runtime.mjs';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
// 提交摘要里的 pathspec 与 `git log` 都以仓库根为基准,不能在应用目录里执行。
|
||||
@@ -46,16 +52,12 @@ function explicitBuildTarget(args) {
|
||||
}
|
||||
|
||||
function validateReleaseTarget(target) {
|
||||
if (target === 'universal-apple-darwin') {
|
||||
throw new Error(
|
||||
'内置 Codex 资源仅支持 macOS 单架构构建,请使用 aarch64-apple-darwin 或 x86_64-apple-darwin',
|
||||
);
|
||||
}
|
||||
if (
|
||||
![
|
||||
'x86_64-pc-windows-msvc',
|
||||
'aarch64-apple-darwin',
|
||||
'x86_64-apple-darwin',
|
||||
'universal-apple-darwin',
|
||||
].includes(target)
|
||||
) {
|
||||
throw new Error(`不支持的发布目标:${target}`);
|
||||
@@ -91,14 +93,7 @@ const cargoLockPath = path.join(appRoot, 'src-tauri', 'Cargo.lock');
|
||||
const defaultOssBaseUrl =
|
||||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc';
|
||||
|
||||
const reservedChannelNames = new Set([
|
||||
'win',
|
||||
'mac',
|
||||
'windows',
|
||||
'macos',
|
||||
'darwin',
|
||||
'linux',
|
||||
]);
|
||||
export { resolveReleaseChannel } from './channel-identity.mjs';
|
||||
|
||||
/**
|
||||
* 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须
|
||||
@@ -167,21 +162,6 @@ export function resolveReleasePlatform(target = defaultTarget()) {
|
||||
throw new Error(`不支持的发布目标:${target}`);
|
||||
}
|
||||
|
||||
export function resolveReleaseChannel(env = process.env) {
|
||||
const channel = env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev';
|
||||
if (
|
||||
!/^[a-z][a-z0-9-]{0,31}$/u.test(channel) ||
|
||||
channel.endsWith('-') ||
|
||||
reservedChannelNames.has(channel) ||
|
||||
/-(win|mac)$/u.test(channel)
|
||||
) {
|
||||
throw new Error(
|
||||
'发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道',
|
||||
);
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
/** 系统分区延续已发布客户端端点,渠道本身不包含系统。 */
|
||||
export function resolveReleasePartition(
|
||||
channel = resolveReleaseChannel(),
|
||||
@@ -200,10 +180,12 @@ export function updateManifestUrl(
|
||||
}
|
||||
|
||||
/**
|
||||
* 单架构产物只登记实际目标,不能把同一原生资源映射为另一架构。
|
||||
* universal 主程序与双目录原生资源共用一个更新包;单架构只登记实际目标。
|
||||
*/
|
||||
export function resolveManifestPlatformKeys(target = defaultTarget()) {
|
||||
validateReleaseTarget(target);
|
||||
if (target === 'universal-apple-darwin')
|
||||
return ['darwin-aarch64', 'darwin-x86_64'];
|
||||
if (target === 'aarch64-apple-darwin') return ['darwin-aarch64'];
|
||||
if (target === 'x86_64-apple-darwin') return ['darwin-x86_64'];
|
||||
if (target.includes('windows')) {
|
||||
@@ -428,12 +410,19 @@ export function buildTauriBuildArguments(
|
||||
];
|
||||
}
|
||||
|
||||
/** 渠道端点必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道。 */
|
||||
/**
|
||||
* 渠道端点与安装身份必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道,
|
||||
* 而 `productName` / `identifier` 决定安装目录、卸载项与客户端数据目录,
|
||||
* 不同渠道必须在同一台设备上并存而不是互相顶掉。
|
||||
*/
|
||||
export function createChannelConfig(
|
||||
channel = resolveReleaseChannel(),
|
||||
target = defaultTarget(),
|
||||
) {
|
||||
const { productName, identifier } = resolveChannelInstallIdentity(channel);
|
||||
return {
|
||||
productName,
|
||||
identifier,
|
||||
plugins: {
|
||||
updater: {
|
||||
endpoints: [updateManifestUrl(channel, target)],
|
||||
@@ -442,22 +431,25 @@ export function createChannelConfig(
|
||||
};
|
||||
}
|
||||
|
||||
function writeChannelConfigFile(channel, target) {
|
||||
function writeChannelConfigFile(channel, target, includeNodeRuntime = false) {
|
||||
const configPath = path.join(
|
||||
os.tmpdir(),
|
||||
`agc-tauri-channel-${channel}-${target}.json`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`${JSON.stringify(createChannelConfig(channel, target), null, 2)}\n`,
|
||||
);
|
||||
const config = createChannelConfig(channel, target);
|
||||
// 普通 cargo test/dev 不要求发行资源;只有完成 staging 的发行构建加入映射。
|
||||
if (includeNodeRuntime)
|
||||
config.bundle = {
|
||||
resources: { 'resources/node-runtime': 'game-runtime/node' },
|
||||
};
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
||||
return configPath;
|
||||
}
|
||||
|
||||
export function runTauriBuild(
|
||||
args = [],
|
||||
context = resolveReleaseContext(args),
|
||||
{ spawn = spawnSync } = {},
|
||||
{ spawn = spawnSync, stageRuntime = stageNodeRuntime } = {},
|
||||
) {
|
||||
if (
|
||||
explicitBuildTarget(args) &&
|
||||
@@ -467,7 +459,12 @@ export function runTauriBuild(
|
||||
}
|
||||
const tauriArguments = buildTauriBuildArguments(args, context.target);
|
||||
const { channel, target } = context;
|
||||
const configPath = writeChannelConfigFile(channel, target);
|
||||
if (!args.includes('--no-bundle')) stageRuntime(target);
|
||||
const configPath = writeChannelConfigFile(
|
||||
channel,
|
||||
target,
|
||||
!args.includes('--no-bundle'),
|
||||
);
|
||||
console.log(
|
||||
`[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`,
|
||||
);
|
||||
@@ -483,7 +480,17 @@ export function runTauriBuild(
|
||||
const result = spawn(
|
||||
npmCommand,
|
||||
['--prefix', '../..', 'exec', 'tauri', '--', ...tauriArguments],
|
||||
{ cwd: appRoot, stdio: 'inherit', shell: process.platform === 'win32' },
|
||||
{
|
||||
cwd: appRoot,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
env: {
|
||||
...process.env,
|
||||
// Vite embeds the platform API origin in the packaged renderer. The
|
||||
// release channel and updater channel therefore cannot drift apart.
|
||||
VITE_AGC_PLATFORM_CHANNEL: channel,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
@@ -543,6 +550,18 @@ export function selectFirstInstallArtifact(
|
||||
if (!selected?.endsWith('.exe')) {
|
||||
throw new Error('Windows 首装包必须复用本次 NSIS .exe 更新包');
|
||||
}
|
||||
} else if (target === 'universal-apple-darwin') {
|
||||
// universal 主程序只产出一个 DMG,aarch64 与 x86_64 首装共用它(命名见 build-macos-ci.mjs)。
|
||||
const suffix = `_${version}_universal.dmg`;
|
||||
const candidates = files.filter((file) =>
|
||||
path.basename(file).endsWith(suffix),
|
||||
);
|
||||
if (candidates.length !== 1) {
|
||||
throw new Error(
|
||||
`首装 DMG 必须唯一匹配本次版本 ${version} 的 universal 产物,找到 ${candidates.length} 个`,
|
||||
);
|
||||
}
|
||||
selected = candidates[0];
|
||||
} else {
|
||||
// Tauri DMG 文件名使用 aarch64 / x64,而 updater 的 Intel 平台键是 x86_64。
|
||||
const architecture = target.startsWith('aarch64') ? 'aarch64' : 'x64';
|
||||
@@ -848,14 +867,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 (
|
||||
|
||||
@@ -37,6 +37,11 @@ import {
|
||||
selectReleaseArtifact,
|
||||
updateManifestUrl,
|
||||
} from './build-release.mjs';
|
||||
import {
|
||||
AGC_APP_IDENTIFIER,
|
||||
AGC_PRODUCT_NAME,
|
||||
resolveChannelInstallIdentity,
|
||||
} from './channel-identity.mjs';
|
||||
|
||||
const windowsTarget = 'x86_64-pc-windows-msvc';
|
||||
const universalTarget = 'universal-apple-darwin';
|
||||
@@ -45,19 +50,22 @@ const packageVersion = JSON.parse(
|
||||
).version;
|
||||
|
||||
function createDmgFixture(root, target, version = packageVersion) {
|
||||
const architecture = target.startsWith('aarch64') ? 'aarch64' : 'x64';
|
||||
const architecture = target.startsWith('aarch64')
|
||||
? 'aarch64'
|
||||
: target === universalTarget
|
||||
? 'universal'
|
||||
: 'x64';
|
||||
const dmg = path.join(root, `陶泥儿_${version}_${architecture}.dmg`);
|
||||
writeFileSync(dmg, 'first installation disk image');
|
||||
return dmg;
|
||||
}
|
||||
|
||||
test('native sidecar builds reject universal targets and accept each macOS architecture', () => {
|
||||
assert.throws(() => buildTauriBuildArguments([], universalTarget), /单架构/);
|
||||
assert.throws(
|
||||
() => buildTauriBuildArguments(['--target=universal-apple-darwin']),
|
||||
/单架构/,
|
||||
);
|
||||
for (const target of ['aarch64-apple-darwin', 'x86_64-apple-darwin']) {
|
||||
test('native sidecar builds accept universal and each macOS architecture', () => {
|
||||
for (const target of [
|
||||
universalTarget,
|
||||
'aarch64-apple-darwin',
|
||||
'x86_64-apple-darwin',
|
||||
]) {
|
||||
assert.deepEqual(buildTauriBuildArguments([], target), [
|
||||
'build',
|
||||
'--target',
|
||||
@@ -181,6 +189,8 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
|
||||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json',
|
||||
);
|
||||
assert.deepEqual(createChannelConfig('dev', 'aarch64-apple-darwin'), {
|
||||
productName: AGC_PRODUCT_NAME,
|
||||
identifier: AGC_APP_IDENTIFIER,
|
||||
plugins: {
|
||||
updater: {
|
||||
endpoints: [
|
||||
@@ -201,8 +211,91 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('macOS manifests only advertise the architecture actually built', () => {
|
||||
assert.throws(() => resolveManifestPlatformKeys(universalTarget), /单架构/);
|
||||
test('channel install identity isolates co-installed builds and keeps the default channel stable', () => {
|
||||
// 默认渠道必须保持已发布客户端身份:改身份等于换一个 App,升级链会断。
|
||||
assert.deepEqual(resolveChannelInstallIdentity('dev'), {
|
||||
productName: AGC_PRODUCT_NAME,
|
||||
identifier: AGC_APP_IDENTIFIER,
|
||||
});
|
||||
assert.deepEqual(resolveChannelInstallIdentity('release'), {
|
||||
productName: '陶泥儿 Release',
|
||||
identifier: `${AGC_APP_IDENTIFIER}.release`,
|
||||
});
|
||||
assert.deepEqual(resolveChannelInstallIdentity('beta-2'), {
|
||||
productName: '陶泥儿 Beta-2',
|
||||
identifier: `${AGC_APP_IDENTIFIER}.beta-2`,
|
||||
});
|
||||
|
||||
// 同一台设备上不同渠道的安装目录、卸载项与数据目录必须互不相同。
|
||||
for (const channel of ['release', 'beta-2', 'a'.repeat(32)]) {
|
||||
const identity = resolveChannelInstallIdentity(channel);
|
||||
assert.notEqual(identity.productName, AGC_PRODUCT_NAME);
|
||||
assert.notEqual(identity.identifier, AGC_APP_IDENTIFIER);
|
||||
assert.ok(identity.identifier.startsWith(`${AGC_APP_IDENTIFIER}.`));
|
||||
}
|
||||
|
||||
for (const channel of ['dev-win', 'Release', 'win', 'beta-']) {
|
||||
assert.throws(
|
||||
() => resolveChannelInstallIdentity(channel),
|
||||
/发布渠道无效/u,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('channel install identity is baked into the same build-time config as the endpoint', () => {
|
||||
withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => {
|
||||
const config = createChannelConfig('release', windowsTarget);
|
||||
assert.equal(config.productName, '陶泥儿 Release');
|
||||
assert.equal(config.identifier, `${AGC_APP_IDENTIFIER}.release`);
|
||||
assert.match(
|
||||
config.plugins.updater.endpoints[0],
|
||||
/\/release-win\/latest\.json$/u,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('channel products keep first-install selection working under the channel product name', () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-channel-dmg-'));
|
||||
try {
|
||||
const { productName } = resolveChannelInstallIdentity('release');
|
||||
const dmg = path.join(root, `${productName}_${packageVersion}_aarch64.dmg`);
|
||||
writeFileSync(dmg, 'channel first installation disk image');
|
||||
writeFileSync(path.join(root, 'windows.exe'), 'wrong platform');
|
||||
assert.equal(
|
||||
selectFirstInstallArtifact([dmg, path.join(root, 'windows.exe')], {
|
||||
target: 'aarch64-apple-darwin',
|
||||
version: packageVersion,
|
||||
}),
|
||||
dmg,
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('packaged renderer receives the same channel as the updater manifest', () => {
|
||||
const context = resolveReleaseContext([], {
|
||||
AGC_BUILD_TARGET: windowsTarget,
|
||||
AGC_UPDATE_CHANNEL: 'release',
|
||||
});
|
||||
let spawnOptions;
|
||||
runTauriBuild([], context, {
|
||||
// 必须 stub:真实 staging 会用宿主平台(如 macOS 的 darwin/arm64)去对默认的
|
||||
// Windows 目标做一致性校验,在非 Windows 主机上直接失败——本用例只关心渠道注入。
|
||||
stageRuntime: () => {},
|
||||
spawn: (_binary, _args, options) => {
|
||||
spawnOptions = options;
|
||||
return { status: 0 };
|
||||
},
|
||||
});
|
||||
assert.equal(spawnOptions?.env?.VITE_AGC_PLATFORM_CHANNEL, 'release');
|
||||
});
|
||||
|
||||
test('macOS manifests advertise exactly the architectures actually built', () => {
|
||||
assert.deepEqual(resolveManifestPlatformKeys(universalTarget), [
|
||||
'darwin-aarch64',
|
||||
'darwin-x86_64',
|
||||
]);
|
||||
assert.deepEqual(resolveManifestPlatformKeys('aarch64-apple-darwin'), [
|
||||
'darwin-aarch64',
|
||||
]);
|
||||
@@ -246,7 +339,6 @@ test('release context resolves explicit targets before environment/default and f
|
||||
['--target='],
|
||||
['--target', '--no-bundle'],
|
||||
['--target', windowsTarget, '--target=aarch64-apple-darwin'],
|
||||
['--target', universalTarget],
|
||||
['--target', 'unknown'],
|
||||
])
|
||||
assert.throws(() => resolveReleaseContext(args, {}));
|
||||
@@ -279,6 +371,7 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and
|
||||
build: (args, context) => {
|
||||
seenContexts.push(context);
|
||||
runTauriBuild(args, context, {
|
||||
stageRuntime: () => {},
|
||||
spawn: (_binary, command) => {
|
||||
const configIndex = command.lastIndexOf('--config');
|
||||
const config = JSON.parse(
|
||||
@@ -461,8 +554,8 @@ test('invalid target or platform used as channel fails before any release side e
|
||||
},
|
||||
};
|
||||
await assert.rejects(
|
||||
() => buildRelease(['--target', universalTarget], sideEffects),
|
||||
/单架构/,
|
||||
() => buildRelease(['--target', 'unknown'], sideEffects),
|
||||
/不支持的发布目标/,
|
||||
);
|
||||
await withEnv({ AGC_UPDATE_CHANNEL: 'dev-win' }, () =>
|
||||
assert.rejects(
|
||||
@@ -473,6 +566,43 @@ test('invalid target or platform used as channel fails before any release side e
|
||||
assert.equal(touched, false);
|
||||
});
|
||||
|
||||
test('universal uses the Mac channel and the same signed artifact for both architectures', () => {
|
||||
const context = resolveReleaseContext(['--target', universalTarget], {
|
||||
AGC_BUILD_TARGET: windowsTarget,
|
||||
});
|
||||
// 渠道本身不含系统:分区由渠道 + 目标推导,二者不能混为一谈。
|
||||
assert.equal(context.channel, 'dev');
|
||||
assert.equal(
|
||||
resolveReleasePartition(context.channel, context.target),
|
||||
'dev-mac',
|
||||
);
|
||||
assert.ok(context.bundleRoot.includes(universalTarget));
|
||||
withSignedArtifact('陶泥儿.app.tar.gz', (artifact) => {
|
||||
const manifest = createUpdateManifest(artifact, {
|
||||
...context,
|
||||
downloadArtifact: createDmgFixture(
|
||||
path.dirname(artifact),
|
||||
universalTarget,
|
||||
),
|
||||
});
|
||||
assert.deepEqual(Object.keys(manifest.platforms), [
|
||||
'darwin-aarch64',
|
||||
'darwin-x86_64',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
manifest.platforms['darwin-aarch64'],
|
||||
manifest.platforms['darwin-x86_64'],
|
||||
);
|
||||
assert.match(manifest.platforms['darwin-aarch64'].url, /\/dev-mac\//);
|
||||
// 两个平台键共用同一个 universal 首装包,不能要求出两份架构 DMG。
|
||||
assert.deepEqual(
|
||||
manifest.downloads['darwin-aarch64'].url,
|
||||
manifest.downloads['darwin-x86_64'].url,
|
||||
);
|
||||
assert.match(manifest.downloads['darwin-aarch64'].url, /_universal\.dmg$/u);
|
||||
});
|
||||
});
|
||||
|
||||
test('Windows remains the default and explicit Windows overrides macOS environment', () => {
|
||||
const files = ['/tmp/mac.app.tar.gz', '/tmp/windows.exe', '/tmp/mac.dmg'];
|
||||
for (const context of [
|
||||
@@ -490,6 +620,7 @@ test('Windows remains the default and explicit Windows overrides macOS environme
|
||||
['--target', windowsTarget, '--config', 'user-config.json'],
|
||||
context,
|
||||
{
|
||||
stageRuntime: () => {},
|
||||
spawn: (_binary, command) => {
|
||||
assert.ok(
|
||||
command.includes(
|
||||
@@ -528,6 +659,117 @@ 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 = [];
|
||||
runTauriBuild(['--target', windowsTarget], context, {
|
||||
stageRuntime(target) {
|
||||
assert.equal(target, windowsTarget);
|
||||
events.push('stage');
|
||||
},
|
||||
spawn(_binary, args) {
|
||||
events.push('build');
|
||||
const config = JSON.parse(
|
||||
readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'),
|
||||
);
|
||||
assert.deepEqual(config.bundle.resources, {
|
||||
'resources/node-runtime': 'game-runtime/node',
|
||||
});
|
||||
return { status: 0 };
|
||||
},
|
||||
});
|
||||
assert.deepEqual(events, ['stage', 'build']);
|
||||
runTauriBuild(['--no-bundle', '--target', windowsTarget], context, {
|
||||
stageRuntime() {
|
||||
assert.fail('no-bundle must not stage resources');
|
||||
},
|
||||
spawn(_binary, args) {
|
||||
const config = JSON.parse(
|
||||
readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'),
|
||||
);
|
||||
assert.equal(config.bundle, undefined);
|
||||
return { status: 0 };
|
||||
},
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
runTauriBuild(['--target', windowsTarget], context, {
|
||||
stageRuntime() {
|
||||
throw new Error('missing runtime');
|
||||
},
|
||||
spawn() {
|
||||
assert.fail('invalid runtime must prevent build');
|
||||
},
|
||||
}),
|
||||
/missing runtime/,
|
||||
);
|
||||
});
|
||||
|
||||
test('channel manifest carries version, platform keys and signature', () => {
|
||||
withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => {
|
||||
withEnv({ AGC_UPDATE_RELEASE_NOTES: '修复与改进' }, () => {
|
||||
@@ -631,6 +873,7 @@ for (const channel of ['release', 'beta-2']) {
|
||||
'2.3.4',
|
||||
);
|
||||
runTauriBuild([`--target=${target}`], context, {
|
||||
stageRuntime: () => {},
|
||||
spawn: (_binary, command) => {
|
||||
const config = JSON.parse(
|
||||
readFileSync(
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* AGC 渠道 → 安装身份。
|
||||
*
|
||||
* 渠道同时决定两件事:
|
||||
* - 更新端点:OSS 分区 `<channel>-win` / `<channel>-mac` 的清单地址;
|
||||
* - 安装身份:`productName` 与 `identifier`。
|
||||
*
|
||||
* 安装身份决定 Windows 安装目录与卸载项、macOS `.app` 名字与 bundle id、
|
||||
* Windows WebView2 数据目录以及 `%APPDATA%\<identifier>` 客户端数据目录。
|
||||
* 因此不同渠道的包体在同一台设备上并存时互不顶掉,也不会共享登录态、
|
||||
* 本地项目与运行锁。
|
||||
*
|
||||
* 默认渠道 `dev` 保持已发布客户端身份不变:升级链路与既有安装不能断。
|
||||
*/
|
||||
|
||||
export const AGC_DEFAULT_CHANNEL = 'dev';
|
||||
export const AGC_PRODUCT_NAME = '陶泥儿';
|
||||
export const AGC_APP_IDENTIFIER = 'world.genarrative.ai-game-creator';
|
||||
|
||||
const reservedChannelNames = new Set([
|
||||
'win',
|
||||
'mac',
|
||||
'windows',
|
||||
'macos',
|
||||
'darwin',
|
||||
'linux',
|
||||
]);
|
||||
|
||||
/** 校验渠道名:小写字母开头,允许数字与连字符,系统名不属于渠道。 */
|
||||
export function validateReleaseChannel(channel) {
|
||||
if (
|
||||
typeof channel !== 'string' ||
|
||||
!/^[a-z][a-z0-9-]{0,31}$/u.test(channel) ||
|
||||
channel.endsWith('-') ||
|
||||
reservedChannelNames.has(channel) ||
|
||||
/-(win|mac)$/u.test(channel)
|
||||
) {
|
||||
throw new Error(
|
||||
'发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道',
|
||||
);
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
export function resolveReleaseChannel(env = process.env) {
|
||||
return validateReleaseChannel(env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev');
|
||||
}
|
||||
|
||||
/** 安装身份里的展示后缀:`release` → `Release`,`beta-2` → `Beta-2`。 */
|
||||
export function channelDisplaySuffix(channel) {
|
||||
return validateReleaseChannel(channel)
|
||||
.split('-')
|
||||
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
||||
.join('-');
|
||||
}
|
||||
|
||||
/**
|
||||
* 渠道对应的安装身份。默认渠道返回基线身份,其它渠道派生渠道后缀,
|
||||
* 保证同一台设备上不同渠道互不覆盖。
|
||||
*/
|
||||
export function resolveChannelInstallIdentity(channel = AGC_DEFAULT_CHANNEL) {
|
||||
validateReleaseChannel(channel);
|
||||
if (channel === AGC_DEFAULT_CHANNEL) {
|
||||
return Object.freeze({
|
||||
productName: AGC_PRODUCT_NAME,
|
||||
identifier: AGC_APP_IDENTIFIER,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
productName: `${AGC_PRODUCT_NAME} ${channelDisplaySuffix(channel)}`,
|
||||
identifier: `${AGC_APP_IDENTIFIER}.${channel}`,
|
||||
});
|
||||
}
|
||||
@@ -4,13 +4,34 @@ import { EventEmitter } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import ts from 'typescript';
|
||||
|
||||
// 固定解析源码保留上游测试中的替换字符;必须同时核对原始字节与许可。
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
'--test',
|
||||
fileURLToPath(
|
||||
new URL(
|
||||
'../src-tauri/vendor/codex-patch-parser/upstream-integrity.test.mjs',
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
|
||||
import {
|
||||
appIdentifier,
|
||||
defaultRealSwarmTestTask,
|
||||
} from './agent-swarm-test-chat.mjs';
|
||||
import {
|
||||
AGC_APP_IDENTIFIER,
|
||||
AGC_PRODUCT_NAME,
|
||||
resolveChannelInstallIdentity,
|
||||
} from './channel-identity.mjs';
|
||||
import {
|
||||
askHidden,
|
||||
assertSafeGameCreatorConfigDestination,
|
||||
@@ -86,10 +107,6 @@ const appInvokeSources = readSourceFiles(
|
||||
new URL('../src/', import.meta.url),
|
||||
new Set(['.ts', '.tsx']),
|
||||
);
|
||||
const appEntrypointSource = fs.readFileSync(
|
||||
new URL('../src/main.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const tauriHandlerSource = fs.readFileSync(
|
||||
new URL('../src-tauri/src/main.rs', import.meta.url),
|
||||
'utf8',
|
||||
@@ -114,6 +131,45 @@ const rustSharedContractSource = fs.readFileSync(
|
||||
);
|
||||
const allowedUncalledTauriCommands = [
|
||||
'append_direct_project_conversation_message',
|
||||
// Supervisor 调试窗口、开发者面板、专业 Agent 对话与旧命令聊天的前端调用方已随
|
||||
// Supervisor 前端链路整体删除;命令本身仍注册在 Rust 侧并由 native Runtime、CLI
|
||||
// swarm 与 Rust 测试使用,保留 present,仅不再出现在 App 前端源码里。
|
||||
'answer_game_creator_agent_runtime_user_input',
|
||||
'cancel_game_creator_agent_runtime_task',
|
||||
'chat_with_game_creator_role_agent',
|
||||
'chat_with_game_creator_role_agent_stream',
|
||||
'check_game_creator_llm_config',
|
||||
'confirm_game_creator_agent_runtime_task',
|
||||
'diff_local_project_checkpoint',
|
||||
'get_game_creation_agent_capabilities',
|
||||
'get_limited_local_commands',
|
||||
'list_local_project_export_packages',
|
||||
'read_game_creator_agent_runtime',
|
||||
'read_local_agent_memory',
|
||||
'read_local_game_memory',
|
||||
'reject_game_creator_agent_runtime_task',
|
||||
'retry_game_creator_agent_runtime_task',
|
||||
'schedule_game_creator_agent_ready_tasks',
|
||||
'start_game_creator_agent_runtime_task',
|
||||
'steer_game_creator_agent_runtime_task',
|
||||
'write_local_agent_memory',
|
||||
'write_local_game_memory',
|
||||
'write_local_project_file',
|
||||
// Agent 运行时会话 / 目标 / 协作命令由 native 侧与 CLI swarm 驱动,前端没有调用方。
|
||||
'archive_game_creator_agent_session',
|
||||
'clear_game_creator_agent_goal',
|
||||
'compact_game_creator_agent_runtime_context',
|
||||
'confirm_retry_game_creator_agent_runtime_task',
|
||||
'create_game_creator_agent_session',
|
||||
'edit_game_creator_agent_goal',
|
||||
'fork_game_creator_agent_session',
|
||||
'list_game_creator_agent_sessions',
|
||||
'pause_game_creator_agent_goal',
|
||||
'read_game_creator_agent_goal',
|
||||
'resume_game_creator_agent_goal',
|
||||
'set_active_game_creator_agent_session',
|
||||
'start_game_creator_agent_goal',
|
||||
'start_game_creator_supervisor_runtime_task',
|
||||
// TODO: Remove the retired binding command after the legacy runtime path is removed.
|
||||
'bind_components',
|
||||
'chat_with_game_creator_agent',
|
||||
@@ -143,6 +199,27 @@ const allowedUncalledTauriCommands = [
|
||||
'call_agc_plugin',
|
||||
'read_agc_plugin_panel',
|
||||
'set_agc_plugin_enabled',
|
||||
// 下面这些命令的调用方只有随 Project Supervisor 前端链路一起删除的旧命令聊天入口;
|
||||
// 现在 App 前端、工作台与策划聊天都没有接线(检查点 / 恢复 / 索引 /
|
||||
// 画板同步 / 素材登记 / 权限策略 / 本地草案 / 平台美术),Rust 侧只剩注册与实现,
|
||||
// `*_at` helper 仍由 Rust 用例覆盖。接回新入口还是删除属于 native 能力取舍,先按
|
||||
// native-only 登记,避免孤儿检查一直报错。
|
||||
// 预览不在本清单:`activate_local_game_preview` 已按 ADR 回接到 App 的「运行」入口。
|
||||
// 导出试玩包同样不在本清单:发布链路(`requestGamePublish`)已把它接回
|
||||
// DirectProject 聊天头的「发布」入口。
|
||||
'build_local_project_index',
|
||||
'control_agent_run',
|
||||
'create_local_project_checkpoint',
|
||||
'generate_local_game_draft',
|
||||
'generate_platform_art_asset',
|
||||
'import_canvas_asset',
|
||||
'import_canvas_export',
|
||||
'open_canvas_project',
|
||||
'register_local_asset',
|
||||
'restore_local_project_checkpoint',
|
||||
'run_limited_local_command',
|
||||
'sync_canvas_project_assets',
|
||||
'write_project_permission_policy',
|
||||
];
|
||||
const sourceExtensions = new Set([
|
||||
'.json',
|
||||
@@ -1292,7 +1369,8 @@ if (
|
||||
}
|
||||
|
||||
for (const requiredSource of [
|
||||
"export const appIdentifier = 'world.genarrative.ai-game-creator'",
|
||||
"import { AGC_APP_IDENTIFIER } from './channel-identity.mjs'",
|
||||
'export const appIdentifier = AGC_APP_IDENTIFIER',
|
||||
"'--swarm-chat'",
|
||||
"'--autonomous-game-build'",
|
||||
"'--preview-serve'",
|
||||
@@ -1303,16 +1381,52 @@ for (const requiredSource of [
|
||||
}
|
||||
}
|
||||
|
||||
if (tauriConfig.productName !== '陶泥儿') {
|
||||
// 基线配置必须等于默认渠道的安装身份:默认渠道不能改身份,否则已发布客户端
|
||||
// 的升级链路与既有安装目录都会断开。
|
||||
const defaultChannelIdentity = resolveChannelInstallIdentity('dev');
|
||||
if (tauriConfig.productName !== AGC_PRODUCT_NAME) {
|
||||
throw new Error('AI game creator shell productName drifted');
|
||||
}
|
||||
|
||||
if (tauriConfig.identifier !== 'world.genarrative.ai-game-creator') {
|
||||
if (tauriConfig.identifier !== AGC_APP_IDENTIFIER) {
|
||||
throw new Error('AI game creator shell identifier drifted');
|
||||
}
|
||||
|
||||
if (
|
||||
tauriConfig.productName !== defaultChannelIdentity.productName ||
|
||||
tauriConfig.identifier !== defaultChannelIdentity.identifier
|
||||
) {
|
||||
throw new Error(
|
||||
'AI game creator shell baseline config must match the default channel identity',
|
||||
);
|
||||
}
|
||||
|
||||
// 非默认渠道必须派生出独立安装身份,否则同机安装会互相顶掉。
|
||||
for (const channel of ['release', 'beta-2']) {
|
||||
const identity = resolveChannelInstallIdentity(channel);
|
||||
if (
|
||||
identity.productName === defaultChannelIdentity.productName ||
|
||||
identity.identifier === defaultChannelIdentity.identifier ||
|
||||
!identity.identifier.startsWith(`${AGC_APP_IDENTIFIER}.`)
|
||||
) {
|
||||
throw new Error(`channel install identity not isolated: ${channel}`);
|
||||
}
|
||||
}
|
||||
|
||||
const expectedBundledDesignAgentResources = {
|
||||
'design-agent': 'design-agent',
|
||||
...Object.fromEntries(
|
||||
[
|
||||
'codex-patch-parser',
|
||||
'codex-utils-path-uri',
|
||||
'codex-utils-absolute-path',
|
||||
].flatMap((name) =>
|
||||
['LICENSE', 'NOTICE'].map((file) => [
|
||||
`vendor/${name}/${file}`,
|
||||
`licenses/${name}/${file}`,
|
||||
]),
|
||||
),
|
||||
),
|
||||
};
|
||||
const expectedBundledWindowsResources = {
|
||||
'resources/codex/win-x64/bin/codex.exe': 'coding-agent/win-x64/bin/codex.exe',
|
||||
@@ -1336,7 +1450,7 @@ assert.deepEqual(
|
||||
'AI game creator shell base Tauri config must bundle the design-agent resource pack',
|
||||
);
|
||||
for (const key of Object.keys(tauriConfig.bundle?.resources ?? {})) {
|
||||
if (String(key).includes('codex')) {
|
||||
if (String(key).startsWith('resources/codex/')) {
|
||||
throw new Error(
|
||||
'AI game creator shell base Tauri config must not require Windows-only Codex resources',
|
||||
);
|
||||
@@ -1365,18 +1479,20 @@ if (windowsTauriConfig.bundle?.useLocalToolsDir !== true) {
|
||||
assert.deepEqual(
|
||||
macosTauriConfig.bundle?.resources,
|
||||
Object.fromEntries([
|
||||
...[
|
||||
'bin/codex',
|
||||
'bin/codex-code-mode-host',
|
||||
'codex-path/rg',
|
||||
'codex-resources/zsh/bin/zsh',
|
||||
'codex-package.json',
|
||||
'NOTICE.md',
|
||||
'manifest.json',
|
||||
].map((file) => [
|
||||
`resources/codex/mac-native/${file}`,
|
||||
`coding-agent/mac-native/${file}`,
|
||||
]),
|
||||
...['darwin-arm64', 'darwin-x64'].flatMap((arch) =>
|
||||
[
|
||||
'bin/codex',
|
||||
'bin/codex-code-mode-host',
|
||||
'codex-path/rg',
|
||||
'codex-resources/zsh/bin/zsh',
|
||||
'codex-package.json',
|
||||
'NOTICE.md',
|
||||
'manifest.json',
|
||||
].map((file) => [
|
||||
`resources/codex/mac-native/${arch}/${file}`,
|
||||
`coding-agent/mac-native/${arch}/${file}`,
|
||||
]),
|
||||
),
|
||||
['resources/plugins', 'plugins'],
|
||||
]),
|
||||
'macOS must bundle the complete native Codex layout and plugin workspace',
|
||||
@@ -1410,13 +1526,7 @@ const eventCapability = JSON.parse(
|
||||
);
|
||||
const eventCapabilityWindows = new Set(eventCapability.windows ?? []);
|
||||
const eventCapabilityPermissions = new Set(eventCapability.permissions ?? []);
|
||||
for (const windowLabel of [
|
||||
'client',
|
||||
'developer',
|
||||
'main',
|
||||
'launcher',
|
||||
'supervisor-chat',
|
||||
]) {
|
||||
for (const windowLabel of ['client', 'main', 'launcher']) {
|
||||
if (!eventCapabilityWindows.has(windowLabel)) {
|
||||
throw new Error(
|
||||
`AI game creator shell event capability missing window: ${windowLabel}`,
|
||||
@@ -1799,20 +1909,6 @@ if (
|
||||
);
|
||||
}
|
||||
|
||||
for (const snippet of [
|
||||
'import.meta.env.DEV',
|
||||
'supervisorChatMode',
|
||||
'supervisorChatOnly',
|
||||
'open_project_supervisor_chat_window',
|
||||
'index.html?supervisor-chat&projectPath=',
|
||||
]) {
|
||||
if (!`${appEntrypointSource}\n${tauriRustSource}`.includes(snippet)) {
|
||||
throw new Error(
|
||||
`AI game creator shell developer window guardrail drifted: ${snippet}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (tauriHandlerSource.includes('open_developer_window(app.handle())?')) {
|
||||
throw new Error(
|
||||
'AI game creator normal startup must not automatically open the developer window',
|
||||
@@ -1845,31 +1941,17 @@ for (const snippet of [
|
||||
'官方账号服务(固定)',
|
||||
'runtime_config.save',
|
||||
"'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'",
|
||||
"'activate_local_game_preview'",
|
||||
'已切换到客户端运行视图',
|
||||
'async function executeRunLocal',
|
||||
'function needsInitializedChatProject',
|
||||
'function resolvePendingCommandProjectPath',
|
||||
'resolveChatProjectPath(localProject) ?? draftProjectPath',
|
||||
'`permission.cancel ${command.id} missing-project`',
|
||||
"'/remember [short|long|blackboard] 内容:追加短期、长期或黑板记忆'",
|
||||
"'/memory-set [short|long|blackboard] 内容:覆盖保存对应记忆'",
|
||||
'function parseRememberInput',
|
||||
"'/trace 或 /loop:查看最近一次 Agent loop trace'",
|
||||
'async function executeAgentTraceChat',
|
||||
"relativePath: '.agent/logs/command.log'",
|
||||
"'permission.pending'",
|
||||
"'permission.confirm'",
|
||||
"'permission.cancel'",
|
||||
"'command.auto'",
|
||||
"'agent.run_status'",
|
||||
'function summarizeAgentRunTrace',
|
||||
'工具调用:${agentRunTrace.toolCallCount}/${agentRunTrace.maxToolCalls}',
|
||||
'agentRunTrace.error ?',
|
||||
'className="trace-error"',
|
||||
'agentRunTrace.taskGraph.repairRoutes.map',
|
||||
"in: ${step.inputPaths.join(', ') || 'none'}",
|
||||
"out: ${step.outputPaths.join(', ') || 'none'}",
|
||||
]) {
|
||||
if (!appSource.includes(snippet)) {
|
||||
throw new Error(
|
||||
|
||||
@@ -8,6 +8,13 @@ import path from 'node:path';
|
||||
// 只操作临时复制品;不启动 GUI、不读取开发机凭据、不访问 Provider。
|
||||
assert.equal(process.platform, 'darwin', '此验证必须在 macOS 执行');
|
||||
const source = path.resolve(process.argv[2] || '');
|
||||
const architecture =
|
||||
process.argv[3] || (process.arch === 'arm64' ? 'arm64' : 'x86_64');
|
||||
assert.ok(
|
||||
['arm64', 'x86_64'].includes(architecture),
|
||||
'架构只接受 arm64 / x86_64',
|
||||
);
|
||||
const requireUniversal = process.argv.includes('--universal');
|
||||
assert.ok(
|
||||
source.endsWith('.app') && fs.statSync(source).isDirectory(),
|
||||
'请传入 .app 绝对路径',
|
||||
@@ -15,7 +22,28 @@ assert.ok(
|
||||
const root = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'agc-macos-bundle-')),
|
||||
);
|
||||
const app = path.join(root, '陶泥儿 隔离测试.app');
|
||||
// 产品名从传入的 .app 推导,不在校验脚本里写死;改名后校验对象仍指向同一个包。
|
||||
const appBundleName = path.basename(source);
|
||||
const app = path.join(root, `隔离-${appBundleName}`);
|
||||
// 侧车清单版本必须等于锁定的 @openai/codex 版本,避免两处固定版本漂移。
|
||||
const appPackage = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(
|
||||
path.dirname(new URL(import.meta.url).pathname),
|
||||
'../package.json',
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
const pinnedCodexVersion =
|
||||
appPackage.dependencies?.['@openai/codex'] ??
|
||||
appPackage.devDependencies?.['@openai/codex'] ??
|
||||
appPackage.optionalDependencies?.['@openai/codex'];
|
||||
assert.match(
|
||||
pinnedCodexVersion,
|
||||
/^\d+\.\d+\.\d+$/u,
|
||||
'package.json 必须锁定精确的 @openai/codex 版本',
|
||||
);
|
||||
const home = path.join(root, 'home');
|
||||
const config = path.join(root, 'config');
|
||||
const tmp = path.join(root, 'tmp');
|
||||
@@ -31,17 +59,58 @@ const env = {
|
||||
};
|
||||
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: root,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
// 只强制被测应用切片;本机 Xcode 检查工具可能仅提供宿主架构。
|
||||
const useSlice = command.startsWith(`${app}${path.sep}`);
|
||||
const result = spawnSync(
|
||||
useSlice ? '/usr/bin/arch' : command,
|
||||
useSlice ? [`-${architecture}`, command, ...args] : args,
|
||||
{
|
||||
cwd: root,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
timeout: 120_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
},
|
||||
);
|
||||
assert.ifError(result.error);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* APFS 上优先用 `ditto --clone`:整包按区块克隆,秒级完成且几乎不占额外空间。
|
||||
* 跨卷或非 APFS 时回退到真实复制;两种路径都必须产出可独立改动的副本,
|
||||
* 因为「缺组件拒绝」用例会在副本里改名文件。
|
||||
*/
|
||||
function copyBundle(from, to) {
|
||||
const cloned = spawnSync('/usr/bin/ditto', ['--clone', from, to], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
if (
|
||||
cloned.status === 0 &&
|
||||
fs.existsSync(path.join(to, 'Contents/Info.plist'))
|
||||
) {
|
||||
return 'clone';
|
||||
}
|
||||
fs.cpSync(from, to, { recursive: true });
|
||||
return 'copy';
|
||||
}
|
||||
|
||||
/** 可执行名以包内 Info.plist 为准:它是稳定契约,但没必要在校验脚本里重复硬编码。 */
|
||||
function readBundleExecutable(appPath) {
|
||||
const plist = path.join(appPath, 'Contents/Info.plist');
|
||||
const result = spawnSync(
|
||||
'/usr/libexec/PlistBuddy',
|
||||
['-c', 'Print :CFBundleExecutable', plist],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
const name = (result.stdout ?? '').trim();
|
||||
assert.ok(
|
||||
name.length > 0,
|
||||
`无法从 Info.plist 读取 CFBundleExecutable:${plist}`,
|
||||
);
|
||||
return name;
|
||||
}
|
||||
|
||||
async function hashFile(file) {
|
||||
const hash = createHash('sha256');
|
||||
for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
|
||||
@@ -60,7 +129,7 @@ async function handshake(executable) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error('app-server 初始化超时')),
|
||||
15_000,
|
||||
120_000,
|
||||
);
|
||||
const finish = (error) => {
|
||||
clearTimeout(timer);
|
||||
@@ -127,23 +196,39 @@ async function handshake(executable) {
|
||||
}
|
||||
|
||||
try {
|
||||
fs.cpSync(source, app, { recursive: true });
|
||||
const copiedWith = copyBundle(source, app);
|
||||
const resources = path.join(app, 'Contents/Resources');
|
||||
const bundle = path.join(resources, 'coding-agent/mac-native');
|
||||
const platform = architecture === 'arm64' ? 'darwin-arm64' : 'darwin-x64';
|
||||
const bundle = path.join(resources, 'coding-agent/mac-native', platform);
|
||||
const executable = path.join(bundle, 'bin/codex');
|
||||
const main = path.join(
|
||||
app,
|
||||
'Contents/MacOS/genarrative-ai-game-creator-shell',
|
||||
);
|
||||
const main = path.join(app, 'Contents/MacOS', readBundleExecutable(app));
|
||||
const mainArchitectures = run('/usr/bin/lipo', ['-archs', main]);
|
||||
assert.equal(mainArchitectures.status, 0);
|
||||
assert.ok(mainArchitectures.stdout.split(/\s+/).includes(architecture));
|
||||
if (requireUniversal) {
|
||||
assert.deepEqual(mainArchitectures.stdout.trim().split(/\s+/).sort(), [
|
||||
'arm64',
|
||||
'x86_64',
|
||||
]);
|
||||
for (const platform of ['darwin-arm64', 'darwin-x64']) {
|
||||
assert.ok(
|
||||
fs.existsSync(
|
||||
path.join(
|
||||
resources,
|
||||
'coding-agent/mac-native',
|
||||
platform,
|
||||
'manifest.json',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(bundle, 'manifest.json'), 'utf8'),
|
||||
);
|
||||
assert.equal(manifest.schemaVersion, 'genarrative-codex-sidecar.v2');
|
||||
assert.equal(
|
||||
manifest.platform,
|
||||
process.arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64',
|
||||
);
|
||||
assert.equal(manifest.version, 'codex-cli 0.147.0');
|
||||
assert.equal(manifest.platform, platform);
|
||||
assert.equal(manifest.version, `codex-cli ${pinnedCodexVersion}`);
|
||||
const components = [
|
||||
'bin/codex',
|
||||
'bin/codex-code-mode-host',
|
||||
@@ -159,14 +244,41 @@ try {
|
||||
fs.accessSync(file, fs.constants.X_OK);
|
||||
const arch = run('/usr/bin/lipo', ['-archs', file]);
|
||||
assert.equal(arch.status, 0, component);
|
||||
assert.equal(
|
||||
arch.stdout.trim(),
|
||||
process.arch === 'arm64' ? 'arm64' : 'x86_64',
|
||||
component,
|
||||
);
|
||||
assert.equal(arch.stdout.trim(), architecture, component);
|
||||
}
|
||||
}
|
||||
assert.ok(fs.existsSync(path.join(bundle, 'NOTICE.md')));
|
||||
const nodeRoot = path.join(resources, 'game-runtime/node');
|
||||
const nodeManifest = JSON.parse(
|
||||
fs.readFileSync(path.join(nodeRoot, 'manifest.json'), 'utf8'),
|
||||
);
|
||||
assert.equal(nodeManifest.schemaVersion, 'agc-node-runtime.v1');
|
||||
assert.equal(nodeManifest.platform, 'darwin');
|
||||
assert.equal(nodeManifest.arch, process.arch);
|
||||
const runtimeFiles = fs
|
||||
.readdirSync(nodeRoot, { recursive: true })
|
||||
.filter(
|
||||
(file) =>
|
||||
fs.statSync(path.join(nodeRoot, file)).isFile() &&
|
||||
file !== 'manifest.json',
|
||||
);
|
||||
assert.deepEqual(runtimeFiles.sort(), Object.keys(nodeManifest.files).sort());
|
||||
for (const [file, digest] of Object.entries(nodeManifest.files)) {
|
||||
assert.equal(await hashFile(path.join(nodeRoot, file)), digest, file);
|
||||
}
|
||||
assert.ok(nodeManifest.files['NODE-LICENSE']);
|
||||
assert.ok(nodeManifest.files['node_modules/npm/LICENSE']);
|
||||
assert.equal(
|
||||
run(path.join(nodeRoot, 'node'), ['--version']).stdout.trim(),
|
||||
nodeManifest.nodeVersion,
|
||||
);
|
||||
assert.equal(
|
||||
run(path.join(nodeRoot, 'node'), [
|
||||
path.join(nodeRoot, 'node_modules/npm/bin/npm-cli.js'),
|
||||
'--version',
|
||||
]).stdout.trim(),
|
||||
nodeManifest.npmVersion,
|
||||
);
|
||||
const plugin = path.join(resources, 'plugins/agc-cocos-editor');
|
||||
for (const file of [
|
||||
'plugin.json',
|
||||
@@ -175,12 +287,21 @@ try {
|
||||
]) {
|
||||
assert.ok(fs.existsSync(path.join(plugin, file)), file);
|
||||
}
|
||||
// 随包 Node 的 npm 是包里唯一允许出现的 node_modules:除了 npm 目录自身与它的子项,
|
||||
// 还要放行它的上级目录 `game-runtime/node/node_modules`(recursive readdir 会列出目录项,
|
||||
// 少了这一条会让整个门禁对合法包失败——#439 引入后一直没被跑到,直到 2026-09-21 才暴露)。
|
||||
const allowedNodeModules = (file) =>
|
||||
file === 'game-runtime/node/node_modules' ||
|
||||
file === 'game-runtime/node/node_modules/npm' ||
|
||||
file.startsWith('game-runtime/node/node_modules/npm/');
|
||||
const packageFiles = fs.readdirSync(resources, { recursive: true });
|
||||
assert.ok(
|
||||
!packageFiles.some((file) =>
|
||||
/(^|\/)(\.env[^/]*|auth\.json|node_modules|target|\.git)(\/|$)|\.(exe|dll)$/.test(
|
||||
file,
|
||||
),
|
||||
!packageFiles.some(
|
||||
(file) =>
|
||||
/(^|\/)(\.env[^/]*|auth\.json|target|\.git)(\/|$)|\.(exe|dll)$/.test(
|
||||
file,
|
||||
) ||
|
||||
(/(^|\/)node_modules(\/|$)/.test(file) && !allowedNodeModules(file)),
|
||||
),
|
||||
);
|
||||
assert.equal(run(executable, ['--version']).stdout.trim(), manifest.version);
|
||||
@@ -212,7 +333,7 @@ try {
|
||||
assert.notEqual(broken.status, 0);
|
||||
assert.match(`${broken.stdout}\n${broken.stderr}`, /Codex CLI 未安装/);
|
||||
console.log(
|
||||
'PASS: 隔离安装包资源、架构、摘要、权限、正式 Codex 查找、app-server 握手及缺组件拒绝',
|
||||
`PASS (${architecture}, 副本=${copiedWith}): 隔离安装包资源、架构、摘要、权限、正式 Codex 查找、app-server 握手及缺组件拒绝`,
|
||||
);
|
||||
console.log(
|
||||
'未验证:GUI、真实登录/Provider 对话、Cocos macOS 原生桥接;插件 Node 仍为外部前提',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
// 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 把工具链解到 `<tools>/.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 pathImpl = platform === 'win32' ? path.win32 : path.posix;
|
||||
const explicit = env.AGC_TAURI_NSIS_CACHE_DIR?.trim();
|
||||
if (explicit) return pathImpl.resolve(explicit);
|
||||
// Jenkins Windows 节点以 SYSTEM 运行,ProgramData 稳定可写且不受工作区清理影响;
|
||||
// 缓存里只有待解压的原始归档,不会从该目录执行任何程序。
|
||||
if (platform === 'win32') {
|
||||
const programData = env.ProgramData?.trim() || 'C:\\ProgramData';
|
||||
return pathImpl.join(programData, 'genarrative', 'tauri-nsis-cache');
|
||||
}
|
||||
return pathImpl.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('<owner>', match[1])
|
||||
.replaceAll('<repo>', match[2])
|
||||
.replaceAll('<version>', match[3])
|
||||
.replaceAll('<asset>', 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);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
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 {
|
||||
defaultAppRoot,
|
||||
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 = defaultAppRoot();
|
||||
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: 'D:\\agc-cache' }, 'win32'),
|
||||
'D:\\agc-cache',
|
||||
);
|
||||
assert.equal(
|
||||
resolveNsisCacheDir({ ProgramData: 'D:\\ProgramData' }, 'win32'),
|
||||
path.win32.join('D:\\ProgramData', 'genarrative', 'tauri-nsis-cache'),
|
||||
);
|
||||
assert.equal(
|
||||
resolveNsisCacheDir(
|
||||
{ AGC_TAURI_NSIS_CACHE_DIR: '/tmp/agc-cache' },
|
||||
'linux',
|
||||
),
|
||||
'/tmp/agc-cache',
|
||||
);
|
||||
assert.ok(
|
||||
resolveNsisCacheDir({}, 'linux').endsWith(
|
||||
path.posix.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/<owner>/<repo>/<version>/<asset>',
|
||||
}),
|
||||
'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);
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
const repoRoot = path.resolve(appRoot, '../..');
|
||||
const platforms = {
|
||||
arm64: 'aarch64-apple-darwin',
|
||||
x64: 'x86_64-apple-darwin',
|
||||
};
|
||||
|
||||
export function lockedMacPackage(lock, arch, version) {
|
||||
assert.ok(Object.hasOwn(platforms, arch), '未知 macOS 架构');
|
||||
const alias = `@openai/codex-darwin-${arch}`;
|
||||
const entry = lock.packages?.[`node_modules/${alias}`];
|
||||
assert.equal(
|
||||
entry?.version,
|
||||
`${version}-darwin-${arch}`,
|
||||
'原生依赖必须与应用锁定版本一致',
|
||||
);
|
||||
assert.deepEqual(entry.os, ['darwin']);
|
||||
assert.deepEqual(entry.cpu, [arch]);
|
||||
const url = new URL(entry.resolved);
|
||||
assert.equal(url.protocol, 'https:');
|
||||
assert.equal(
|
||||
url.hostname,
|
||||
'registry.npmjs.org',
|
||||
'只下载锁定的官方 npm 原生包',
|
||||
);
|
||||
assert.equal(url.username + url.password + url.search + url.hash, '');
|
||||
assert.match(entry.integrity, /^sha512-[A-Za-z0-9+/]+={0,2}$/);
|
||||
return { alias, target: platforms[arch], ...entry };
|
||||
}
|
||||
|
||||
export function verifyPackageIntegrity(bytes, expected) {
|
||||
const actual = `sha512-${createHash('sha512').update(bytes).digest('base64')}`;
|
||||
assert.equal(actual, expected, 'Codex 下载包 lockfile integrity 不匹配');
|
||||
}
|
||||
|
||||
export function validateArchiveListing(listing) {
|
||||
const files = listing.trim().split(/\r?\n/u);
|
||||
assert.ok(files.length > 0);
|
||||
for (const file of files) {
|
||||
assert.ok(file.startsWith('package/'), '原生包必须只有 package 根目录');
|
||||
assert.ok(
|
||||
!file.split('/').includes('..') && !file.includes('\\'),
|
||||
'压缩包路径不安全',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareMacosCodex() {
|
||||
assert.equal(process.platform, 'darwin', '该入口仅用于 macOS 构建机');
|
||||
const lock = JSON.parse(
|
||||
fs.readFileSync(path.join(repoRoot, 'package-lock.json'), 'utf8'),
|
||||
);
|
||||
const app = JSON.parse(
|
||||
fs.readFileSync(path.join(appRoot, 'package.json'), 'utf8'),
|
||||
);
|
||||
const version = app.devDependencies['@openai/codex'];
|
||||
assert.match(version, /^\d+\.\d+\.\d+$/u, 'Codex 必须锁定精确版本');
|
||||
const cache = path.join(appRoot, 'src-tauri/target/.macos-native-cache');
|
||||
fs.mkdirSync(cache, { recursive: true });
|
||||
for (const arch of Object.keys(platforms)) {
|
||||
const entry = lockedMacPackage(lock, arch, version);
|
||||
const archive = path.join(cache, `codex-${entry.version}.tgz`);
|
||||
if (!fs.existsSync(archive)) {
|
||||
const response = await fetch(entry.resolved, {
|
||||
signal: AbortSignal.timeout(300_000),
|
||||
});
|
||||
assert.ok(response.ok, `原生包下载失败 HTTP ${response.status}`);
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
verifyPackageIntegrity(bytes, entry.integrity);
|
||||
const partial = `${archive}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(partial, bytes);
|
||||
fs.renameSync(partial, archive);
|
||||
}
|
||||
verifyPackageIntegrity(fs.readFileSync(archive), entry.integrity);
|
||||
validateArchiveListing(
|
||||
execFileSync('tar', ['-tzf', archive], { encoding: 'utf8' }),
|
||||
);
|
||||
// 拒绝链接、设备及其它特殊条目,不能让 tar 在包目录之外写入。
|
||||
const entries = execFileSync('tar', ['-tvzf', archive], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.ok(
|
||||
entries
|
||||
.trim()
|
||||
.split(/\r?\n/u)
|
||||
.every((line) => /^[-d]/u.test(line)),
|
||||
'原生包禁止链接或特殊文件',
|
||||
);
|
||||
const parent = path.join(repoRoot, 'node_modules/@openai');
|
||||
fs.mkdirSync(parent, { recursive: true });
|
||||
const stage = fs.mkdtempSync(path.join(parent, '.mac-native-'));
|
||||
try {
|
||||
execFileSync(
|
||||
'tar',
|
||||
['-xzf', archive, '-C', stage, '--strip-components=1'],
|
||||
{ stdio: 'pipe' },
|
||||
);
|
||||
const metadata = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(stage, 'vendor', entry.target, 'codex-package.json'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert.equal(metadata.version, version);
|
||||
assert.equal(metadata.target, entry.target);
|
||||
assert.equal(metadata.entrypoint, 'bin/codex');
|
||||
const destination = path.join(repoRoot, 'node_modules', entry.alias);
|
||||
assert.ok(
|
||||
!fs.existsSync(destination) ||
|
||||
!fs.lstatSync(destination).isSymbolicLink(),
|
||||
'拒绝覆盖链接依赖',
|
||||
);
|
||||
fs.rmSync(destination, { recursive: true, force: true });
|
||||
fs.renameSync(stage, destination);
|
||||
} finally {
|
||||
fs.rmSync(stage, { recursive: true, force: true });
|
||||
}
|
||||
console.log(`[macOS Codex] ${entry.version}: lockfile integrity 已验证`);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
) {
|
||||
await prepareMacosCodex();
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import {
|
||||
lockedMacPackage,
|
||||
validateArchiveListing,
|
||||
verifyPackageIntegrity,
|
||||
} from './prepare-macos-codex.mjs';
|
||||
|
||||
const lock = JSON.parse(
|
||||
fs.readFileSync(new URL('../../../package-lock.json', import.meta.url)),
|
||||
);
|
||||
const version = JSON.parse(
|
||||
fs.readFileSync(new URL('../package.json', import.meta.url)),
|
||||
).devDependencies['@openai/codex'];
|
||||
|
||||
test('both macOS dependencies resolve from the lockfile without floating versions', () => {
|
||||
assert.equal(
|
||||
lockedMacPackage(lock, 'arm64', version).target,
|
||||
'aarch64-apple-darwin',
|
||||
);
|
||||
assert.equal(
|
||||
lockedMacPackage(lock, 'x64', version).target,
|
||||
'x86_64-apple-darwin',
|
||||
);
|
||||
assert.throws(() => lockedMacPackage(lock, 'other', version));
|
||||
assert.throws(() => lockedMacPackage(lock, 'x64', '0.0.0'));
|
||||
});
|
||||
|
||||
test('native package integrity rejects tampering', () => {
|
||||
const bytes = Buffer.from('pinned package');
|
||||
const integrity = `sha512-${createHash('sha512').update(bytes).digest('base64')}`;
|
||||
verifyPackageIntegrity(bytes, integrity);
|
||||
assert.throws(() =>
|
||||
verifyPackageIntegrity(Buffer.from('modified'), integrity),
|
||||
);
|
||||
});
|
||||
|
||||
test('archive traversal and non-package entries fail closed', () => {
|
||||
validateArchiveListing(
|
||||
'package/package.json\npackage/vendor/target/bin/codex\n',
|
||||
);
|
||||
for (const listing of [
|
||||
'',
|
||||
'/tmp/payload',
|
||||
'package/../private',
|
||||
'other/file',
|
||||
'package/..\\file',
|
||||
]) {
|
||||
assert.throws(() => validateArchiveListing(listing));
|
||||
}
|
||||
});
|
||||
|
||||
test('CI pipeline is manual, publishes the macOS partition and never reuses a developer workspace', () => {
|
||||
const pipeline = fs.readFileSync(
|
||||
new URL(
|
||||
'../../../jenkins/Jenkinsfile.ai-game-creator-shell-macos-build',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
for (const required of [
|
||||
'genarrative-agc-macos',
|
||||
'disableConcurrentBuilds()',
|
||||
'$AGC_AGENT_ROOT',
|
||||
'StrictHostKeyChecking=yes',
|
||||
'git merge-base --is-ancestor',
|
||||
'allowEmptyArchive: false',
|
||||
"string(name: 'AGC_UPDATE_CHANNEL', defaultValue: 'dev'",
|
||||
'AGC_UPDATE_CHANNEL=${params.AGC_UPDATE_CHANNEL}',
|
||||
"string(credentialsId: 'AgcUpdaterSigningKey'",
|
||||
"string(credentialsId: 'AgcUpdaterSigningKeyPassword'",
|
||||
"string(credentialsId: 'AliyunAccessKeyId'",
|
||||
"string(credentialsId: 'AliyunaccessKeySecret'",
|
||||
'AGC_RELEASE_VERSION',
|
||||
'OSSUTIL_BIN',
|
||||
// 并行度必须可调:节点是共用机器,写死容易把整机压满或反过来浪费一半核心。
|
||||
"string(name: 'CARGO_BUILD_JOBS', defaultValue: '8'",
|
||||
'CARGO_BUILD_JOBS=${params.CARGO_BUILD_JOBS}',
|
||||
// Agent 工作区按约定匹配,不写死节点名:节点改名(-local → -01)后守卫仍成立。
|
||||
'"$HOME"/Library/Jenkins/agents/*/workspace/*',
|
||||
// 上一次发布的 commit 落在 master 上,取到它更新摘要才不会退化成「最近提交」。
|
||||
'refs/heads/master:refs/remotes/origin/master',
|
||||
]) {
|
||||
assert.ok(pipeline.includes(required), required);
|
||||
}
|
||||
assert.ok(
|
||||
!pipeline.includes('genarrative-agc-macos-local'),
|
||||
'Jenkinsfile 不得写死具体节点名',
|
||||
);
|
||||
// 这条管线是正式发布入口(与 Windows 对称):默认真发布,演练需显式勾选。
|
||||
assert.match(
|
||||
pipeline,
|
||||
/booleanParam\(name: 'AGC_RELEASE_DRY_RUN', defaultValue: false/u,
|
||||
'Channel 发布默认必须是真发布,演练只能显式勾选',
|
||||
);
|
||||
// 节点是办公机:离线期间排队的旧构建必须自行让位,且跳过要覆盖后续全部阶段。
|
||||
assert.match(
|
||||
pipeline,
|
||||
/booleanParam\(name: 'SKIP_IF_SUPERSEDED', defaultValue: false/u,
|
||||
);
|
||||
// 仓库文件不得出现节点用户名/个人 Home 路径:换机或改名后必须仍然可用。
|
||||
assert.ok(
|
||||
!pipeline.includes('/Users/'),
|
||||
'Jenkinsfile 不得写死个人 Home 路径,工具链位置应按 $HOME 展开',
|
||||
);
|
||||
assert.ok(
|
||||
pipeline.includes('export PATH="$HOME/'),
|
||||
'PATH 必须在 shell 步骤里按 $HOME 展开',
|
||||
);
|
||||
// 超时必须高于实测最慢(78 分钟冷构建 + 共用机器),否则会被中断在链接阶段。
|
||||
assert.ok(
|
||||
pipeline.includes('timeout(time: 150'),
|
||||
'构建超时上限必须留出冷构建余量',
|
||||
);
|
||||
for (const diagnostic of ['macOS 发布失败', '被中断']) {
|
||||
assert.ok(pipeline.includes(diagnostic), diagnostic);
|
||||
}
|
||||
assert.ok(
|
||||
pipeline.includes('.jenkins-superseded-by'),
|
||||
'必须记录被推进的标记供后续阶段判定',
|
||||
);
|
||||
assert.equal(
|
||||
(pipeline.match(/env\.AGC_BUILD_SUPERSEDED != 'true'/gu) ?? []).length,
|
||||
3,
|
||||
'Toolchain / Package / Archive 三个阶段都必须按跳过标记收口',
|
||||
);
|
||||
for (const forbidden of [
|
||||
'triggers {',
|
||||
'cron(',
|
||||
'pollSCM(',
|
||||
'git clean -fdx',
|
||||
// release:upload 会重新触发一次完整构建,既翻倍耗时也绕过本 Job 的验签门禁。
|
||||
'release:upload',
|
||||
]) {
|
||||
assert.ok(!pipeline.includes(forbidden), forbidden);
|
||||
}
|
||||
});
|
||||
|
||||
test('macOS release entry verifies the updater signature before uploading', () => {
|
||||
const entry = fs.readFileSync(
|
||||
new URL('./build-macos-ci.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const verifyIndex = entry.indexOf('verifyUpdaterSignature({');
|
||||
const uploadIndex = entry.indexOf('uploadReleaseArtifacts(release');
|
||||
assert.ok(verifyIndex > 0, '必须调用更新包验签');
|
||||
assert.ok(uploadIndex > 0, '必须调用 OSS 上传');
|
||||
assert.ok(verifyIndex < uploadIndex, '必须先验签再上传,验不过不得写 OSS');
|
||||
// 无签名私钥时禁止构建:未签名的更新包会被客户端一律拒绝。
|
||||
assert.ok(entry.includes('TAURI_SIGNING_PRIVATE_KEY'));
|
||||
// `--no-sign` 会连带跳过 updater 的 minisign 签名,产物将没有 .sig,入口不得传它。
|
||||
assert.ok(
|
||||
!entry.includes("'--no-sign'"),
|
||||
'--no-sign 会同时跳过 updater 签名,产物缺少 .sig',
|
||||
);
|
||||
// workspace 会跨构建保留产物:必须先删本次要写的对象,否则会因同名 DMG 失败,
|
||||
// 或让上一轮遗留的 .sig 让验签门禁误通过。
|
||||
for (const required of [
|
||||
// 清理对象用派生的产品名算出来,而不是写死某个名字。
|
||||
'${updaterArtifactName}.sig',
|
||||
'${firstInstallName}.sha256',
|
||||
'fs.rmSync(stale, { force: true })',
|
||||
"'-ov'",
|
||||
]) {
|
||||
assert.ok(entry.includes(required), required);
|
||||
}
|
||||
});
|
||||
|
||||
test('macOS release entry and smoke script derive product names from config and the bundle', () => {
|
||||
const entry = fs.readFileSync(
|
||||
new URL('./build-macos-ci.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
// 产品名决定 *.app、updater 归档与 DMG 卷名:它必须从渠道安装身份派生,
|
||||
// 写死会在换渠道或改名后静默找错对象。
|
||||
assert.ok(
|
||||
entry.includes('resolveChannelInstallIdentity'),
|
||||
'入口必须从渠道安装身份派生产品名',
|
||||
);
|
||||
assert.ok(
|
||||
entry.includes('resolveProductName(context.channel)'),
|
||||
'产品名必须按当前发布渠道解析',
|
||||
);
|
||||
assert.ok(!entry.includes('陶泥儿'), 'macOS 发布入口不得写死产品名');
|
||||
assert.ok(
|
||||
entry.includes("const macTarget = 'aarch64-apple-darwin'"),
|
||||
'macOS 发布入口必须固定单架构目标',
|
||||
);
|
||||
assert.ok(
|
||||
entry.includes('_${version}_aarch64.dmg'),
|
||||
'首装包名必须保留清单侧单架构分支唯一匹配所需的后缀(Tauri 口径 aarch64)',
|
||||
);
|
||||
|
||||
const smoke = fs.readFileSync(
|
||||
new URL('./check-macos-bundle.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(!smoke.includes('陶泥儿'), '校验脚本不得写死产品名');
|
||||
for (const required of [
|
||||
'path.basename(source)',
|
||||
'Print :CFBundleExecutable',
|
||||
"'--clone'",
|
||||
]) {
|
||||
assert.ok(smoke.includes(required), required);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
# 只读取 Windows Installer 已登记的同版本 Node.js 缓存;不执行安装、不访问网络。
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
||||
$expectedVersion = $env:AGC_STAGING_NODE_VERSION
|
||||
if ($expectedVersion -notmatch '^\d+\.\d+\.\d+$') { throw 'Invalid Node version' }
|
||||
|
||||
# WinVerifyTrust 强制仅使用本地证书缓存,禁止吊销/证书 URL 网络检索。
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class AgcOfflineSignature {
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct FileInfo { public uint Size; public IntPtr Path; public IntPtr File; public IntPtr Subject; }
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct TrustData {
|
||||
public uint Size; public IntPtr Policy; public IntPtr Sip; public uint Ui;
|
||||
public uint Revocation; public uint Choice; public IntPtr File;
|
||||
public uint StateAction; public IntPtr State; public IntPtr Url;
|
||||
public uint Flags; public uint Context;
|
||||
}
|
||||
[DllImport("wintrust.dll", ExactSpelling=true, PreserveSig=true)]
|
||||
static extern int WinVerifyTrust(IntPtr window, ref Guid action, ref TrustData data);
|
||||
public static bool Verify(string path) {
|
||||
IntPtr name = Marshal.StringToCoTaskMemUni(path);
|
||||
IntPtr file = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(FileInfo)));
|
||||
try {
|
||||
var info = new FileInfo { Size=(uint)Marshal.SizeOf(typeof(FileInfo)), Path=name };
|
||||
Marshal.StructureToPtr(info, file, false);
|
||||
var data = new TrustData { Size=(uint)Marshal.SizeOf(typeof(TrustData)), Ui=2, Choice=1, File=file, Flags=0x1000|0x10 };
|
||||
var action = new Guid("00AAC56B-CD44-11d0-8CC2-00C04FC295EE");
|
||||
return WinVerifyTrust(new IntPtr(-1), ref action, ref data) == 0;
|
||||
} finally { Marshal.FreeHGlobal(file); Marshal.FreeCoTaskMem(name); }
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
function Read-Property($database, [string]$name) {
|
||||
$view = $database.OpenView("SELECT ``Value`` FROM ``Property`` WHERE ``Property`` = '$name'")
|
||||
try {
|
||||
[void]$view.Execute()
|
||||
$record = $view.Fetch()
|
||||
if ($null -ne $record) { return $record.StringData(1) }
|
||||
return ''
|
||||
} finally { [void]$view.Close() }
|
||||
}
|
||||
|
||||
$installer = New-Object -ComObject WindowsInstaller.Installer
|
||||
$cacheRoot = [System.IO.Path]::GetFullPath((Join-Path ([Environment]::GetFolderPath('Windows')) 'Installer'))
|
||||
$registrations = @(
|
||||
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
||||
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
||||
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
|
||||
)
|
||||
$products = Get-ItemProperty $registrations -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.DisplayName -eq 'Node.js' -and $_.DisplayVersion -eq $expectedVersion -and $_.PSChildName -match '^\{[0-9A-Fa-f-]{36}\}$' } |
|
||||
Select-Object -ExpandProperty PSChildName -Unique
|
||||
foreach ($product in $products) {
|
||||
try {
|
||||
if ($installer.ProductInfo($product, 'ProductName') -ne 'Node.js') { continue }
|
||||
if ($installer.ProductInfo($product, 'VersionString') -ne $expectedVersion) { continue }
|
||||
$package = [System.IO.Path]::GetFullPath($installer.ProductInfo($product, 'LocalPackage'))
|
||||
if (-not [string]::Equals([System.IO.Path]::GetDirectoryName($package), $cacheRoot, [StringComparison]::OrdinalIgnoreCase)) { continue }
|
||||
if ([System.IO.Path]::GetExtension($package) -ne '.msi') { continue }
|
||||
$entry = Get-Item -LiteralPath $package -Force
|
||||
$cache = Get-Item -LiteralPath $cacheRoot -Force
|
||||
if (($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) -or ($cache.Attributes -band [IO.FileAttributes]::ReparsePoint)) { continue }
|
||||
if (-not [AgcOfflineSignature]::Verify($package)) { continue }
|
||||
$certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new([System.Security.Cryptography.X509Certificates.X509Certificate]::CreateFromSignedFile($package))
|
||||
if ($certificate.Subject -notmatch '(^|,\s*)O=OpenJS Foundation(,|$)') { continue }
|
||||
$database = $installer.OpenDatabase($package, 0)
|
||||
if ((Read-Property $database 'ProductName') -ne 'Node.js') { continue }
|
||||
if ((Read-Property $database 'ProductVersion') -ne $expectedVersion) { continue }
|
||||
if ((Read-Property $database 'ProductCode') -ne $product) { continue }
|
||||
$manufacturer = Read-Property $database 'Manufacturer'
|
||||
if ($manufacturer -notin @('Node.js Foundation', 'OpenJS Foundation')) { continue }
|
||||
$view = $database.OpenView('SELECT `Text` FROM `Control` WHERE `Dialog_` = ''LicenseAgreementDlg'' AND `Control` = ''LicenseText''')
|
||||
try {
|
||||
[void]$view.Execute()
|
||||
$record = $view.Fetch()
|
||||
if ($null -eq $record) { continue }
|
||||
$content = $record.StringData(1)
|
||||
} finally { [void]$view.Close() }
|
||||
if (-not $content.StartsWith('{\rtf') -or $content.Length -gt 1048576) { continue }
|
||||
if (-not $content.Contains('Node.js') -or -not $content.Contains('Permission is hereby granted')) { continue }
|
||||
[pscustomobject]@{
|
||||
productName = 'Node.js'; version = $expectedVersion; manufacturer = $manufacturer
|
||||
signatureVerified = $true; signer = 'OpenJS Foundation'; format = 'rtf'; content = $content
|
||||
} | ConvertTo-Json -Compress
|
||||
exit 0
|
||||
} catch {
|
||||
# 单个损坏/无权限缓存不能绕过验证;继续查找其它已登记候选。
|
||||
continue
|
||||
}
|
||||
}
|
||||
throw 'No matching trusted installed Node.js license'
|
||||
@@ -7,7 +7,8 @@
|
||||
// 整套用例串行跑满 507 秒,占掉 CI 上 `AI game creator shell Rust tests` job 的大头。
|
||||
//
|
||||
// 这里保留「片内串行」的既有口径,只把用例集合切成 N 片:
|
||||
// - CI 用 `--shard-index=<i>` 让**每个 job 只跑一片**,靠多个 job 并发把整套用例摊开;
|
||||
// - CI 用 `--shard-index=<i>` 让**每次分片调用只跑一片**,由两条 lane job 顺序承载两片,
|
||||
// 在不重复预热依赖的前提下保留 job 级并发;
|
||||
// - 本地不传 `--shard-index` 时把 N 片放进 N 个**独立进程**并行(--concurrency 可调),
|
||||
// 保留一条命令跑全量的入口。
|
||||
// 片并集必须等于全集、且不得重复,数量不符即失败,防止分片规则改动后静默漏跑;该校验
|
||||
@@ -149,6 +150,7 @@ function formatDuration(milliseconds) {
|
||||
// 编译一次,直接拿到测试可执行文件:后续每片都运行同一个二进制,不再各自调用 cargo,
|
||||
// 免得 N 个 cargo 去争 package cache 与 target 目录锁。
|
||||
function resolveTestExecutable() {
|
||||
const startedAt = Date.now();
|
||||
return new Promise((resolve, reject) => {
|
||||
const cargoArguments = buildCargoArguments({
|
||||
kind: options.targetKind,
|
||||
@@ -193,6 +195,9 @@ function resolveTestExecutable() {
|
||||
reject(new Error(`unable to start cargo: ${error.message}`));
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
console.log(
|
||||
`[rust-shards] compile duration=${formatDuration(Date.now() - startedAt)} exit=${code}`,
|
||||
);
|
||||
if (code !== 0) {
|
||||
reject(
|
||||
new Error(
|
||||
@@ -371,6 +376,26 @@ async function runWithConcurrency(shards, runner) {
|
||||
return results;
|
||||
}
|
||||
|
||||
function extractFailingTestNames(result) {
|
||||
const names = new Set();
|
||||
for (const line of result.failures) {
|
||||
const normalized = line
|
||||
.trim()
|
||||
.replace(/^\[rust-shards\]\s*/, '')
|
||||
.replace(/^----\s*/, '')
|
||||
.replace(/\s*stdout\s*----$/, '')
|
||||
.replace(/\s*\(\d+\)\s*$/, '')
|
||||
.trim();
|
||||
const match = normalized.match(
|
||||
/^(process_session::tests::[A-Za-z0-9_:]+|tests::[A-Za-z0-9_:]+)$/,
|
||||
);
|
||||
if (match) {
|
||||
names.add(match[1]);
|
||||
}
|
||||
}
|
||||
return [...names];
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const executable = await resolveTestExecutable();
|
||||
const testNames = await listTestNames(executable);
|
||||
@@ -410,15 +435,55 @@ async function main() {
|
||||
|
||||
const results = await runWithConcurrency(
|
||||
selectedShards,
|
||||
({ index, shardTestNames }) =>
|
||||
runShard(executable, index, shards.length, shardTestNames),
|
||||
async ({ index, shardTestNames }) => {
|
||||
const result = await runShard(
|
||||
executable,
|
||||
index,
|
||||
shards.length,
|
||||
shardTestNames,
|
||||
);
|
||||
if (result.ok) {
|
||||
return result;
|
||||
}
|
||||
// 片内串行的时序型用例在高负载 CI 上会偶发假红。只对失败用例做一次
|
||||
// 有界复核:复核通过按 flaky 记录,复核失败才判红,避免把真实回归洗掉。
|
||||
const failingTestNames = extractFailingTestNames(result).filter((name) =>
|
||||
shardTestNames.includes(name),
|
||||
);
|
||||
if (failingTestNames.length === 0) {
|
||||
return result;
|
||||
}
|
||||
const retry = await runShard(
|
||||
executable,
|
||||
index,
|
||||
shards.length,
|
||||
failingTestNames,
|
||||
);
|
||||
if (!retry.ok) {
|
||||
return {
|
||||
...result,
|
||||
failures: [
|
||||
`re-run of ${failingTestNames.length} failing test(s) also failed`,
|
||||
...retry.failures,
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
ok: true,
|
||||
retriedTestNames: failingTestNames,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
let failed = false;
|
||||
for (const result of results) {
|
||||
if (result.ok) {
|
||||
const retrySuffix = result.retriedTestNames
|
||||
? ` (flaky: re-ran ${result.retriedTestNames.length} failing test(s) and passed: ${result.retriedTestNames.join(', ')})`
|
||||
: '';
|
||||
console.log(
|
||||
`[rust-shards] ${result.label} ok: ${result.testCount} test(s) in ${formatDuration(result.durationMs)}`,
|
||||
`[rust-shards] ${result.label} ok: ${result.testCount} test(s) in ${formatDuration(result.durationMs)}${retrySuffix}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import {
|
||||
advanceRunner,
|
||||
createRunner,
|
||||
createRunnerRandom,
|
||||
measuredRunnerFairWindowMs,
|
||||
restartRunner,
|
||||
RUNNER_SEED,
|
||||
runnerProjection,
|
||||
runnerSeedFromSearch,
|
||||
setRunnerInput,
|
||||
startRunner,
|
||||
stepRunner,
|
||||
} from '../src-tauri/resources/agc-skills/agc-browser-playtest/references/runner-physics.mjs';
|
||||
|
||||
function jump(
|
||||
settings,
|
||||
holdTicks,
|
||||
functions = {
|
||||
createRunner,
|
||||
startRunner,
|
||||
setRunnerInput,
|
||||
stepRunner,
|
||||
runnerProjection,
|
||||
},
|
||||
) {
|
||||
const state = functions.createRunner(RUNNER_SEED, settings);
|
||||
functions.startRunner(state);
|
||||
functions.setRunnerInput(state, 'jump', true);
|
||||
const samples = [];
|
||||
for (let tick = 0; tick < 150; tick += 1) {
|
||||
if (tick === holdTicks) functions.setRunnerInput(state, 'jump', false);
|
||||
functions.stepRunner(state);
|
||||
samples.push(functions.runnerProjection(state));
|
||||
if (state.onGround && state.jumpCount > 0) break;
|
||||
}
|
||||
return {
|
||||
state,
|
||||
samples,
|
||||
height: Math.max(
|
||||
...samples.map((sample) => sample.groundY - sample.playerY),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
test('fixed seed, fixed steps and independent decoration produce the same course', () => {
|
||||
assert.equal(runnerSeedFromSearch('?agcPlaytestSeed=20260920'), RUNNER_SEED);
|
||||
assert.throws(() =>
|
||||
runnerSeedFromSearch('?agcPlaytestSeed=1&agcPlaytestSeed=2'),
|
||||
);
|
||||
assert.throws(() => runnerSeedFromSearch('?agcPlaytestSeed=4294967296'));
|
||||
const first = createRunner();
|
||||
const decoration = createRunnerRandom(71);
|
||||
for (let index = 0; index < 100; index += 1) decoration();
|
||||
const second = createRunner();
|
||||
assert.deepEqual(first.course, second.course);
|
||||
assert.notEqual(
|
||||
first.courseFingerprint,
|
||||
createRunner(20260921).courseFingerprint,
|
||||
);
|
||||
startRunner(first);
|
||||
startRunner(second);
|
||||
for (let index = 0; index < 60; index += 1) advanceRunner(first, 1 / 60);
|
||||
for (let index = 0; index < 120; index += 1) advanceRunner(second, 1 / 120);
|
||||
assert.deepEqual(runnerProjection(first), runnerProjection(second));
|
||||
const course = structuredClone(first.course);
|
||||
restartRunner(first);
|
||||
assert.equal(first.phase, 'ready');
|
||||
assert.equal(first.simulationTick, 0);
|
||||
assert.equal(first.jumpCount, 0);
|
||||
assert.deepEqual(first.course, course);
|
||||
});
|
||||
|
||||
test('short jump cuts once, long jump rises higher, release restores slide collision size', () => {
|
||||
const short = jump({}, 4);
|
||||
const long = jump({}, 18);
|
||||
assert.equal(short.state.jumpCount, 1);
|
||||
assert.equal(short.state.jumpCutCount, 1);
|
||||
assert.ok(long.height > short.height + 10);
|
||||
assert.ok(short.state.onGround && long.state.onGround);
|
||||
const state = short.state;
|
||||
setRunnerInput(state, 'slide', true);
|
||||
stepRunner(state);
|
||||
assert.ok(state.sliding && state.playerHeight < state.config.playerHeight);
|
||||
setRunnerInput(state, 'slide', false);
|
||||
stepRunner(state);
|
||||
assert.ok(!state.sliding && !state.slideHeld);
|
||||
assert.equal(state.playerHeight, state.config.playerHeight);
|
||||
});
|
||||
|
||||
test('baseline has at least 180ms clearance while the original narrow-window parameters fail', () => {
|
||||
const baseline = jump({}, 4);
|
||||
assert.ok(
|
||||
measuredRunnerFairWindowMs(
|
||||
baseline.samples,
|
||||
49,
|
||||
baseline.state.course[0],
|
||||
) >= 180,
|
||||
);
|
||||
const original = jump(
|
||||
{
|
||||
gravity: 2300,
|
||||
jumpVelocity: 800,
|
||||
releaseVelocity: 720,
|
||||
playerWidth: 48.9,
|
||||
},
|
||||
1,
|
||||
);
|
||||
const window = measuredRunnerFairWindowMs(
|
||||
original.samples,
|
||||
48.9,
|
||||
original.state.course[0],
|
||||
);
|
||||
assert.ok(window < 180, `original jump window ${window}ms must fail`);
|
||||
});
|
||||
|
||||
test('regression mutations expose repeated jump-cut and ignored slide release', async () => {
|
||||
const source = fs.readFileSync(
|
||||
new URL(
|
||||
'../src-tauri/resources/agc-skills/agc-browser-playtest/references/runner-physics.mjs',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
const repeatedCut = source.replace('&& !state.jumpCut)', ')');
|
||||
assert.notEqual(repeatedCut, source);
|
||||
const broken = await import(
|
||||
`data:text/javascript;base64,${Buffer.from(repeatedCut).toString('base64')}`
|
||||
);
|
||||
assert.ok(jump({}, 4, broken).state.jumpCutCount > 1);
|
||||
const noRelease = source.replace(
|
||||
'state.slideHeld = Boolean(held);',
|
||||
'if (held) state.slideHeld = true;',
|
||||
);
|
||||
const stuck = await import(
|
||||
`data:text/javascript;base64,${Buffer.from(noRelease).toString('base64')}`
|
||||
);
|
||||
const state = stuck.createRunner();
|
||||
stuck.startRunner(state);
|
||||
stuck.setRunnerInput(state, 'slide', true);
|
||||
stuck.stepRunner(state);
|
||||
stuck.setRunnerInput(state, 'slide', false);
|
||||
stuck.stepRunner(state);
|
||||
assert.equal(state.sliding, true);
|
||||
});
|
||||
@@ -0,0 +1,407 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
export const nodeRuntimeSchema = 'agc-node-runtime.v1';
|
||||
|
||||
export function readInstalledNodeLicense(
|
||||
version,
|
||||
{
|
||||
execute = execFileSync,
|
||||
powershellPath = path.join(
|
||||
process.env.SystemRoot || 'C:/Windows',
|
||||
'System32/WindowsPowerShell/v1.0/powershell.exe',
|
||||
),
|
||||
} = {},
|
||||
) {
|
||||
const result = JSON.parse(
|
||||
execute(
|
||||
powershellPath,
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
'& ([scriptblock]::Create([IO.File]::ReadAllText($env:AGC_LICENSE_READER_SCRIPT, [Text.Encoding]::UTF8)))',
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 20_000,
|
||||
maxBuffer: 2 * 1024 * 1024,
|
||||
env: {
|
||||
...process.env,
|
||||
AGC_STAGING_NODE_VERSION: version,
|
||||
AGC_LICENSE_READER_SCRIPT: fileURLToPath(
|
||||
new URL('./read-installed-node-license.ps1', import.meta.url),
|
||||
),
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
if (
|
||||
result.productName !== 'Node.js' ||
|
||||
result.version !== version ||
|
||||
!['Node.js Foundation', 'OpenJS Foundation'].includes(
|
||||
result.manufacturer,
|
||||
) ||
|
||||
result.signatureVerified !== true ||
|
||||
result.signer !== 'OpenJS Foundation' ||
|
||||
result.format !== 'rtf' ||
|
||||
typeof result.content !== 'string' ||
|
||||
!result.content.startsWith('{\\rtf') ||
|
||||
!result.content.includes('Node.js') ||
|
||||
!result.content.includes('Permission is hereby granted')
|
||||
) {
|
||||
throw new Error('已安装 Node 许可的版本、产品或签名身份不匹配');
|
||||
}
|
||||
return result.content;
|
||||
}
|
||||
|
||||
export function targetRuntime(target) {
|
||||
const targets = {
|
||||
'x86_64-pc-windows-msvc': ['win32', 'x64'],
|
||||
'aarch64-apple-darwin': ['darwin', 'arm64'],
|
||||
'x86_64-apple-darwin': ['darwin', 'x64'],
|
||||
};
|
||||
// 随包 Node 是**单架构**官方发行版:一份运行时只服务它自己的架构。
|
||||
// macOS 发布当前固定为 aarch64-apple-darwin 单架构包(Intel 未支持),
|
||||
// universal 目标没有正确的运行时来源,必须失败关闭——绝不能退化成
|
||||
// 「按宿主架构暂存一份 arm64」:那样通用包自检(按 process.arch)能过,
|
||||
// 但 Intel 机器上这份运行时不可执行,用户拿到的是坏包。
|
||||
if (target === 'universal-apple-darwin')
|
||||
throw new Error(
|
||||
'Node 运行时不支持 universal-apple-darwin:随包 Node 只有单架构发行版,' +
|
||||
'通用包需按架构各带一份(另行下载另一架构官方发行版)之后才能构建;' +
|
||||
'当前 macOS 发布固定为 aarch64-apple-darwin 单架构',
|
||||
);
|
||||
const value = targets[target];
|
||||
if (!value) throw new Error(`Node 运行时不支持发布目标:${target}`);
|
||||
return { platform: value[0], arch: value[1] };
|
||||
}
|
||||
|
||||
function inside(root, file) {
|
||||
const relative = path.relative(root, file);
|
||||
return (
|
||||
relative !== '..' &&
|
||||
!relative.startsWith(`..${path.sep}`) &&
|
||||
!path.isAbsolute(relative)
|
||||
);
|
||||
}
|
||||
|
||||
function packageFiles(root, directory = root) {
|
||||
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const file = path.join(directory, entry.name);
|
||||
if (entry.isSymbolicLink()) throw new Error('运行时资源不能包含符号链接');
|
||||
if (entry.isDirectory()) return packageFiles(root, file);
|
||||
if (!entry.isFile()) throw new Error('运行时资源包含非普通文件');
|
||||
return [file];
|
||||
});
|
||||
}
|
||||
|
||||
function replacementIdentity(destination) {
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.lstatSync(destination);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return null;
|
||||
throw error;
|
||||
}
|
||||
if (stat.isSymbolicLink() || !stat.isDirectory())
|
||||
throw new Error('拒绝覆盖链接或非目录运行时目标');
|
||||
if (fs.readdirSync(destination).length === 0) return stat;
|
||||
let manifest;
|
||||
try {
|
||||
const file = path.join(destination, 'manifest.json');
|
||||
const metadata = fs.lstatSync(file);
|
||||
if (
|
||||
!metadata.isFile() ||
|
||||
metadata.isSymbolicLink() ||
|
||||
metadata.size > 4 * 1024 * 1024
|
||||
)
|
||||
throw new Error('manifest invalid');
|
||||
manifest = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
} catch {
|
||||
throw new Error('拒绝覆盖非本工具生成的运行时目录');
|
||||
}
|
||||
if (
|
||||
!manifest ||
|
||||
typeof manifest !== 'object' ||
|
||||
!manifest.files ||
|
||||
typeof manifest.files !== 'object' ||
|
||||
Array.isArray(manifest.files)
|
||||
)
|
||||
throw new Error('拒绝覆盖没有合法运行时清单的目录');
|
||||
const entries = Object.entries(manifest.files || {});
|
||||
const validFiles =
|
||||
entries.length > 0 &&
|
||||
entries.length <= 20_000 &&
|
||||
entries.every(
|
||||
([file, digest]) =>
|
||||
typeof digest === 'string' &&
|
||||
/^[0-9a-f]{64}$/u.test(digest) &&
|
||||
!file.includes('\\') &&
|
||||
!file.includes(':') &&
|
||||
file.split('/').every((part) => part && part !== '.' && part !== '..'),
|
||||
);
|
||||
const nodeFile = manifest.platform === 'win32' ? 'node.exe' : 'node';
|
||||
if (
|
||||
manifest.schemaVersion !== nodeRuntimeSchema ||
|
||||
!['win32', 'darwin'].includes(manifest.platform) ||
|
||||
!['x64', 'arm64'].includes(manifest.arch) ||
|
||||
!/^v\d+\.\d+\.\d+$/u.test(manifest.nodeVersion) ||
|
||||
!/^\d+\.\d+\.\d+$/u.test(manifest.npmVersion) ||
|
||||
!validFiles ||
|
||||
!manifest.files[nodeFile] ||
|
||||
!manifest.files['node_modules/npm/bin/npm-cli.js'] ||
|
||||
!manifest.files['node_modules/npm/LICENSE'] ||
|
||||
!(manifest.files['NODE-LICENSE'] || manifest.files['NODE-LICENSE.rtf'])
|
||||
) {
|
||||
throw new Error('拒绝覆盖没有合法运行时清单的目录');
|
||||
}
|
||||
// 容许重建损坏/缺文件的资源,但不删除后来混入的其它文件或链接。
|
||||
for (const file of packageFiles(destination)) {
|
||||
const relative = path.relative(destination, file).split(path.sep).join('/');
|
||||
if (
|
||||
relative !== 'manifest.json' &&
|
||||
!Object.hasOwn(manifest.files, relative)
|
||||
)
|
||||
throw new Error('拒绝覆盖包含未登记文件的运行时目录');
|
||||
}
|
||||
return stat;
|
||||
}
|
||||
|
||||
function sameDirectoryIdentity(before, after) {
|
||||
return before === null
|
||||
? after === null
|
||||
: after !== null &&
|
||||
before.dev === after.dev &&
|
||||
before.ino === after.ino &&
|
||||
before.birthtimeMs === after.birthtimeMs;
|
||||
}
|
||||
|
||||
function cleanupStaging(staging, parent, prefix, identity) {
|
||||
if (
|
||||
path.dirname(staging) !== parent ||
|
||||
!path.basename(staging).startsWith(prefix) ||
|
||||
fs.realpathSync(parent) !== parent
|
||||
)
|
||||
throw new Error('拒绝清理非本次创建的 staging 路径');
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.lstatSync(staging);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return;
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
stat.isSymbolicLink() ||
|
||||
!stat.isDirectory() ||
|
||||
!sameDirectoryIdentity(identity, stat)
|
||||
)
|
||||
throw new Error('staging 目录身份发生变化,拒绝递归清理');
|
||||
fs.rmSync(staging, { recursive: true });
|
||||
}
|
||||
|
||||
export function assertPortableMacNode(output) {
|
||||
const dependencies = output
|
||||
.split(/\r?\n/u)
|
||||
.slice(1)
|
||||
.map((line) => line.trim().split(' (')[0])
|
||||
.filter(Boolean);
|
||||
if (
|
||||
dependencies.some(
|
||||
(dependency) =>
|
||||
!dependency.startsWith('/usr/lib/') &&
|
||||
!dependency.startsWith('/System/Library/'),
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'Node 链接了非系统动态库,不能作为便携运行时发布;请使用官方独立 Node 发行版',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function stageNodeRuntime(
|
||||
target,
|
||||
{
|
||||
nodePath = process.execPath,
|
||||
npmCli = process.env.npm_execpath,
|
||||
licensePath = process.env.AGC_NODE_LICENSE_PATH,
|
||||
destination = path.join(appRoot, 'src-tauri', 'resources', 'node-runtime'),
|
||||
execute = execFileSync,
|
||||
installedLicense = readInstalledNodeLicense,
|
||||
} = {},
|
||||
) {
|
||||
const native = targetRuntime(target);
|
||||
const node = fs.realpathSync(nodePath);
|
||||
const query = (args) =>
|
||||
execute(node, args, {
|
||||
encoding: 'utf8',
|
||||
timeout: 10_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
}).trim();
|
||||
const info = JSON.parse(
|
||||
query([
|
||||
'-p',
|
||||
'JSON.stringify({platform:process.platform,arch:process.arch,version:process.version})',
|
||||
]),
|
||||
);
|
||||
if (!/^v\d+\.\d+\.\d+$/u.test(info.version))
|
||||
throw new Error('发行 Node 必须使用稳定的三段版本');
|
||||
if (info.platform !== native.platform || info.arch !== native.arch) {
|
||||
throw new Error(
|
||||
`Node 运行时平台/架构与发布目标不一致:${info.platform}/${info.arch} → ${target}`,
|
||||
);
|
||||
}
|
||||
if (native.platform === 'darwin') {
|
||||
assertPortableMacNode(
|
||||
execute('/usr/bin/otool', ['-L', node], {
|
||||
encoding: 'utf8',
|
||||
timeout: 10_000,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const nodeDirectory = path.dirname(node);
|
||||
const npmCandidates = [
|
||||
npmCli,
|
||||
path.join(nodeDirectory, 'node_modules/npm/bin/npm-cli.js'),
|
||||
path.resolve(nodeDirectory, '../lib/node_modules/npm/bin/npm-cli.js'),
|
||||
];
|
||||
const cli = npmCandidates.find(
|
||||
(candidate) =>
|
||||
candidate &&
|
||||
fs.existsSync(candidate) &&
|
||||
path.basename(fs.realpathSync(candidate)) === 'npm-cli.js',
|
||||
);
|
||||
if (!cli) throw new Error('缺少与构建 Node 配套的 npm-cli.js');
|
||||
const npmRoot = path.resolve(path.dirname(fs.realpathSync(cli)), '..');
|
||||
const npmPackage = JSON.parse(
|
||||
fs.readFileSync(path.join(npmRoot, 'package.json'), 'utf8'),
|
||||
);
|
||||
if (
|
||||
npmPackage.name !== 'npm' ||
|
||||
!/^\d+\.\d+\.\d+$/u.test(npmPackage.version) ||
|
||||
query([cli, '--version']) !== npmPackage.version
|
||||
)
|
||||
throw new Error('npm 包身份或实际版本不匹配');
|
||||
const licenses = [
|
||||
licensePath,
|
||||
path.join(nodeDirectory, 'LICENSE'),
|
||||
path.join(nodeDirectory, 'LICENSE.txt'),
|
||||
path.resolve(nodeDirectory, '../LICENSE'),
|
||||
path.resolve(nodeDirectory, '../share/doc/node/LICENSE'),
|
||||
];
|
||||
const nodeLicense = licenses.find(
|
||||
(candidate) =>
|
||||
candidate && fs.existsSync(candidate) && fs.statSync(candidate).isFile(),
|
||||
);
|
||||
let license;
|
||||
let licenseName = 'NODE-LICENSE';
|
||||
if (nodeLicense) license = fs.readFileSync(nodeLicense, 'utf8');
|
||||
else if (native.platform === 'win32') {
|
||||
try {
|
||||
license = installedLicense(info.version.slice(1));
|
||||
licenseName = 'NODE-LICENSE.rtf';
|
||||
} catch {
|
||||
throw new Error(
|
||||
'缺少同版本受信任 Node 完整许可;请通过 AGC_NODE_LICENSE_PATH 指定本地发行版 LICENSE 文件',
|
||||
);
|
||||
}
|
||||
} else
|
||||
throw new Error(
|
||||
'缺少 Node 完整许可;请通过 AGC_NODE_LICENSE_PATH 指定本地发行版 LICENSE 文件',
|
||||
);
|
||||
if (
|
||||
!license.includes('Node.js') ||
|
||||
!license.includes('Permission is hereby granted')
|
||||
)
|
||||
throw new Error('Node LICENSE 不包含发行许可');
|
||||
if (!fs.statSync(path.join(npmRoot, 'LICENSE')).isFile())
|
||||
throw new Error('npm 缺少 LICENSE');
|
||||
// 临时同级目录完成后才替换资源;不污染 Node 安装或项目工作区。
|
||||
const requestedDestination = path.resolve(destination);
|
||||
if (requestedDestination === path.dirname(requestedDestination))
|
||||
throw new Error('运行时输出目录不能是文件系统根目录');
|
||||
fs.mkdirSync(path.dirname(requestedDestination), { recursive: true });
|
||||
const parent = fs.realpathSync(path.dirname(requestedDestination));
|
||||
const resolvedDestination = path.join(
|
||||
parent,
|
||||
path.basename(requestedDestination),
|
||||
);
|
||||
const destinationIdentity = replacementIdentity(resolvedDestination);
|
||||
const stagingPrefix = `${path.basename(resolvedDestination)}-staging-`;
|
||||
const staging = fs.mkdtempSync(path.join(parent, stagingPrefix));
|
||||
const stagingIdentity = fs.lstatSync(staging);
|
||||
try {
|
||||
const executable = native.platform === 'win32' ? 'node.exe' : 'node';
|
||||
fs.copyFileSync(node, path.join(staging, executable));
|
||||
fs.chmodSync(path.join(staging, executable), 0o755);
|
||||
fs.writeFileSync(path.join(staging, licenseName), license);
|
||||
fs.cpSync(npmRoot, path.join(staging, 'node_modules/npm'), {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
filter(source) {
|
||||
if (!inside(npmRoot, fs.realpathSync(source)))
|
||||
throw new Error('npm 资源链接越出包目录');
|
||||
// 安装目录里的个人 npm 配置或凭据不属于发行包。
|
||||
if (
|
||||
/^(?:\.npmrc|npmrc|\.env.*|auth\.json|\.git)$/u.test(
|
||||
path.basename(source),
|
||||
) ||
|
||||
/\.(?:pem|key)$/u.test(source)
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
for (const name of ['npm', 'npx']) {
|
||||
if (native.platform === 'win32') {
|
||||
fs.writeFileSync(
|
||||
path.join(staging, `${name}.cmd`),
|
||||
`@ECHO OFF\r\n"%~dp0node.exe" "%~dp0node_modules\\npm\\bin\\${name}-cli.js" %*\r\n`,
|
||||
);
|
||||
} else {
|
||||
fs.writeFileSync(
|
||||
path.join(staging, name),
|
||||
`#!/bin/sh\nbasedir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)\nexec "$basedir/node" "$basedir/node_modules/npm/bin/${name}-cli.js" "$@"\n`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
}
|
||||
}
|
||||
const files = Object.fromEntries(
|
||||
packageFiles(staging)
|
||||
.sort()
|
||||
.map((file) => [
|
||||
path.relative(staging, file).split(path.sep).join('/'),
|
||||
createHash('sha256').update(fs.readFileSync(file)).digest('hex'),
|
||||
]),
|
||||
);
|
||||
const manifest = {
|
||||
schemaVersion: nodeRuntimeSchema,
|
||||
...native,
|
||||
nodeVersion: info.version,
|
||||
npmVersion: npmPackage.version,
|
||||
files,
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(staging, 'manifest.json'),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
);
|
||||
if (
|
||||
fs.realpathSync(parent) !== parent ||
|
||||
!sameDirectoryIdentity(
|
||||
destinationIdentity,
|
||||
replacementIdentity(resolvedDestination),
|
||||
)
|
||||
)
|
||||
throw new Error('运行时目标身份发生变化,拒绝覆盖');
|
||||
if (destinationIdentity !== null)
|
||||
fs.rmSync(resolvedDestination, { recursive: true });
|
||||
fs.renameSync(staging, resolvedDestination);
|
||||
return manifest;
|
||||
} finally {
|
||||
cleanupStaging(staging, parent, stagingPrefix, stagingIdentity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import {
|
||||
assertPortableMacNode,
|
||||
readInstalledNodeLicense,
|
||||
stageNodeRuntime,
|
||||
targetRuntime,
|
||||
} from './stage-node-runtime.mjs';
|
||||
|
||||
function fixture(run) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-node-staging-test-'));
|
||||
try {
|
||||
const npm = path.join(root, 'source/node_modules/npm');
|
||||
fs.mkdirSync(path.join(npm, 'bin'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'source/node.exe'),
|
||||
'native executable fixture',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'source/LICENSE'),
|
||||
'Node.js\nPermission is hereby granted',
|
||||
);
|
||||
fs.writeFileSync(path.join(npm, 'LICENSE'), 'npm distribution license');
|
||||
fs.writeFileSync(
|
||||
path.join(npm, 'package.json'),
|
||||
JSON.stringify({ name: 'npm', version: '11.0.0' }),
|
||||
);
|
||||
for (const name of ['npm', 'npx'])
|
||||
fs.writeFileSync(path.join(npm, `bin/${name}-cli.js`), '// fixture');
|
||||
const options = {
|
||||
nodePath: path.join(root, 'source/node.exe'),
|
||||
npmCli: path.join(npm, 'bin/npm-cli.js'),
|
||||
destination: path.join(root, 'bundle'),
|
||||
installedLicense() {
|
||||
throw new Error('no installed fixture license');
|
||||
},
|
||||
execute(_file, args) {
|
||||
return args[0] === '-p'
|
||||
? JSON.stringify({
|
||||
platform: 'win32',
|
||||
arch: 'x64',
|
||||
version: 'v24.0.0',
|
||||
})
|
||||
: '11.0.0';
|
||||
},
|
||||
};
|
||||
return run(root, options);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('stages matching Node/npm and licenses with a complete integrity manifest', () =>
|
||||
fixture((_root, options) => {
|
||||
const manifest = stageNodeRuntime('x86_64-pc-windows-msvc', options);
|
||||
assert.equal(manifest.nodeVersion, 'v24.0.0');
|
||||
assert.equal(manifest.npmVersion, '11.0.0');
|
||||
assert.ok(manifest.files['NODE-LICENSE']);
|
||||
assert.ok(manifest.files['node_modules/npm/LICENSE']);
|
||||
for (const [file, digest] of Object.entries(manifest.files)) {
|
||||
assert.equal(
|
||||
createHash('sha256')
|
||||
.update(fs.readFileSync(path.join(options.destination, file)))
|
||||
.digest('hex'),
|
||||
digest,
|
||||
);
|
||||
}
|
||||
assert.match(
|
||||
fs.readFileSync(path.join(options.destination, 'npm.cmd'), 'utf8'),
|
||||
/%~dp0node\.exe/u,
|
||||
);
|
||||
}));
|
||||
|
||||
test('local npm configuration and credential files never enter the runtime bundle', () =>
|
||||
fixture((root, options) => {
|
||||
const npm = path.join(root, 'source/node_modules/npm');
|
||||
for (const name of [
|
||||
'.npmrc',
|
||||
'npmrc',
|
||||
'.env.local',
|
||||
'auth.json',
|
||||
'private.key',
|
||||
])
|
||||
fs.writeFileSync(path.join(npm, name), 'private-fixture');
|
||||
const manifest = stageNodeRuntime('x86_64-pc-windows-msvc', options);
|
||||
for (const name of [
|
||||
'.npmrc',
|
||||
'npmrc',
|
||||
'.env.local',
|
||||
'auth.json',
|
||||
'private.key',
|
||||
]) {
|
||||
assert.equal(manifest.files[`node_modules/npm/${name}`], undefined);
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(options.destination, 'node_modules/npm', name)),
|
||||
false,
|
||||
);
|
||||
}
|
||||
}));
|
||||
|
||||
test('rejects mismatched architecture before modifying the existing runtime', () =>
|
||||
fixture((_root, options) => {
|
||||
fs.mkdirSync(options.destination);
|
||||
fs.writeFileSync(path.join(options.destination, 'keep'), 'old');
|
||||
assert.throws(
|
||||
() => stageNodeRuntime('aarch64-apple-darwin', options),
|
||||
/平台\/架构/u,
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(options.destination, 'keep'), 'utf8'),
|
||||
'old',
|
||||
);
|
||||
}));
|
||||
|
||||
test('refuses to recursively replace an unrelated existing directory', () =>
|
||||
fixture((root, options) => {
|
||||
fs.mkdirSync(options.destination);
|
||||
fs.writeFileSync(path.join(options.destination, 'keep.txt'), 'user data');
|
||||
assert.throws(
|
||||
() => stageNodeRuntime('x86_64-pc-windows-msvc', options),
|
||||
/拒绝覆盖非本工具/u,
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(options.destination, 'keep.txt'), 'utf8'),
|
||||
'user data',
|
||||
);
|
||||
assert.equal(
|
||||
fs.readdirSync(root).some((name) => name.startsWith('bundle-staging-')),
|
||||
false,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(options.destination, 'manifest.json'),
|
||||
JSON.stringify({ schemaVersion: 'another-tool.v1', files: {} }),
|
||||
);
|
||||
assert.throws(
|
||||
() => stageNodeRuntime('x86_64-pc-windows-msvc', options),
|
||||
/拒绝覆盖/u,
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(options.destination, 'keep.txt'), 'utf8'),
|
||||
'user data',
|
||||
);
|
||||
}));
|
||||
|
||||
test('refuses a linked destination without touching the linked directory', () =>
|
||||
fixture((root, options) => {
|
||||
const linked = path.join(root, 'other-project');
|
||||
fs.mkdirSync(linked);
|
||||
fs.writeFileSync(path.join(linked, 'keep.txt'), 'user data');
|
||||
fs.symlinkSync(
|
||||
linked,
|
||||
options.destination,
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
);
|
||||
assert.throws(
|
||||
() => stageNodeRuntime('x86_64-pc-windows-msvc', options),
|
||||
/拒绝覆盖链接/u,
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(linked, 'keep.txt'), 'utf8'),
|
||||
'user data',
|
||||
);
|
||||
assert.equal(fs.lstatSync(options.destination).isSymbolicLink(), true);
|
||||
assert.equal(
|
||||
fs.readdirSync(root).some((name) => name.startsWith('bundle-staging-')),
|
||||
false,
|
||||
);
|
||||
}));
|
||||
|
||||
test('replaces only an empty directory or this tool runtime without unrelated files', () =>
|
||||
fixture((root, options) => {
|
||||
fs.mkdirSync(options.destination);
|
||||
stageNodeRuntime('x86_64-pc-windows-msvc', options);
|
||||
fs.writeFileSync(
|
||||
path.join(options.destination, 'node.exe'),
|
||||
'damaged previous build',
|
||||
);
|
||||
stageNodeRuntime('x86_64-pc-windows-msvc', options);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(options.destination, 'node.exe'), 'utf8'),
|
||||
'native executable fixture',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(options.destination, 'unrelated.txt'),
|
||||
'keep this',
|
||||
);
|
||||
assert.throws(
|
||||
() => stageNodeRuntime('x86_64-pc-windows-msvc', options),
|
||||
/未登记文件/u,
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(options.destination, 'unrelated.txt'), 'utf8'),
|
||||
'keep this',
|
||||
);
|
||||
assert.equal(
|
||||
fs.readdirSync(root).some((name) => name.startsWith('bundle-staging-')),
|
||||
false,
|
||||
);
|
||||
}));
|
||||
|
||||
test('missing npm and missing Node license fail closed without a partial bundle', () =>
|
||||
fixture((root, options) => {
|
||||
fs.rmSync(path.join(root, 'source/LICENSE'));
|
||||
assert.throws(
|
||||
() => stageNodeRuntime('x86_64-pc-windows-msvc', options),
|
||||
/缺少同版本受信任 Node 完整许可/u,
|
||||
);
|
||||
assert.equal(fs.existsSync(options.destination), false);
|
||||
fs.rmSync(path.join(root, 'source/node_modules/npm/bin/npm-cli.js'));
|
||||
assert.throws(
|
||||
() => stageNodeRuntime('x86_64-pc-windows-msvc', options),
|
||||
/npm-cli/u,
|
||||
);
|
||||
}));
|
||||
|
||||
test('installed MSI license requires matching version product manufacturer and verified signer', () => {
|
||||
const receipt = {
|
||||
productName: 'Node.js',
|
||||
version: '24.0.0',
|
||||
manufacturer: 'Node.js Foundation',
|
||||
signatureVerified: true,
|
||||
signer: 'OpenJS Foundation',
|
||||
format: 'rtf',
|
||||
content: '{\\rtf1 Node.js Permission is hereby granted}',
|
||||
};
|
||||
const read = (value) =>
|
||||
readInstalledNodeLicense('24.0.0', {
|
||||
execute: () => JSON.stringify(value),
|
||||
});
|
||||
assert.equal(read(receipt), receipt.content);
|
||||
for (const changed of [
|
||||
{ version: '23.0.0' },
|
||||
{ productName: 'other' },
|
||||
{ manufacturer: 'unknown' },
|
||||
{ signatureVerified: false },
|
||||
{ signer: 'other' },
|
||||
]) {
|
||||
assert.throws(() => read({ ...receipt, ...changed }), /不匹配/u);
|
||||
}
|
||||
assert.throws(
|
||||
() =>
|
||||
readInstalledNodeLicense('24.0.0', {
|
||||
execute() {
|
||||
throw new Error('no registered MSI');
|
||||
},
|
||||
}),
|
||||
/no registered MSI/,
|
||||
);
|
||||
});
|
||||
|
||||
test('matching installed license is preserved as original RTF and included in hashes', () =>
|
||||
fixture((root, options) => {
|
||||
fs.rmSync(path.join(root, 'source/LICENSE'));
|
||||
const content = '{\\rtf1 Node.js Permission is hereby granted}';
|
||||
options.installedLicense = (version) => {
|
||||
assert.equal(version, '24.0.0');
|
||||
return content;
|
||||
};
|
||||
const manifest = stageNodeRuntime('x86_64-pc-windows-msvc', options);
|
||||
assert.equal(
|
||||
fs.readFileSync(
|
||||
path.join(options.destination, 'NODE-LICENSE.rtf'),
|
||||
'utf8',
|
||||
),
|
||||
content,
|
||||
);
|
||||
assert.equal(
|
||||
manifest.files['NODE-LICENSE.rtf'],
|
||||
createHash('sha256').update(content).digest('hex'),
|
||||
);
|
||||
assert.equal(manifest.files['NODE-LICENSE'], undefined);
|
||||
}));
|
||||
|
||||
test('native target and macOS dynamic dependency policy reject nonportable Node', () => {
|
||||
assert.deepEqual(targetRuntime('aarch64-apple-darwin'), {
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
});
|
||||
// universal 必须失败关闭:只带宿主架构那一份运行时,Intel 上不可执行。
|
||||
assert.throws(
|
||||
() => targetRuntime('universal-apple-darwin'),
|
||||
/universal-apple-darwin/u,
|
||||
);
|
||||
assertPortableMacNode(
|
||||
'/node:\n\t/usr/lib/libSystem.B.dylib (compatibility version 1)\n',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertPortableMacNode(
|
||||
'/node:\n\t/opt/homebrew/opt/icu/lib/libicu.dylib (compatibility version 1)\n',
|
||||
),
|
||||
/非系统动态库/u,
|
||||
);
|
||||
});
|
||||
@@ -1192,7 +1192,7 @@ async function main() {
|
||||
function isDirectModuleExecution() {
|
||||
return Boolean(
|
||||
process.argv[1] &&
|
||||
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
|
||||
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
createHash,
|
||||
createPublicKey,
|
||||
verify as cryptoVerify,
|
||||
} from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* 更新包签名门禁:用产物里烘焙的 updater 公钥校验 `.sig`,
|
||||
* 防止「发布出去的更新包没人装得上」——客户端校验失败会直接拒绝安装,
|
||||
* 而且公钥发布后不可更换,所以必须在构建期、上传前就失败关闭。
|
||||
*
|
||||
* 格式说明(与 Tauri 2 的实际产出对齐,均为实测):
|
||||
* - `tauri.conf.json` 的 `plugins.updater.pubkey` 是「minisign 公钥文本」的 base64;
|
||||
* - 产物旁的 `<artifact>.sig` 是「minisign 签名文本」的 base64;
|
||||
* - 公钥 blob 42 字节(alg `Ed` + 8 字节 keyId + 32 字节 Ed25519 公钥);
|
||||
* - 签名 blob 74 字节(alg `Ed` 或 `ED` + 8 字节 keyId + 64 字节签名);
|
||||
* - Tauri 产出的是 `ED`:先对文件做 BLAKE2b-512,再对摘要做 Ed25519 签名。
|
||||
*/
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
const defaultTauriConfigPath = path.join(appRoot, 'src-tauri/tauri.conf.json');
|
||||
const defaultMacosConfigPath = path.join(
|
||||
appRoot,
|
||||
'src-tauri/tauri.macos.conf.json',
|
||||
);
|
||||
|
||||
const PUBLIC_KEY_ALGORITHM = 'Ed';
|
||||
const RAW_ALGORITHM = 'Ed';
|
||||
const PREHASHED_ALGORITHM = 'ED';
|
||||
|
||||
function unwrapMinisignText(value, label) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error(`${label} 为空`);
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.startsWith('untrusted comment:')) return trimmed;
|
||||
const decoded = Buffer.from(trimmed, 'base64').toString('utf8');
|
||||
if (!decoded.startsWith('untrusted comment:')) {
|
||||
throw new Error(`${label} 不是 minisign 内容(缺少 untrusted comment 头)`);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function contentLines(text) {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
}
|
||||
|
||||
/** 解析 updater 公钥(`tauri.conf.json` 里的 base64 值或 minisign 文本)。 */
|
||||
export function decodeUpdaterPublicKey(value, label = 'updater 公钥') {
|
||||
const lines = contentLines(unwrapMinisignText(value, label));
|
||||
if (lines.length < 2) throw new Error(`${label} 缺少密钥内容行`);
|
||||
const blob = Buffer.from(lines[1], 'base64');
|
||||
if (blob.length !== 42) {
|
||||
throw new Error(
|
||||
`${label} 长度异常:期望 42 字节,实际 ${blob.length} 字节`,
|
||||
);
|
||||
}
|
||||
const algorithm = blob.subarray(0, 2).toString('latin1');
|
||||
if (algorithm !== PUBLIC_KEY_ALGORITHM) {
|
||||
throw new Error(`${label} 算法不受支持:${algorithm}`);
|
||||
}
|
||||
return { algorithm, keyId: blob.subarray(2, 10), key: blob.subarray(10) };
|
||||
}
|
||||
|
||||
/** 解析 `.sig`(base64 值或 minisign 文本)。 */
|
||||
export function decodeUpdaterSignature(value, label = '更新包签名') {
|
||||
const lines = contentLines(unwrapMinisignText(value, label));
|
||||
if (lines.length < 2) throw new Error(`${label} 缺少签名内容行`);
|
||||
const blob = Buffer.from(lines[1], 'base64');
|
||||
if (blob.length !== 74) {
|
||||
throw new Error(
|
||||
`${label} 长度异常:期望 74 字节,实际 ${blob.length} 字节`,
|
||||
);
|
||||
}
|
||||
const algorithm = blob.subarray(0, 2).toString('latin1');
|
||||
if (algorithm !== RAW_ALGORITHM && algorithm !== PREHASHED_ALGORITHM) {
|
||||
throw new Error(`${label} 算法不受支持:${algorithm}`);
|
||||
}
|
||||
return {
|
||||
algorithm,
|
||||
keyId: blob.subarray(2, 10),
|
||||
signature: blob.subarray(10),
|
||||
trustedComment: lines[2] ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function publicKeyObject(rawKey) {
|
||||
return createPublicKey({
|
||||
key: { kty: 'OKP', crv: 'Ed25519', x: rawKey.toString('base64url') },
|
||||
format: 'jwk',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验更新包签名;任何不一致都抛错(调用方据此失败关闭)。
|
||||
*/
|
||||
export function verifyUpdaterSignature({
|
||||
artifactPath,
|
||||
signaturePath,
|
||||
pubkey,
|
||||
}) {
|
||||
const publicKey = decodeUpdaterPublicKey(pubkey);
|
||||
const signature = decodeUpdaterSignature(
|
||||
fs.readFileSync(signaturePath, 'utf8'),
|
||||
);
|
||||
if (!publicKey.keyId.equals(signature.keyId)) {
|
||||
throw new Error(
|
||||
`更新包签名与内置公钥的 keyId 不一致:公钥 ${publicKey.keyId.toString('hex')},签名 ${signature.keyId.toString('hex')};` +
|
||||
'签名私钥与产物内烘焙的公钥不是同一对,发布后客户端会拒绝安装',
|
||||
);
|
||||
}
|
||||
const payload = fs.readFileSync(artifactPath);
|
||||
const message =
|
||||
signature.algorithm === PREHASHED_ALGORITHM
|
||||
? createHash('blake2b512').update(payload).digest()
|
||||
: payload;
|
||||
if (
|
||||
!cryptoVerify(
|
||||
null,
|
||||
message,
|
||||
publicKeyObject(publicKey.key),
|
||||
signature.signature,
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`更新包签名校验失败:${path.basename(artifactPath)};该产物无法被客户端接受`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
algorithm: signature.algorithm,
|
||||
keyId: publicKey.keyId.toString('hex'),
|
||||
trustedComment: signature.trustedComment,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取该平台生效的 updater 公钥:macOS 配置可覆盖基础配置,与构建期行为一致。
|
||||
*/
|
||||
export function readUpdaterPubkey({
|
||||
configPath = defaultTauriConfigPath,
|
||||
platformConfigPath = defaultMacosConfigPath,
|
||||
} = {}) {
|
||||
const readPubkey = (file) => {
|
||||
if (!fs.existsSync(file)) return null;
|
||||
const config = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
return config?.plugins?.updater?.pubkey ?? null;
|
||||
};
|
||||
const pubkey = readPubkey(platformConfigPath) ?? readPubkey(configPath);
|
||||
if (!pubkey) throw new Error('未在 Tauri 配置中找到 plugins.updater.pubkey');
|
||||
return pubkey;
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
) {
|
||||
const [artifactPath, signaturePath = `${artifactPath}.sig`] =
|
||||
process.argv.slice(2);
|
||||
if (!artifactPath) {
|
||||
throw new Error(
|
||||
'用法:node verify-updater-signature.mjs <更新包> [<签名文件>]',
|
||||
);
|
||||
}
|
||||
const result = verifyUpdaterSignature({
|
||||
artifactPath,
|
||||
signaturePath,
|
||||
pubkey: readUpdaterPubkey(),
|
||||
});
|
||||
console.log(
|
||||
`[agc-macos] 更新包签名校验通过:${path.basename(artifactPath)}(alg=${result.algorithm},keyId=${result.keyId})`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createHash,
|
||||
generateKeyPairSync,
|
||||
randomBytes,
|
||||
sign as cryptoSign,
|
||||
} from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
decodeUpdaterPublicKey,
|
||||
decodeUpdaterSignature,
|
||||
readUpdaterPubkey,
|
||||
verifyUpdaterSignature,
|
||||
} from './verify-updater-signature.mjs';
|
||||
|
||||
/**
|
||||
* 用进程内生成的 Ed25519 密钥自造 minisign 结构,
|
||||
* 覆盖 Tauri 实际使用的 `ED`(BLAKE2b-512 预哈希)与 `Ed`(原文)两种模式。
|
||||
*/
|
||||
function createKeyMaterial() {
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
||||
const rawKey = Buffer.from(
|
||||
publicKey.export({ format: 'jwk' }).x,
|
||||
'base64url',
|
||||
);
|
||||
const keyId = randomBytes(8);
|
||||
const pubkey = Buffer.from(
|
||||
`untrusted comment: minisign public key: ${keyId.reverse().toString('hex').toUpperCase()}\n` +
|
||||
`${Buffer.concat([Buffer.from('Ed'), keyId, rawKey]).toString('base64')}\n`,
|
||||
).toString('base64');
|
||||
return { privateKey, keyId, rawKey, pubkey };
|
||||
}
|
||||
|
||||
function signFixture({ privateKey, keyId }, payload, algorithm) {
|
||||
const message =
|
||||
algorithm === 'ED'
|
||||
? createHash('blake2b512').update(payload).digest()
|
||||
: payload;
|
||||
const signature = cryptoSign(null, message, privateKey);
|
||||
const blob = Buffer.concat([Buffer.from(algorithm), keyId, signature]);
|
||||
const globalSignature = cryptoSign(null, blob, privateKey);
|
||||
return Buffer.from(
|
||||
'untrusted comment: signature from tauri secret key\n' +
|
||||
`${blob.toString('base64')}\n` +
|
||||
'trusted comment: timestamp:0\tfile:fixture\n' +
|
||||
`${globalSignature.toString('base64')}\n`,
|
||||
).toString('base64');
|
||||
}
|
||||
|
||||
function withFixture(run) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-sig-test-'));
|
||||
try {
|
||||
const artifactPath = path.join(directory, 'app.app.tar.gz');
|
||||
fs.writeFileSync(artifactPath, 'update payload');
|
||||
return run({ directory, artifactPath });
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('接受 Tauri 实际使用的 ED(BLAKE2b-512 预哈希)签名', () => {
|
||||
withFixture(({ directory, artifactPath }) => {
|
||||
const material = createKeyMaterial();
|
||||
const signaturePath = path.join(directory, 'app.app.tar.gz.sig');
|
||||
fs.writeFileSync(
|
||||
signaturePath,
|
||||
signFixture(material, fs.readFileSync(artifactPath), 'ED'),
|
||||
);
|
||||
const result = verifyUpdaterSignature({
|
||||
artifactPath,
|
||||
signaturePath,
|
||||
pubkey: material.pubkey,
|
||||
});
|
||||
assert.equal(result.algorithm, 'ED');
|
||||
assert.equal(result.keyId, material.keyId.toString('hex'));
|
||||
});
|
||||
});
|
||||
|
||||
test('接受原文 Ed 签名,两种算法互不通用', () => {
|
||||
withFixture(({ directory, artifactPath }) => {
|
||||
const material = createKeyMaterial();
|
||||
const payload = fs.readFileSync(artifactPath);
|
||||
const signaturePath = path.join(directory, 'app.app.tar.gz.sig');
|
||||
fs.writeFileSync(signaturePath, signFixture(material, payload, 'Ed'));
|
||||
assert.equal(
|
||||
verifyUpdaterSignature({
|
||||
artifactPath,
|
||||
signaturePath,
|
||||
pubkey: material.pubkey,
|
||||
}).algorithm,
|
||||
'Ed',
|
||||
);
|
||||
// 原文模式下签名的是别的载荷时必须失败:证明确实在校验内容而非只看结构。
|
||||
fs.writeFileSync(
|
||||
signaturePath,
|
||||
signFixture(material, Buffer.from('别的载荷'), 'Ed'),
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
verifyUpdaterSignature({
|
||||
artifactPath,
|
||||
signaturePath,
|
||||
pubkey: material.pubkey,
|
||||
}),
|
||||
/签名校验失败/u,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('产物被篡改时失败关闭', () => {
|
||||
withFixture(({ directory, artifactPath }) => {
|
||||
const material = createKeyMaterial();
|
||||
const signaturePath = path.join(directory, 'app.app.tar.gz.sig');
|
||||
fs.writeFileSync(
|
||||
signaturePath,
|
||||
signFixture(material, fs.readFileSync(artifactPath), 'ED'),
|
||||
);
|
||||
fs.writeFileSync(artifactPath, 'tampered payload');
|
||||
assert.throws(
|
||||
() =>
|
||||
verifyUpdaterSignature({
|
||||
artifactPath,
|
||||
signaturePath,
|
||||
pubkey: material.pubkey,
|
||||
}),
|
||||
/签名校验失败/u,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('签名私钥与内置公钥不是同一对时给出明确错误', () => {
|
||||
withFixture(({ directory, artifactPath }) => {
|
||||
const signing = createKeyMaterial();
|
||||
const baked = createKeyMaterial();
|
||||
const signaturePath = path.join(directory, 'app.app.tar.gz.sig');
|
||||
fs.writeFileSync(
|
||||
signaturePath,
|
||||
signFixture(signing, fs.readFileSync(artifactPath), 'ED'),
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
verifyUpdaterSignature({
|
||||
artifactPath,
|
||||
signaturePath,
|
||||
pubkey: baked.pubkey,
|
||||
}),
|
||||
/keyId 不一致/u,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('公钥或签名格式非法时拒绝解析', () => {
|
||||
assert.throws(() => decodeUpdaterPublicKey(''), /为空/u);
|
||||
assert.throws(
|
||||
() => decodeUpdaterPublicKey('bm90IGEgbWluaXNpZ24ga2V5'),
|
||||
/不是 minisign 内容/u,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
decodeUpdaterPublicKey(
|
||||
Buffer.from('untrusted comment: x\nAAAA\n').toString('base64'),
|
||||
),
|
||||
/长度异常/u,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
decodeUpdaterSignature(
|
||||
Buffer.from('untrusted comment: x\nAAAA\n').toString('base64'),
|
||||
),
|
||||
/长度异常/u,
|
||||
);
|
||||
});
|
||||
|
||||
test('仓库里配置的 updater 公钥可被解析(两平台共用)', () => {
|
||||
const decoded = decodeUpdaterPublicKey(readUpdaterPubkey());
|
||||
assert.equal(decoded.algorithm, 'Ed');
|
||||
assert.equal(decoded.key.length, 32);
|
||||
});
|
||||
Reference in New Issue
Block a user