Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb1df3c6da | |||
| e230d9dc32 | |||
| b10a2d5d79 | |||
| a35cdcc68c | |||
| 37189c9252 | |||
| e1c8edd6bf | |||
| 8a3daff55b | |||
| 97d8a6c39a | |||
| 70271b408e | |||
| 623e007fae | |||
| fc0ce4ee5f | |||
| c1d26f11df | |||
| b1cadd0cc8 | |||
| 261228ed3f | |||
| 8c17e40d7d | |||
| 237e440057 | |||
| fc46cabb75 | |||
| 762f037150 | |||
| 48985d3447 |
@@ -235,6 +235,10 @@ VITE_DEBUG_MODE=""
|
||||
# This is read by api-server and exposed through /api/runtime/frontend-config.
|
||||
GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false"
|
||||
|
||||
# 官网客户端下载检测渠道:dev、release 或自定义渠道;修改后重启 API 服务。
|
||||
# Windows/macOS 是系统维度,不填写 dev-win/dev-mac。
|
||||
GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL="dev"
|
||||
|
||||
# Optional: official VikingDB credentials for regenerating build-tag similarities
|
||||
# with the Python embedding script. The script auto-loads `.env.local` and uses
|
||||
# the fixed `bge-large-zh` embedding model.
|
||||
|
||||
@@ -47,6 +47,8 @@ temp*build*/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/codex-package.json
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/manifest.json
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/NOTICE.md
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/darwin-arm64/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/darwin-x64/
|
||||
/plugins/agc-cocos-editor/native/payload/
|
||||
/plugins/agc-unity-editor/dotnet/**/bin/
|
||||
/plugins/agc-unity-editor/dotnet/**/obj/
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
|
||||
- 本文件只保留 Agent 进入仓库后必须立即遵守的最高优先级规则;完整执行细则见 [`docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md`](docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md)。
|
||||
- 团队级长期项目记忆位于 [`docs/project-memory/`](docs/project-memory/),供 3 名开发人员和各自本地 Agent 通过 Git 同步。
|
||||
- [`.codex/`](.codex/) 只保存仓库级 Codex 工具资源,例如 skills、plugins、hooks 和配置模板;长期项目知识不要写入 `.codex/`。
|
||||
- [`.codex/`](.codex/) 保存仓库级 Codex 工具资源,例如 skills、plugins、hooks 和配置模板;长期项目知识写入 `docs/` 与 `docs/project-memory/`。
|
||||
- 若 `docs/project-memory/shared-memory/` 与当前代码或最新 `docs/` 冲突,以代码和最新 `docs/` 为准,并同步修正过期共享记忆。
|
||||
|
||||
## 开始任务前
|
||||
|
||||
- 先写清一句话交付结果、验收判据和不做项,再按“必须项 / 风险项 / 可选项”排序;优先完成修改、定向验证和边界检查组成的最小闭环。设置时间盒和检查点,新增发现只有在影响交付判据时才扩大范围,否则记录为后续事项;工具探测、历史整理或验证便利不能自行改变任务目标。
|
||||
- 先写清一句话交付结果、验收判据和修改范围,再按“必须项 / 风险项 / 可选项”排序;优先完成修改、定向验证和边界检查组成的最小闭环。设置时间盒和检查点,新增发现只有在影响交付判据时才扩大范围,其余记录为后续事项。
|
||||
- Agent 可见内容直接描述当前任务、输入和成功条件,细节按调用需要提供。
|
||||
- 简单自包含任务可以直接执行;复杂开发、跨模块修改、后端 / UI / 文档体系调整前,按顺序读取:
|
||||
1. 本文件。
|
||||
2. [`docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md`](docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md)。
|
||||
@@ -23,24 +24,20 @@
|
||||
|
||||
- 禁止提交个人 `~/.codex` 配置、`.env`、API Key、Token、Cookie、会话记录、认证文件、本地私密路径、构建产物、日志、缓存和数据库 dump。
|
||||
- 不要在 `.gitignore` 中新增 `.env.local`。
|
||||
- 不要擅自把现有中文文案、注释、剧情或文档改写成英文;看到中文乱码时先确认真实编码,不要沿用乱码或用英文替换。
|
||||
- 修改包含中文的文件时优先局部补丁,避免整文件重写;修改后优先运行仓库编码检查。
|
||||
- 后续新增 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`;历史文档不要求批量重命名,除非本次任务明确涉及。
|
||||
- 现有中文文案、注释、剧情和文档保持中文;看到乱码时先确认真实编码并恢复正确中文。
|
||||
- 修改包含中文的文件时优先局部补丁;修改后优先运行仓库编码检查。
|
||||
- 新增 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`。
|
||||
- 工程修改要同步更新对应 `docs/` 文档;产生长期有效的架构约定、接口变化、排障经验、开发流程或协作规则时,同步更新 `docs/project-memory/shared-memory/`。
|
||||
- 默认保持系统简洁:优先复用、修改、扩展现有系统、页面和公共组件,不新建平行系统或平行页面。
|
||||
- UI 开发优先复用现有公共组件;发现跨页面或跨端重复的视觉/交互模式时,先抽取到 `packages/shared` 共享组件库并让现有页面迁移使用,禁止在业务页复制同类 UI。共享组件只承载通用表现与交互,不下沉领域规则、后端副作用或正式业务状态。
|
||||
- 对已明确退役且不存在现役调用方、公开契约、持久化数据、活跃实例或迁移要求的对象,坚持“四不写”:
|
||||
1. 不写历史兼容代码。
|
||||
2. 不写用于维持退役行为的防御性兼容测试。
|
||||
3. 不写仅说明其曾存在或已删除的墓碑注释。
|
||||
4. 不写仅记录其已删除的墓碑文档;直接将权威文档更新为当前状态。
|
||||
- 公开 API、持久化数据、SpacetimeDB schema、跨版本重放、活跃实例和正式迁移不适用“四不写”;必要兼容应最小化、白名单化并配套契约或迁移测试,迁移完成后同步删除兼容实现与对应测试。
|
||||
- UI 面板中不要默认写功能说明、规则描述或开发解释文案;移动端优先,同时保证网页端可正常显示和操作。
|
||||
- 点击按钮弹出独立面板的设计,不要实现成在当前面板下面追加内容。
|
||||
- 默认保持系统简洁:优先复用、修改、扩展现有系统、页面和公共组件。
|
||||
- UI 开发优先复用现有公共组件;发现跨页面或跨端重复的视觉/交互模式时,先抽取到 `packages/shared` 共享组件库并让现有页面迁移使用。共享组件承载通用表现与交互,领域规则、后端副作用和正式业务状态由后端负责。
|
||||
- 对已明确退役且不存在现役调用方、公开契约、持久化数据、活跃实例或迁移要求的对象,直接清理实现、专属测试和说明,将权威文档更新为当前状态;历史由 Git 保存。
|
||||
- 公开 API、持久化数据、SpacetimeDB schema、跨版本重放、活跃实例和正式迁移按实际需求保留最小、白名单化的兼容,并配套契约或迁移测试;迁移完成后同步删除兼容实现与对应测试。
|
||||
- UI 面板优先呈现任务内容与操作,说明按当前操作需要提供;移动端优先,同时保证网页端可正常显示和操作。
|
||||
- 点击按钮弹出的独立面板使用弹窗、抽屉、popover 或页面级 portal。
|
||||
|
||||
## 任务路由
|
||||
|
||||
- Issue 使用自托管 Gitea;优先用 Gitea UI/API 或 `tea` CLI,不使用 GitHub `gh` 或 GitLab `glab`,除非仓库已迁移。默认 triage 标签:`needs-triage`、`needs-info`、`ready-for-agent`、`ready-for-human`、`wontfix`。
|
||||
- Issue 使用自托管 Gitea;优先用 Gitea UI/API 或 `tea` CLI。默认 triage 标签:`needs-triage`、`needs-info`、`ready-for-agent`、`ready-for-human`、`wontfix`。
|
||||
- 需要仓库级 Codex skills/plugins 时,再读取 [`.codex/README.md`](.codex/README.md)。
|
||||
- 涉及 AI 游戏创作独立 App、多智能体 Runtime、本地项目产物或本地 HTTP 预览时,先读取 [`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`](docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)。
|
||||
- 新增、补齐、迁移或重构玩法入口、玩法类型、创作工作台、生成页、结果页、发布、运行态、作品架、广场或公开 read model 前,必须读取并按 [`genarrative-play-type-integration`](.codex/skills/genarrative-play-type-integration/SKILL.md) 执行。
|
||||
@@ -50,10 +47,10 @@
|
||||
## 后端红线
|
||||
|
||||
- 后端最新技术约束以 [`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`](docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md) 为准。
|
||||
- 后端路线固定为 `server-rs + Axum + SpacetimeDB`;旧 `server-node`、Express、PostgreSQL、Go 服务端、`maincloud` / `Maincloud` / `MAINCLOUD` 只作为历史残留,不作为兼容目标。
|
||||
- 后端路线固定为 `server-rs + Axum + SpacetimeDB`。
|
||||
- DDD 分层边界按总纲执行:领域规则沉到 `module-*`,SpacetimeDB 表和事务编排留在 `spacetime-module`,后端访问 SpacetimeDB 统一经 `spacetime-client` facade,HTTP/SSE/BFF 留在 `api-server`,外部副作用留在 `platform-*`,前后端 DTO 留在 `shared-contracts`。
|
||||
- 前端只做表现、交互和临时 UI 状态,不承接正式业务真相,不绕过后端投影或后端 API 直接实现业务规则。
|
||||
- 契约、路由、DTO 去留和 breaking change 以当前后端架构文档、`server-rs/crates/api-server/src/app.rs`、`shared-contracts` 和 `packages/shared` 为准;不得在前端、`api-server` 或临时兼容层中重新发明旧接口。
|
||||
- 前端负责表现、交互和临时 UI 状态;正式业务状态与规则以后端投影和后端 API 为准。
|
||||
- 契约、路由、DTO 去留和 breaking change 以当前后端架构文档、`server-rs/crates/api-server/src/app.rs`、`shared-contracts` 和 `packages/shared` 为准。
|
||||
- 凡修改 `/api/external/v1` 的路由、HTTP 方法、请求 / 响应 DTO、请求头、状态码、鉴权或异步语义,必须在同一次变更中同步更新权威契约 [`docs/openapi/genarrative-external-v1.openapi.json`](docs/openapi/genarrative-external-v1.openapi.json) 及对应契约测试;Rust 实现与 OpenAPI 未保持一致时任务不得视为完成。
|
||||
- SpacetimeDB 已有表新增字段时,字段必须放在 Rust 表结构体最后,并设置明确默认值;需要删除、改名、重排或改类型时,必须先询问用户并确认迁移计划。
|
||||
- 修改 SpacetimeDB schema 后必须同步 `migration.rs`、表目录和生成绑定,并运行 `npm run check:spacetime-schema`。
|
||||
|
||||
@@ -154,6 +154,27 @@ test('灰度发布页可通过功能入口生成画布 Agent Gate Key', async ()
|
||||
);
|
||||
});
|
||||
|
||||
test('灰度发布页可选择模板库并默认启用零比例灰度', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
await screen.findByRole('button', { name: 'editor.new-toolbar' });
|
||||
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), ['agc']);
|
||||
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
||||
'agc:template-library',
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText('Gate Key 目标') as HTMLSelectElement).value,
|
||||
).toBe('template-library');
|
||||
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
|
||||
true,
|
||||
);
|
||||
expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe(
|
||||
'0',
|
||||
);
|
||||
});
|
||||
|
||||
test('灰度发布页保存时转换数组和百分比', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
|
||||
|
||||
@@ -27,9 +27,17 @@ interface GateTargetOption {
|
||||
|
||||
const GATE_PREFIX_LABELS: Record<string, string> = {
|
||||
'image-editor': '画布',
|
||||
agc: '客户端',
|
||||
};
|
||||
|
||||
const FIXED_GATE_TARGETS: GateTargetOption[] = [
|
||||
{
|
||||
prefix: 'agc',
|
||||
suffix: 'template-library',
|
||||
key: 'agc:template-library',
|
||||
label: '模板库',
|
||||
description: '客户端模板库灰度',
|
||||
},
|
||||
{
|
||||
prefix: 'image-editor',
|
||||
suffix: 'agent-sidebar',
|
||||
@@ -180,7 +188,7 @@ export function AdminGrayReleaseConfigPage({
|
||||
setSelectedGateKey('');
|
||||
setGatePrefix(option.prefix);
|
||||
setGateKey(option.key);
|
||||
setEnabled(false);
|
||||
setEnabled(option.key === 'agc:template-library');
|
||||
setRolloutPercent('0');
|
||||
setAllowUserIds('');
|
||||
setAllowUserTags('');
|
||||
|
||||
@@ -957,9 +957,6 @@ async function runInteractiveCargo(cliArguments, setActiveChild) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 立项策划跑 standard 档,`agent.delegate` 这类动作按项目权限策略必须逐个确认,
|
||||
// 而确认和问询都只从 CLI 的 stdin 读。自主构建档没有这一步,所以只有 --plan 需要
|
||||
// 一个把「人坐在终端前敲 approve」自动化掉的应答器;判据本身仍然走后端确认命令。
|
||||
const swarmConfirmationPromptPattern = /输入 approve 或 reject:$/u;
|
||||
const swarmUserInputPromptPattern = /请选择 1-\d+,或直接输入其他答案:$/u;
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } 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,
|
||||
resolveReleaseContext,
|
||||
resolveReleasePartition,
|
||||
runTauriBuild,
|
||||
} from './build-release.mjs';
|
||||
import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs';
|
||||
import {
|
||||
readUpdaterPubkey,
|
||||
verifyUpdaterSignature,
|
||||
} from './verify-updater-signature.mjs';
|
||||
|
||||
/**
|
||||
* AGC macOS 分区(`<channel>-mac`)发布入口:构建 universal 包 → 双架构 smoke → 生成 universal DMG
|
||||
* → 生成分区清单 latest.json → 用产物内烘焙的公钥验签 → 按 dry-run 决定是否上传 OSS。
|
||||
*
|
||||
* 边界:
|
||||
* - Apple 签名与公证暂缺,产物为未签名 + 未公证(`--no-sign`),必须显式记录而非静默通过;
|
||||
* - 更新包签名(TAURI_SIGNING_PRIVATE_KEY,minisign)是硬需求:缺了客户端一律拒绝安装,
|
||||
* 因此构建前要求凭据存在,构建后用内置公钥复核 `.sig` 才允许继续上传;
|
||||
* - 未通过验签绝不写 OSS:上传顺序为更新包、签名、首装包,全部成功后才覆盖渠道清单指针。
|
||||
*/
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
const repoRoot = path.resolve(appRoot, '../..');
|
||||
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');
|
||||
const context = resolveReleaseContext(['--target=universal-apple-darwin']);
|
||||
const partition = resolveReleasePartition(context.channel, context.target);
|
||||
const version = await prepareReleaseVersion(context);
|
||||
|
||||
const args = [
|
||||
'--target=universal-apple-darwin',
|
||||
'--bundles',
|
||||
'app',
|
||||
'--ci',
|
||||
'--no-sign',
|
||||
// 基础配置已开启;这里显式声明,避免被其它配置来源关掉后静默失去更新能力。
|
||||
'--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/陶泥儿.app');
|
||||
for (const architecture of ['arm64', 'x86_64']) {
|
||||
command(process.execPath, [
|
||||
path.join(appRoot, 'scripts/check-macos-bundle.mjs'),
|
||||
app,
|
||||
architecture,
|
||||
'--universal',
|
||||
]);
|
||||
}
|
||||
|
||||
// DMG 放在 bundle 根目录下:渠道清单的首装包选择会扫描该目录,命名必须匹配 `_<version>_universal.dmg`。
|
||||
const dmgDirectory = path.join(context.bundleRoot, 'macos');
|
||||
fs.mkdirSync(dmgDirectory, { recursive: true });
|
||||
const dmg = path.join(dmgDirectory, `陶泥儿_${version}_universal.dmg`);
|
||||
const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-ci-dmg-'));
|
||||
try {
|
||||
command('ditto', [app, path.join(stage, '陶泥儿.app')]);
|
||||
fs.symlinkSync('/Applications', path.join(stage, 'Applications'));
|
||||
command('hdiutil', [
|
||||
'create',
|
||||
'-volname',
|
||||
'陶泥儿',
|
||||
'-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),
|
||||
'首装包必须锁定本次生成的 universal 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();
|
||||
fs.writeFileSync(
|
||||
path.join(artifacts, 'build-manifest.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version,
|
||||
commit,
|
||||
target: context.target,
|
||||
channel: context.channel,
|
||||
// Apple 签名与公证暂缺:显式记录为未验证项,不静默通过。
|
||||
appleSigned: false,
|
||||
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',
|
||||
},
|
||||
smokes: ['arm64', 'x86_64'],
|
||||
intelSmoke: process.arch === 'arm64' ? 'Rosetta' : 'native',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
console.log(
|
||||
dryRun
|
||||
? `[agc-macos] dry-run 完成:${partition} 分区产物与清单已生成,未写入 OSS`
|
||||
: `[agc-macos] ${partition} 分区更新包、签名、首装包与清单已上传 OSS`,
|
||||
);
|
||||
@@ -42,16 +42,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}`);
|
||||
@@ -68,7 +64,7 @@ export function resolveReleaseContext(args = [], env = process.env) {
|
||||
);
|
||||
return Object.freeze({
|
||||
target,
|
||||
channel: resolveReleaseChannel(env, target),
|
||||
channel: resolveReleaseChannel(env),
|
||||
bundleRoot: path.join(
|
||||
appRoot,
|
||||
'src-tauri',
|
||||
@@ -87,14 +83,14 @@ const cargoLockPath = path.join(appRoot, 'src-tauri', 'Cargo.lock');
|
||||
const defaultOssBaseUrl =
|
||||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc';
|
||||
|
||||
/**
|
||||
* 发布渠道 → 目标平台。渠道名会进入 OSS 路径并烘焙进客户端端点,
|
||||
* 一旦发布就不能改名(改名等于已发布客户端再也找不到更新)。
|
||||
*/
|
||||
const releaseChannels = {
|
||||
'dev-win': 'windows',
|
||||
'dev-mac': 'darwin',
|
||||
};
|
||||
const reservedChannelNames = new Set([
|
||||
'win',
|
||||
'mac',
|
||||
'windows',
|
||||
'macos',
|
||||
'darwin',
|
||||
'linux',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须
|
||||
@@ -162,46 +158,45 @@ export function resolveReleasePlatform(target = defaultTarget()) {
|
||||
throw new Error(`不支持的发布目标:${target}`);
|
||||
}
|
||||
|
||||
export function resolveReleaseChannel(
|
||||
env = process.env,
|
||||
target = defaultTarget(),
|
||||
) {
|
||||
const platform = resolveReleasePlatform(target);
|
||||
const requested = env.AGC_UPDATE_CHANNEL?.trim();
|
||||
if (requested) {
|
||||
const channelPlatform = releaseChannels[requested];
|
||||
if (!channelPlatform) {
|
||||
throw new Error(
|
||||
`未知发布渠道 ${requested};当前支持:${Object.keys(releaseChannels).join('、')}`,
|
||||
);
|
||||
}
|
||||
if (channelPlatform !== platform) {
|
||||
throw new Error(
|
||||
`渠道 ${requested} 只能用于 ${channelPlatform} 目标,当前构建目标为 ${target}`,
|
||||
);
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
const defaultChannel = Object.entries(releaseChannels).find(
|
||||
([, channelPlatform]) => channelPlatform === platform,
|
||||
)?.[0];
|
||||
if (!defaultChannel) {
|
||||
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(
|
||||
`目标 ${target} 没有默认发布渠道,请显式设置 AGC_UPDATE_CHANNEL`,
|
||||
'发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道',
|
||||
);
|
||||
}
|
||||
return defaultChannel;
|
||||
return channel;
|
||||
}
|
||||
|
||||
export function updateManifestUrl(channel = resolveReleaseChannel()) {
|
||||
return `${ossBaseUrl()}/${channel}/latest.json`;
|
||||
/** 系统分区延续已发布客户端端点,渠道本身不包含系统。 */
|
||||
export function resolveReleasePartition(
|
||||
channel = resolveReleaseChannel(),
|
||||
target = defaultTarget(),
|
||||
) {
|
||||
channel = resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel });
|
||||
validateReleaseTarget(target);
|
||||
return `${channel}-${resolveReleasePlatform(target) === 'windows' ? 'win' : 'mac'}`;
|
||||
}
|
||||
|
||||
export function updateManifestUrl(
|
||||
channel = resolveReleaseChannel(),
|
||||
target = defaultTarget(),
|
||||
) {
|
||||
return `${ossBaseUrl()}/${resolveReleasePartition(channel, target)}/latest.json`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单架构产物只登记实际目标,不能把同一原生资源映射为另一架构。
|
||||
* 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')) {
|
||||
@@ -245,8 +240,8 @@ async function readManifestVersion(manifestUrl, label) {
|
||||
}
|
||||
|
||||
/** 上一次发布的渠道清单:拿版本做高水位、拿 commit 生成自动更新摘要。 */
|
||||
async function readRemoteChannelManifest(channel = resolveReleaseChannel()) {
|
||||
return fetchManifest(updateManifestUrl(channel), 'OSS 渠道清单');
|
||||
async function readRemoteChannelManifest(channel, target) {
|
||||
return fetchManifest(updateManifestUrl(channel, target), 'OSS 渠道清单');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -258,14 +253,17 @@ async function readRemoteChannelManifest(channel = resolveReleaseChannel()) {
|
||||
*/
|
||||
export async function resolvePreviousReleaseCommit(
|
||||
channel = resolveReleaseChannel(),
|
||||
{ override = process.env.AGC_UPDATE_PREVIOUS_COMMIT } = {},
|
||||
{
|
||||
override = process.env.AGC_UPDATE_PREVIOUS_COMMIT,
|
||||
target = defaultTarget(),
|
||||
} = {},
|
||||
) {
|
||||
const explicit = override?.trim();
|
||||
if (explicit && /^[0-9a-f]{7,40}$/u.test(explicit)) {
|
||||
return explicit;
|
||||
}
|
||||
try {
|
||||
const manifest = await readRemoteChannelManifest(channel);
|
||||
const manifest = await readRemoteChannelManifest(channel, target);
|
||||
const commit =
|
||||
typeof manifest?.commit === 'string' ? manifest.commit.trim() : '';
|
||||
return /^[0-9a-f]{7,40}$/u.test(commit) ? commit : null;
|
||||
@@ -287,12 +285,14 @@ export async function resolvePreviousReleaseCommit(
|
||||
*/
|
||||
export async function resolveRemoteHighWaterVersion(
|
||||
channel = resolveReleaseChannel(),
|
||||
target = defaultTarget(),
|
||||
) {
|
||||
const channelVersion = await readManifestVersion(
|
||||
updateManifestUrl(channel),
|
||||
updateManifestUrl(channel, target),
|
||||
'OSS 渠道清单',
|
||||
);
|
||||
if (channel !== 'dev-win') return channelVersion;
|
||||
if (channel !== 'dev' || resolveReleasePlatform(target) !== 'windows')
|
||||
return channelVersion;
|
||||
const legacyVersion = await readManifestVersion(
|
||||
legacyBridgeManifestUrl(),
|
||||
'OSS 迁移指针',
|
||||
@@ -310,9 +310,9 @@ function replaceVersionLine(source, version, pattern, label) {
|
||||
}
|
||||
|
||||
export async function prepareReleaseVersion(context = resolveReleaseContext()) {
|
||||
const { channel } = context;
|
||||
const { channel, target } = context;
|
||||
const localVersion = parseVersion(readPackageJson().version, '本地版本');
|
||||
const remoteVersion = await resolveRemoteHighWaterVersion(channel);
|
||||
const remoteVersion = await resolveRemoteHighWaterVersion(channel, target);
|
||||
const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim();
|
||||
const nextVersion = requestedVersion
|
||||
? parseVersion(requestedVersion, '指定版本')
|
||||
@@ -401,24 +401,27 @@ export function buildTauriBuildArguments(
|
||||
}
|
||||
|
||||
/** 渠道端点必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道。 */
|
||||
export function createChannelConfig(channel = resolveReleaseChannel()) {
|
||||
export function createChannelConfig(
|
||||
channel = resolveReleaseChannel(),
|
||||
target = defaultTarget(),
|
||||
) {
|
||||
return {
|
||||
plugins: {
|
||||
updater: {
|
||||
endpoints: [updateManifestUrl(channel)],
|
||||
endpoints: [updateManifestUrl(channel, target)],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function writeChannelConfigFile(channel) {
|
||||
function writeChannelConfigFile(channel, target) {
|
||||
const configPath = path.join(
|
||||
os.tmpdir(),
|
||||
`agc-tauri-channel-${channel}.json`,
|
||||
`agc-tauri-channel-${channel}-${target}.json`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`${JSON.stringify(createChannelConfig(channel), null, 2)}\n`,
|
||||
`${JSON.stringify(createChannelConfig(channel, target), null, 2)}\n`,
|
||||
);
|
||||
return configPath;
|
||||
}
|
||||
@@ -435,8 +438,8 @@ export function runTauriBuild(
|
||||
throw new Error('构建参数与发布上下文目标不一致');
|
||||
}
|
||||
const tauriArguments = buildTauriBuildArguments(args, context.target);
|
||||
const { channel } = context;
|
||||
const configPath = writeChannelConfigFile(channel);
|
||||
const { channel, target } = context;
|
||||
const configPath = writeChannelConfigFile(channel, target);
|
||||
console.log(
|
||||
`[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`,
|
||||
);
|
||||
@@ -512,6 +515,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';
|
||||
@@ -552,7 +567,7 @@ export function createUpdateManifest(
|
||||
artifactPath,
|
||||
{
|
||||
target = defaultTarget(),
|
||||
channel = resolveReleaseChannel(process.env, target),
|
||||
channel = resolveReleaseChannel(),
|
||||
publishedAt = new Date().toISOString(),
|
||||
notes = readReleaseNotes(),
|
||||
commit = readHeadCommit(),
|
||||
@@ -560,7 +575,7 @@ export function createUpdateManifest(
|
||||
} = {},
|
||||
) {
|
||||
validateReleaseTarget(target);
|
||||
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }, target);
|
||||
const partition = resolveReleasePartition(channel, target);
|
||||
const signature = readUpdaterSignature(artifactPath);
|
||||
const version = readPackageJson().version;
|
||||
const firstInstallArtifact = selectFirstInstallArtifact(
|
||||
@@ -568,8 +583,8 @@ export function createUpdateManifest(
|
||||
{ target, version, artifact: artifactPath },
|
||||
);
|
||||
const fileName = path.basename(artifactPath);
|
||||
const url = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`;
|
||||
const downloadUrl = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(path.basename(firstInstallArtifact))}`;
|
||||
const url = `${ossBaseUrl()}/${partition}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`;
|
||||
const downloadUrl = `${ossBaseUrl()}/${partition}/${encodeURIComponent(version)}/${encodeURIComponent(path.basename(firstInstallArtifact))}`;
|
||||
const platforms = {};
|
||||
const downloads = {};
|
||||
for (const key of resolveManifestPlatformKeys(target)) {
|
||||
@@ -702,14 +717,22 @@ export function formatRecentReleaseNotes(commits) {
|
||||
/** 旧协议(sha256)清单:只用于把已发布客户端带到新渠道协议,一个版本周期后整条删除。 */
|
||||
export function createLegacyUpdateManifest(
|
||||
artifactPath,
|
||||
{ channel = resolveReleaseChannel(), notes = readReleaseNotes() } = {},
|
||||
{
|
||||
channel = resolveReleaseChannel(),
|
||||
target = defaultTarget(),
|
||||
notes = readReleaseNotes(),
|
||||
} = {},
|
||||
) {
|
||||
const partition = resolveReleasePartition(channel, target);
|
||||
if (partition !== 'dev-win') {
|
||||
throw new Error('旧协议迁移清单只属于 dev 渠道的 Windows 系统');
|
||||
}
|
||||
const bytes = fs.readFileSync(artifactPath);
|
||||
const version = readPackageJson().version;
|
||||
const fileName = path.basename(artifactPath);
|
||||
return {
|
||||
version,
|
||||
downloadUrl: `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`,
|
||||
downloadUrl: `${ossBaseUrl()}/${partition}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`,
|
||||
sha256: createHash('sha256').update(bytes).digest('hex'),
|
||||
size: bytes.length,
|
||||
...(notes ? { releaseNotes: notes } : {}),
|
||||
@@ -731,7 +754,9 @@ export async function generateUpdateManifest(
|
||||
artifact,
|
||||
});
|
||||
const manualNotes = readReleaseNotes();
|
||||
const previousCommit = await resolvePreviousReleaseCommit(channel);
|
||||
const previousCommit = await resolvePreviousReleaseCommit(channel, {
|
||||
target,
|
||||
});
|
||||
const commits = collectReleaseCommits(previousCommit);
|
||||
const recentCommits = previousCommit ? null : collectRecentReleaseCommits();
|
||||
const notes =
|
||||
@@ -757,8 +782,8 @@ export async function generateUpdateManifest(
|
||||
notes ? `${notes}\n` : '(本次没有可用的更新摘要)\n',
|
||||
);
|
||||
const legacyManifest =
|
||||
channel === 'dev-win'
|
||||
? createLegacyUpdateManifest(artifact, { channel, notes })
|
||||
channel === 'dev' && resolveReleasePlatform(target) === 'windows'
|
||||
? createLegacyUpdateManifest(artifact, { channel, target, notes })
|
||||
: null;
|
||||
const legacyManifestPath = legacyManifest
|
||||
? path.join(bundleRoot, 'legacy-latest.json')
|
||||
@@ -789,6 +814,7 @@ export async function generateUpdateManifest(
|
||||
}
|
||||
return {
|
||||
channel,
|
||||
target,
|
||||
artifact,
|
||||
downloadArtifact,
|
||||
manifest,
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
resolvePreviousReleaseCommit,
|
||||
resolveReleaseChannel,
|
||||
resolveReleaseContext,
|
||||
resolveReleasePartition,
|
||||
resolveRemoteHighWaterVersion,
|
||||
runTauriBuild,
|
||||
selectFirstInstallArtifact,
|
||||
@@ -44,19 +45,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',
|
||||
@@ -126,32 +130,60 @@ test('does not select unsupported files', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves the channel from the target platform and rejects mismatches', () => {
|
||||
assert.equal(resolveReleaseChannel({}, windowsTarget), 'dev-win');
|
||||
assert.equal(resolveReleaseChannel({}, universalTarget), 'dev-mac');
|
||||
test('channels are independent of platform and accept release and custom names', () => {
|
||||
assert.equal(resolveReleaseChannel({}), 'dev');
|
||||
for (const channel of ['dev', 'release', 'beta-2', 'a'.repeat(32)]) {
|
||||
assert.equal(
|
||||
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }),
|
||||
channel,
|
||||
);
|
||||
assert.equal(
|
||||
resolveReleasePartition(channel, windowsTarget),
|
||||
`${channel}-win`,
|
||||
);
|
||||
assert.equal(
|
||||
resolveReleasePartition(channel, 'aarch64-apple-darwin'),
|
||||
`${channel}-mac`,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'dev-mac' }, universalTarget),
|
||||
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: ' release ' }),
|
||||
'release',
|
||||
);
|
||||
for (const channel of [
|
||||
'',
|
||||
' ',
|
||||
'win',
|
||||
'mac',
|
||||
'windows',
|
||||
'macos',
|
||||
'darwin',
|
||||
'linux',
|
||||
'dev-win',
|
||||
'dev-mac',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'dev-mac' }, windowsTarget),
|
||||
/只能用于 darwin 目标/u,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'beta-win' }, windowsTarget),
|
||||
/未知发布渠道/u,
|
||||
);
|
||||
'Release',
|
||||
'../dev',
|
||||
'a/b',
|
||||
'a_b',
|
||||
'-beta',
|
||||
'beta-',
|
||||
'1beta',
|
||||
'a'.repeat(33),
|
||||
]) {
|
||||
assert.throws(
|
||||
() => resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }),
|
||||
/发布渠道无效/u,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('channel manifest URL and build-time endpoint follow the channel', () => {
|
||||
withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => {
|
||||
assert.equal(
|
||||
updateManifestUrl('dev-win'),
|
||||
updateManifestUrl('dev', windowsTarget),
|
||||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json',
|
||||
);
|
||||
assert.deepEqual(createChannelConfig('dev-mac'), {
|
||||
assert.deepEqual(createChannelConfig('dev', 'aarch64-apple-darwin'), {
|
||||
plugins: {
|
||||
updater: {
|
||||
endpoints: [
|
||||
@@ -160,11 +192,23 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
updateManifestUrl('release', windowsTarget),
|
||||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/release-win/latest.json',
|
||||
);
|
||||
assert.equal(
|
||||
createChannelConfig('beta-2', 'x86_64-apple-darwin').plugins.updater
|
||||
.endpoints[0],
|
||||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/beta-2-mac/latest.json',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('macOS manifests only advertise the architecture actually built', () => {
|
||||
assert.throws(() => resolveManifestPlatformKeys(universalTarget), /单架构/);
|
||||
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',
|
||||
]);
|
||||
@@ -185,7 +229,7 @@ test('release context resolves explicit targets before environment/default and f
|
||||
for (const env of [{}, { AGC_BUILD_TARGET: windowsTarget }]) {
|
||||
const context = resolveReleaseContext(args, env);
|
||||
assert.equal(context.target, 'aarch64-apple-darwin');
|
||||
assert.equal(context.channel, 'dev-mac');
|
||||
assert.equal(context.channel, 'dev');
|
||||
assert.match(
|
||||
context.bundleRoot.replaceAll('\\', '/'),
|
||||
/target\/aarch64-apple-darwin\/release\/bundle$/,
|
||||
@@ -194,21 +238,20 @@ test('release context resolves explicit targets before environment/default and f
|
||||
}
|
||||
assert.throws(
|
||||
() => resolveReleaseContext(args, { AGC_UPDATE_CHANNEL: 'dev-win' }),
|
||||
/只能用于 windows/,
|
||||
/发布渠道无效/,
|
||||
);
|
||||
}
|
||||
assert.equal(resolveReleaseContext([], {}).target, windowsTarget);
|
||||
assert.equal(
|
||||
resolveReleaseContext([], { AGC_BUILD_TARGET: 'x86_64-apple-darwin' })
|
||||
.channel,
|
||||
'dev-mac',
|
||||
'dev',
|
||||
);
|
||||
for (const args of [
|
||||
['--target'],
|
||||
['--target='],
|
||||
['--target', '--no-bundle'],
|
||||
['--target', windowsTarget, '--target=aarch64-apple-darwin'],
|
||||
['--target', universalTarget],
|
||||
['--target', 'unknown'],
|
||||
])
|
||||
assert.throws(() => resolveReleaseContext(args, {}));
|
||||
@@ -231,7 +274,10 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and
|
||||
prepareVersion: async (context) => {
|
||||
seenContexts.push(context);
|
||||
assert.equal(
|
||||
await resolveRemoteHighWaterVersion(context.channel),
|
||||
await resolveRemoteHighWaterVersion(
|
||||
context.channel,
|
||||
context.target,
|
||||
),
|
||||
'0.1.67',
|
||||
);
|
||||
},
|
||||
@@ -406,7 +452,7 @@ test('manifest writer refuses to create latest when the current Mac DMG is missi
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid target or mismatched channel fails before any release side effect', async () => {
|
||||
test('invalid target or platform used as channel fails before any release side effect', async () => {
|
||||
let touched = false;
|
||||
const sideEffects = {
|
||||
prepareVersion: () => {
|
||||
@@ -420,18 +466,55 @@ test('invalid target or mismatched channel fails before any release side effect'
|
||||
},
|
||||
};
|
||||
await assert.rejects(
|
||||
() => buildRelease(['--target', universalTarget], sideEffects),
|
||||
/单架构/,
|
||||
() => buildRelease(['--target', 'unknown'], sideEffects),
|
||||
/不支持的发布目标/,
|
||||
);
|
||||
await withEnv({ AGC_UPDATE_CHANNEL: 'dev-win' }, () =>
|
||||
assert.rejects(
|
||||
() => buildRelease(['--target=aarch64-apple-darwin'], sideEffects),
|
||||
/只能用于 windows/,
|
||||
/发布渠道无效/,
|
||||
),
|
||||
);
|
||||
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 [
|
||||
@@ -440,7 +523,7 @@ test('Windows remains the default and explicit Windows overrides macOS environme
|
||||
AGC_BUILD_TARGET: 'aarch64-apple-darwin',
|
||||
}),
|
||||
]) {
|
||||
assert.equal(context.channel, 'dev-win');
|
||||
assert.equal(context.channel, 'dev');
|
||||
assert.equal(
|
||||
selectReleaseArtifact(files, context.target),
|
||||
'/tmp/windows.exe',
|
||||
@@ -484,14 +567,14 @@ test('no-bundle smoke skips version writes and manifest generation', async () =>
|
||||
steps.push('manifest');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(steps, ['dev-mac']);
|
||||
assert.deepEqual(steps, ['dev']);
|
||||
});
|
||||
|
||||
test('channel manifest carries version, platform keys and signature', () => {
|
||||
withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => {
|
||||
withEnv({ AGC_UPDATE_RELEASE_NOTES: '修复与改进' }, () => {
|
||||
const manifest = createUpdateManifest(artifact, {
|
||||
channel: 'dev-win',
|
||||
channel: 'dev',
|
||||
target: windowsTarget,
|
||||
publishedAt: '2026-09-17T00:00:00.000Z',
|
||||
});
|
||||
@@ -522,7 +605,7 @@ test('missing signature fails the channel manifest closed', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
createUpdateManifest(artifact, {
|
||||
channel: 'dev-win',
|
||||
channel: 'dev',
|
||||
target: windowsTarget,
|
||||
}),
|
||||
/缺少更新包签名/u,
|
||||
@@ -535,7 +618,7 @@ test('missing signature fails the channel manifest closed', () => {
|
||||
test('legacy manifest keeps the sha256 contract of published clients', () => {
|
||||
withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => {
|
||||
const legacy = createLegacyUpdateManifest(artifact, {
|
||||
channel: 'dev-win',
|
||||
channel: 'dev',
|
||||
});
|
||||
assert.match(legacy.version, /^\d+\.\d+\.\d+$/u);
|
||||
assert.equal(legacy.sha256.length, 64);
|
||||
@@ -551,6 +634,86 @@ test('next release version follows the higher local or channel version', () => {
|
||||
assert.equal(nextPatchVersion('0.1.12', null), '0.1.13');
|
||||
});
|
||||
|
||||
for (const channel of ['release', 'beta-2']) {
|
||||
for (const target of [windowsTarget, 'aarch64-apple-darwin']) {
|
||||
test(`${channel} ${target} freezes its endpoint, version source and published objects`, async () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-channel-manifest-'));
|
||||
try {
|
||||
const windows = target === windowsTarget;
|
||||
const partition = `${channel}-${windows ? 'win' : 'mac'}`;
|
||||
const artifact = path.join(
|
||||
root,
|
||||
windows ? '陶泥儿_x64-setup.exe' : '陶泥儿.app.tar.gz',
|
||||
);
|
||||
writeFileSync(artifact, 'updater package');
|
||||
writeFileSync(`${artifact}.sig`, 'updater signature');
|
||||
if (!windows) createDmgFixture(root, target);
|
||||
const context = {
|
||||
...resolveReleaseContext([`--target=${target}`], {
|
||||
AGC_UPDATE_CHANNEL: channel,
|
||||
}),
|
||||
bundleRoot: root,
|
||||
};
|
||||
const requests = [];
|
||||
const result = await withStubbedFetch(
|
||||
(url) => {
|
||||
requests.push(url);
|
||||
assert.ok(url.endsWith(`/agc/${partition}/latest.json`));
|
||||
return jsonResponse({
|
||||
version: '2.3.4',
|
||||
commit: 'abcdef1234567890',
|
||||
});
|
||||
},
|
||||
async () => {
|
||||
assert.equal(
|
||||
await resolveRemoteHighWaterVersion(
|
||||
context.channel,
|
||||
context.target,
|
||||
),
|
||||
'2.3.4',
|
||||
);
|
||||
runTauriBuild([`--target=${target}`], context, {
|
||||
spawn: (_binary, command) => {
|
||||
const config = JSON.parse(
|
||||
readFileSync(
|
||||
command[command.lastIndexOf('--config') + 1],
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
config.plugins.updater.endpoints[0].endsWith(
|
||||
`/agc/${partition}/latest.json`,
|
||||
),
|
||||
);
|
||||
return { status: 0 };
|
||||
},
|
||||
});
|
||||
return generateUpdateManifest(context);
|
||||
},
|
||||
);
|
||||
assert.equal(result.channel, channel);
|
||||
assert.equal(result.target, target);
|
||||
assert.equal(result.manifest.version, packageVersion);
|
||||
assert.equal(result.legacyManifestPath, null);
|
||||
assert.equal(result.legacyManifest, null);
|
||||
assert.equal(requests.length, 2);
|
||||
for (const entry of [
|
||||
...Object.values(result.manifest.platforms),
|
||||
...Object.values(result.manifest.downloads),
|
||||
]) {
|
||||
assert.ok(entry.url.includes(`/agc/${partition}/${packageVersion}/`));
|
||||
}
|
||||
assert.throws(
|
||||
() => createLegacyUpdateManifest(artifact, { channel, target }),
|
||||
/只属于 dev 渠道/u,
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('version high water keeps the legacy pointer during the migration window', async () => {
|
||||
await withStubbedFetch(
|
||||
(url) =>
|
||||
@@ -558,7 +721,10 @@ test('version high water keeps the legacy pointer during the migration window',
|
||||
? jsonResponse({}, 404)
|
||||
: jsonResponse({ version: '0.1.57' }),
|
||||
async () => {
|
||||
assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.57');
|
||||
assert.equal(
|
||||
await resolveRemoteHighWaterVersion('dev', windowsTarget),
|
||||
'0.1.57',
|
||||
);
|
||||
// 旧指针 0.1.57 已是高水位,下一次发布必须是 0.1.58,不能退回渠道本地版本。
|
||||
assert.equal(nextPatchVersion('0.1.47', '0.1.57'), '0.1.58');
|
||||
},
|
||||
@@ -572,7 +738,10 @@ test('version high water takes the higher of channel and legacy pointer', async
|
||||
? jsonResponse({ version: '0.1.60' })
|
||||
: jsonResponse({ version: '0.1.57' }),
|
||||
async () => {
|
||||
assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.60');
|
||||
assert.equal(
|
||||
await resolveRemoteHighWaterVersion('dev', windowsTarget),
|
||||
'0.1.60',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -587,7 +756,16 @@ test('version high water ignores the windows migration pointer for other channel
|
||||
return jsonResponse({ version: '0.1.12' });
|
||||
},
|
||||
async () => {
|
||||
assert.equal(await resolveRemoteHighWaterVersion('dev-mac'), '0.1.12');
|
||||
for (const [channel, target] of [
|
||||
['dev', 'aarch64-apple-darwin'],
|
||||
['release', windowsTarget],
|
||||
['beta-2', windowsTarget],
|
||||
]) {
|
||||
assert.equal(
|
||||
await resolveRemoteHighWaterVersion(channel, target),
|
||||
'0.1.12',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -597,20 +775,20 @@ test('release notes anchor prefers the explicit commit and falls back to the man
|
||||
() => jsonResponse({ version: '0.1.61', commit: 'abcdef1234567890' }),
|
||||
async () => {
|
||||
assert.equal(
|
||||
await resolvePreviousReleaseCommit('dev-win', {
|
||||
await resolvePreviousReleaseCommit('dev', {
|
||||
override: '6017d46088c04199e99cf89f347b12d67591475e',
|
||||
}),
|
||||
'6017d46088c04199e99cf89f347b12d67591475e',
|
||||
);
|
||||
// 覆盖值非法时忽略,继续用清单里的 commit。
|
||||
assert.equal(
|
||||
await resolvePreviousReleaseCommit('dev-win', {
|
||||
await resolvePreviousReleaseCommit('dev', {
|
||||
override: 'not-a-sha',
|
||||
}),
|
||||
'abcdef1234567890',
|
||||
);
|
||||
assert.equal(
|
||||
await resolvePreviousReleaseCommit('dev-win', { override: ' ' }),
|
||||
await resolvePreviousReleaseCommit('dev', { override: ' ' }),
|
||||
'abcdef1234567890',
|
||||
);
|
||||
},
|
||||
@@ -620,7 +798,7 @@ test('release notes anchor prefers the explicit commit and falls back to the man
|
||||
() => jsonResponse({ version: '0.1.61' }),
|
||||
async () => {
|
||||
assert.equal(
|
||||
await resolvePreviousReleaseCommit('dev-win', { override: undefined }),
|
||||
await resolvePreviousReleaseCommit('dev', { override: undefined }),
|
||||
null,
|
||||
);
|
||||
},
|
||||
@@ -634,7 +812,7 @@ test('release notes anchor degrades to null when the manifest cannot be read', a
|
||||
};
|
||||
try {
|
||||
assert.equal(
|
||||
await resolvePreviousReleaseCommit('dev-win', { override: undefined }),
|
||||
await resolvePreviousReleaseCommit('dev', { override: undefined }),
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
|
||||
@@ -1365,18 +1365,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',
|
||||
|
||||
@@ -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 绝对路径',
|
||||
@@ -31,13 +38,19 @@ 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;
|
||||
}
|
||||
@@ -60,7 +73,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);
|
||||
@@ -129,20 +142,39 @@ async function handshake(executable) {
|
||||
try {
|
||||
fs.cpSync(source, app, { recursive: true });
|
||||
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 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.platform, platform);
|
||||
assert.equal(manifest.version, 'codex-cli 0.147.0');
|
||||
const components = [
|
||||
'bin/codex',
|
||||
@@ -159,11 +191,7 @@ 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')));
|
||||
@@ -212,7 +240,7 @@ try {
|
||||
assert.notEqual(broken.status, 0);
|
||||
assert.match(`${broken.stdout}\n${broken.stderr}`, /Codex CLI 未安装/);
|
||||
console.log(
|
||||
'PASS: 隔离安装包资源、架构、摘要、权限、正式 Codex 查找、app-server 握手及缺组件拒绝',
|
||||
`PASS (${architecture}): 隔离安装包资源、架构、摘要、权限、正式 Codex 查找、app-server 握手及缺组件拒绝`,
|
||||
);
|
||||
console.log(
|
||||
'未验证:GUI、真实登录/Provider 对话、Cocos macOS 原生桥接;插件 Node 仍为外部前提',
|
||||
|
||||
@@ -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,111 @@
|
||||
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',
|
||||
]) {
|
||||
assert.ok(pipeline.includes(required), required);
|
||||
}
|
||||
// dry-run 必须是默认值:不显式取消勾选就不得写入 OSS。
|
||||
assert.match(
|
||||
pipeline,
|
||||
/booleanParam\(name: 'AGC_RELEASE_DRY_RUN', defaultValue: true/u,
|
||||
);
|
||||
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'));
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
import { resolveReleasePartition } from './build-release.mjs';
|
||||
|
||||
/**
|
||||
* 发布上传的 OSS 命令行整理:把 ossutil 参数与凭据整理成可执行或可打印的形式,
|
||||
* 便于在 dry-run 下核对将要执行的上传,同时保证任何输出都不回显凭据明文。
|
||||
@@ -34,16 +36,28 @@ export function createReleaseUploadPlan(
|
||||
artifact,
|
||||
downloadArtifact,
|
||||
channel,
|
||||
target,
|
||||
manifest,
|
||||
manifestPath,
|
||||
legacyManifestPath,
|
||||
},
|
||||
bucket,
|
||||
) {
|
||||
if (!artifact || !downloadArtifact || !manifestPath || !manifest?.version) {
|
||||
throw new Error('发布结果缺少更新包、首装包或清单');
|
||||
if (
|
||||
!artifact ||
|
||||
!downloadArtifact ||
|
||||
!manifestPath ||
|
||||
!manifest?.version ||
|
||||
!channel ||
|
||||
!target
|
||||
) {
|
||||
throw new Error('发布结果缺少渠道、构建目标、更新包、首装包或清单');
|
||||
}
|
||||
const prefix = `oss://${bucket}/agc/${channel}`;
|
||||
const partition = resolveReleasePartition(channel, target);
|
||||
if (legacyManifestPath && partition !== 'dev-win') {
|
||||
throw new Error('旧协议迁移清单只属于 dev 渠道的 Windows 系统');
|
||||
}
|
||||
const prefix = `oss://${bucket}/agc/${partition}`;
|
||||
const artifacts = [
|
||||
...new Set(
|
||||
[artifact, `${artifact}.sig`, downloadArtifact].map((file) =>
|
||||
|
||||
@@ -40,22 +40,24 @@ test('printed upload command keeps arguments and hides credentials', () => {
|
||||
);
|
||||
});
|
||||
|
||||
function withReleaseFixture(channel, architecture, run) {
|
||||
function withReleaseFixture(channel, architecture, run, platform = 'macos') {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-upload-plan-'));
|
||||
try {
|
||||
const artifact = path.join(
|
||||
root,
|
||||
channel === 'dev-win'
|
||||
platform === 'windows'
|
||||
? '陶泥儿_1.2.3_x64-setup.exe'
|
||||
: '陶泥儿.app.tar.gz',
|
||||
);
|
||||
const downloadArtifact =
|
||||
channel === 'dev-win'
|
||||
platform === 'windows'
|
||||
? artifact
|
||||
: path.join(root, `陶泥儿_1.2.3_${architecture}.dmg`);
|
||||
const manifestPath = path.join(root, 'latest.json');
|
||||
const legacyManifestPath =
|
||||
channel === 'dev-win' ? path.join(root, 'legacy-latest.json') : null;
|
||||
channel === 'dev' && platform === 'windows'
|
||||
? path.join(root, 'legacy-latest.json')
|
||||
: null;
|
||||
for (const file of [
|
||||
artifact,
|
||||
`${artifact}.sig`,
|
||||
@@ -69,6 +71,10 @@ function withReleaseFixture(channel, architecture, run) {
|
||||
artifact,
|
||||
downloadArtifact,
|
||||
channel,
|
||||
target:
|
||||
platform === 'windows'
|
||||
? 'x86_64-pc-windows-msvc'
|
||||
: `${architecture === 'aarch64' ? 'aarch64' : 'x86_64'}-apple-darwin`,
|
||||
manifest: { version: '1.2.3' },
|
||||
manifestPath,
|
||||
legacyManifestPath,
|
||||
@@ -86,7 +92,7 @@ const uploadOptions = {
|
||||
|
||||
for (const architecture of ['aarch64', 'x64']) {
|
||||
test(`uploads every ${architecture} Mac object before the channel pointer`, () => {
|
||||
withReleaseFixture('dev-mac', architecture, (release) => {
|
||||
withReleaseFixture('dev', architecture, (release) => {
|
||||
const calls = [];
|
||||
uploadReleaseArtifacts(release, {
|
||||
...uploadOptions,
|
||||
@@ -120,37 +126,42 @@ for (const architecture of ['aarch64', 'x64']) {
|
||||
}
|
||||
|
||||
test('Windows uploads the shared installer once and publishes migration metadata last', () => {
|
||||
withReleaseFixture('dev-win', 'x64', (release) => {
|
||||
const plan = createReleaseUploadPlan(release, 'agc-dev');
|
||||
assert.deepEqual(
|
||||
plan.map(({ source }) => source),
|
||||
[
|
||||
release.artifact,
|
||||
`${release.artifact}.sig`,
|
||||
release.manifestPath,
|
||||
release.legacyManifestPath,
|
||||
],
|
||||
);
|
||||
assert.equal(plan.at(-1).destination, 'oss://agc-dev/agc/latest.json');
|
||||
const calls = [];
|
||||
uploadReleaseArtifacts(release, {
|
||||
...uploadOptions,
|
||||
spawn: (_binary, args) => {
|
||||
assert.deepEqual(args.slice(0, 2), ['cp', '--force']);
|
||||
calls.push(args[3]);
|
||||
return { status: 0 };
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
calls,
|
||||
plan.map(({ destination }) => destination),
|
||||
);
|
||||
});
|
||||
withReleaseFixture(
|
||||
'dev',
|
||||
'x64',
|
||||
(release) => {
|
||||
const plan = createReleaseUploadPlan(release, 'agc-dev');
|
||||
assert.deepEqual(
|
||||
plan.map(({ source }) => source),
|
||||
[
|
||||
release.artifact,
|
||||
`${release.artifact}.sig`,
|
||||
release.manifestPath,
|
||||
release.legacyManifestPath,
|
||||
],
|
||||
);
|
||||
assert.equal(plan.at(-1).destination, 'oss://agc-dev/agc/latest.json');
|
||||
const calls = [];
|
||||
uploadReleaseArtifacts(release, {
|
||||
...uploadOptions,
|
||||
spawn: (_binary, args) => {
|
||||
assert.deepEqual(args.slice(0, 2), ['cp', '--force']);
|
||||
calls.push(args[3]);
|
||||
return { status: 0 };
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
calls,
|
||||
plan.map(({ destination }) => destination),
|
||||
);
|
||||
},
|
||||
'windows',
|
||||
);
|
||||
});
|
||||
|
||||
for (const failedArtifactIndex of [0, 1, 2]) {
|
||||
test(`failed Mac object ${failedArtifactIndex} prevents both later objects and latest publication`, () => {
|
||||
withReleaseFixture('dev-mac', 'aarch64', (release) => {
|
||||
withReleaseFixture('dev', 'aarch64', (release) => {
|
||||
const destinations = [];
|
||||
assert.throws(
|
||||
() =>
|
||||
@@ -176,7 +187,7 @@ for (const failedArtifactIndex of [0, 1, 2]) {
|
||||
}
|
||||
|
||||
test('dry run prints the complete plan without spawning uploads or exposing credentials', () => {
|
||||
withReleaseFixture('dev-mac', 'aarch64', (release) => {
|
||||
withReleaseFixture('dev', 'aarch64', (release) => {
|
||||
const output = [];
|
||||
uploadReleaseArtifacts(release, {
|
||||
...uploadOptions,
|
||||
@@ -195,3 +206,32 @@ test('dry run prints the complete plan without spawning uploads or exposing cred
|
||||
assert.doesNotMatch(output.join('\n'), /fixture-id|fixture-secret|已上传/u);
|
||||
});
|
||||
});
|
||||
|
||||
for (const channel of ['release', 'beta-2']) {
|
||||
for (const platform of ['windows', 'macos']) {
|
||||
test(`${channel} ${platform} uploads only its own partition and cannot write the dev bridge`, () => {
|
||||
withReleaseFixture(
|
||||
channel,
|
||||
'x64',
|
||||
(release) => {
|
||||
const plan = createReleaseUploadPlan(release, 'agc-dev');
|
||||
const suffix = platform === 'windows' ? 'win' : 'mac';
|
||||
const prefix = `oss://agc-dev/agc/${channel}-${suffix}/`;
|
||||
assert.ok(
|
||||
plan.every(({ destination }) => destination.startsWith(prefix)),
|
||||
);
|
||||
assert.equal(plan.at(-1).destination, `${prefix}latest.json`);
|
||||
assert.throws(
|
||||
() =>
|
||||
createReleaseUploadPlan(
|
||||
{ ...release, legacyManifestPath: release.manifestPath },
|
||||
'agc-dev',
|
||||
),
|
||||
/只属于 dev 渠道/u,
|
||||
);
|
||||
},
|
||||
platform,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -31,7 +31,23 @@ fn sha256_file(path: &std::path::Path) -> Result<String, std::io::Error> {
|
||||
fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) {
|
||||
let target = env::var("TARGET").expect("Cargo TARGET");
|
||||
println!("cargo:rustc-env=AGC_BUILD_TARGET={target}");
|
||||
let Some(layout) = codex_bundle::for_target(&target) else {
|
||||
if target.contains("apple-darwin") {
|
||||
// Tauri 的 universal 两次 Cargo 编译共用 resource staging,
|
||||
// 每次都生成完整双架构目录,最终 bundle 不取决于最后编译的切片。
|
||||
let staging = manifest_dir.join("resources/codex/mac-native");
|
||||
if staging.exists() {
|
||||
fs::remove_dir_all(&staging).expect("清理 macOS Codex staging 失败");
|
||||
}
|
||||
for target in ["aarch64-apple-darwin", "x86_64-apple-darwin"] {
|
||||
stage_codex_target(manifest_dir, target);
|
||||
}
|
||||
} else {
|
||||
stage_codex_target(manifest_dir, &target);
|
||||
}
|
||||
}
|
||||
|
||||
fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) {
|
||||
let Some(layout) = codex_bundle::for_target(target) else {
|
||||
assert!(
|
||||
!target.contains("windows") && !target.contains("apple-darwin"),
|
||||
"不支持的 Codex 随包目标:{target}"
|
||||
@@ -81,7 +97,7 @@ fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) {
|
||||
&fs::read(source.join("codex-package.json")).expect("读取 Codex 原生包元数据失败"),
|
||||
)
|
||||
.expect("Codex 原生包元数据无效");
|
||||
codex_bundle::validate_package_metadata(&metadata, &target, layout)
|
||||
codex_bundle::validate_package_metadata(&metadata, target, layout)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
let target_dir = manifest_dir.join("resources/codex").join(layout.directory);
|
||||
let notice = target_dir.join("NOTICE.md");
|
||||
|
||||
@@ -49,7 +49,11 @@ pub fn for_target(target: &str) -> Option<Layout> {
|
||||
} else {
|
||||
"codex-darwin-x64"
|
||||
},
|
||||
directory: "mac-native",
|
||||
directory: if target.starts_with("aarch64") {
|
||||
"mac-native/darwin-arm64"
|
||||
} else {
|
||||
"mac-native/darwin-x64"
|
||||
},
|
||||
executable: "bin/codex",
|
||||
files: MAC_FILES,
|
||||
}),
|
||||
@@ -90,6 +94,9 @@ mod tests {
|
||||
let intel = for_target("x86_64-apple-darwin").unwrap();
|
||||
assert_eq!(intel.platform, "darwin-x64");
|
||||
assert_eq!(intel.npm_package, "codex-darwin-x64");
|
||||
assert_eq!(mac.directory, "mac-native/darwin-arm64");
|
||||
assert_eq!(intel.directory, "mac-native/darwin-x64");
|
||||
assert_ne!(mac.directory, intel.directory);
|
||||
let windows = for_target("x86_64-pc-windows-msvc").unwrap();
|
||||
assert_eq!(windows.directory, "win-x64");
|
||||
assert_eq!(windows.files.len(), 6);
|
||||
|
||||
@@ -26,6 +26,8 @@ struct PromptBundleManifest {
|
||||
version: String,
|
||||
#[serde(deserialize_with = "deserialize_unique_string_map")]
|
||||
sections: BTreeMap<String, String>,
|
||||
#[serde(default, deserialize_with = "deserialize_unique_string_map")]
|
||||
text_catalogs: BTreeMap<String, String>,
|
||||
compositions: PromptCompositions,
|
||||
variants: PromptVariants,
|
||||
#[serde(default)]
|
||||
@@ -121,6 +123,11 @@ struct AgentRole {
|
||||
brief_path_name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TextCatalog(
|
||||
#[serde(deserialize_with = "deserialize_unique_string_map")] BTreeMap<String, String>,
|
||||
);
|
||||
|
||||
fn deserialize_unique_string_map<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<BTreeMap<String, String>, D::Error>
|
||||
@@ -212,6 +219,13 @@ pub fn compile_manifest(manifest_path: &Path) -> Result<CompiledPromptBundle, St
|
||||
dependencies.insert(path);
|
||||
sections.insert(section_id.clone(), content);
|
||||
}
|
||||
let texts = read_text_catalogs(
|
||||
&manifest.text_catalogs,
|
||||
base,
|
||||
&canonical_base,
|
||||
&mut section_paths,
|
||||
&mut dependencies,
|
||||
)?;
|
||||
validate_registered_markdown_files(base, §ion_paths, &mut dependencies)?;
|
||||
|
||||
validate_composition(
|
||||
@@ -365,7 +379,8 @@ pub fn compile_manifest(manifest_path: &Path) -> Result<CompiledPromptBundle, St
|
||||
));
|
||||
}
|
||||
}
|
||||
let rust_source = render_rust(&manifest, §ions);
|
||||
let mut rust_source = render_rust(&manifest, §ions);
|
||||
rust_source.push_str(&render_text_macro(&texts));
|
||||
let specialist_nodes = manifest
|
||||
.agent_catalog
|
||||
.groups
|
||||
@@ -554,9 +569,13 @@ fn validate_section_reference(
|
||||
}
|
||||
|
||||
fn validate_section_path(path: &str) -> Result<(), String> {
|
||||
validate_prompt_path(path, ".md")
|
||||
}
|
||||
|
||||
fn validate_prompt_path(path: &str, extension: &str) -> Result<(), String> {
|
||||
if path.is_empty()
|
||||
|| path.contains('\\')
|
||||
|| !path.ends_with(".md")
|
||||
|| !path.ends_with(extension)
|
||||
|| path.split('/').any(|segment| {
|
||||
segment.is_empty()
|
||||
|| !segment.chars().all(|character| {
|
||||
@@ -577,6 +596,61 @@ fn validate_section_path(path: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_text_catalogs(
|
||||
catalogs: &BTreeMap<String, String>,
|
||||
base: &Path,
|
||||
canonical_base: &Path,
|
||||
registered_paths: &mut BTreeSet<String>,
|
||||
dependencies: &mut BTreeSet<PathBuf>,
|
||||
) -> Result<BTreeMap<String, String>, String> {
|
||||
let mut texts = BTreeMap::new();
|
||||
for (catalog_id, relative_path) in catalogs {
|
||||
validate_identifier(catalog_id, "text catalog id", false)?;
|
||||
validate_prompt_path(relative_path, ".json")?;
|
||||
if relative_path == "manifest.json"
|
||||
|| !registered_paths.insert(portable_section_path_key(relative_path))
|
||||
{
|
||||
return Err(format!("Prompt 文本目录路径重复:{relative_path}"));
|
||||
}
|
||||
validate_no_symlink_components(base, relative_path)?;
|
||||
let path = base.join(relative_path);
|
||||
let canonical_path = fs::canonicalize(&path)
|
||||
.map_err(|error| format!("解析 Prompt 文本目录路径失败 {relative_path}:{error}"))?;
|
||||
if !canonical_path.starts_with(canonical_base) || !path.is_file() {
|
||||
return Err(format!(
|
||||
"Prompt 文本目录必须是 Bundle 内的普通文件:{relative_path}"
|
||||
));
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|error| format!("读取 Prompt 文本目录失败 {relative_path}:{error}"))?;
|
||||
let TextCatalog(entries) = serde_json::from_str(&content)
|
||||
.map_err(|error| format!("解析 Prompt 文本目录失败 {relative_path}:{error}"))?;
|
||||
if entries.is_empty() {
|
||||
return Err(format!("Prompt 文本目录不能为空:{catalog_id}"));
|
||||
}
|
||||
for (key, text) in entries {
|
||||
validate_identifier(&key, "text key", true)?;
|
||||
validate_nonempty(&text, "prompt text")?;
|
||||
texts.insert(format!("{catalog_id}.{key}"), text);
|
||||
}
|
||||
dependencies.insert(path);
|
||||
}
|
||||
Ok(texts)
|
||||
}
|
||||
|
||||
fn render_text_macro(texts: &BTreeMap<String, String>) -> String {
|
||||
let mut output = String::from("\n#[allow(unused_macros)]\nmacro_rules! prompt_text {\n");
|
||||
for (key, text) in texts {
|
||||
output.push_str(&format!(
|
||||
" ({}) => {{ {} }};\n",
|
||||
rust_literal(key),
|
||||
rust_literal(text),
|
||||
));
|
||||
}
|
||||
output.push_str("}\n");
|
||||
output
|
||||
}
|
||||
|
||||
fn portable_section_path_key(path: &str) -> String {
|
||||
path.to_ascii_lowercase()
|
||||
}
|
||||
@@ -590,9 +664,12 @@ fn validate_registered_markdown_files(
|
||||
collect_markdown_files(base, base, &mut discovered_paths, dependencies)?;
|
||||
for path in discovered_paths {
|
||||
if !registered_paths.contains(&path) {
|
||||
return Err(format!(
|
||||
"Prompt Bundle 存在未登记的 Markdown section:{path}"
|
||||
));
|
||||
let kind = if path.ends_with(".json") {
|
||||
"文本目录"
|
||||
} else {
|
||||
"Markdown section"
|
||||
};
|
||||
return Err(format!("Prompt Bundle 存在未登记的 {kind}:{path}"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -626,7 +703,10 @@ fn collect_markdown_files(
|
||||
|| !path
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("md"))
|
||||
.is_some_and(|extension| {
|
||||
extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("json")
|
||||
})
|
||||
|| path == base.join("manifest.json")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -647,7 +727,12 @@ fn collect_markdown_files(
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.join("/");
|
||||
validate_section_path(&relative)?;
|
||||
let extension = if relative.ends_with(".json") {
|
||||
".json"
|
||||
} else {
|
||||
".md"
|
||||
};
|
||||
validate_prompt_path(&relative, extension)?;
|
||||
discovered_paths.insert(portable_section_path_key(&relative));
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
- project/analysis.md
|
||||
- project/决策台账.md
|
||||
- project/dialog.md
|
||||
不要把正式产物写在工作区根目录,也不要等审批失败后再迁移。
|
||||
阶段审批工具:当你判断本阶段必需产物已完成时,必须提交阶段审批。用户批准后 Runtime 自动进入下一阶段;你不能自行切换阶段。
|
||||
正式产物使用当前阶段指定的相对路径。
|
||||
五个策划阶段的审批:当你判断当前策划阶段必需产物已完成时,必须提交阶段审批。用户批准后 Runtime 自动进入下一阶段。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
顾问阶段不需要继续自主推动项目或主动安排下一步;遵照用户的具体指示行动。
|
||||
顾问阶段遵照用户的具体指示行动。
|
||||
根据用户指示回答问题、读取相关文档、修改工作区文件,并说明改动可能影响的已有产物。
|
||||
涉及方向性变化或多个可行方案时,先向用户说明影响并等待用户决定;不要替用户做决定。
|
||||
顾问阶段没有下一层,也不需要提交阶段审批。
|
||||
涉及方向性变化或多个可行方案时,先向用户说明影响并等待用户决定。
|
||||
顾问阶段以完成用户当前请求并汇报结果为结束点。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
概念阶段定稿时,创建或更新 `project/速览卡.md`。下面是速览卡的参考结构;根据游戏类型、项目规模和用户要求选择字段,同类内容可以合并,项目不需要的字段可以省略,复杂项目可以增加必要字段。表格和列表中的示例行可按实际对象逐行扩展,不代表数量上限:
|
||||
概念阶段定稿时,创建或更新 `project/速览卡.md`。下面是速览卡的参考结构;根据游戏类型、项目规模和用户要求选择适用字段,同类内容可以合并,复杂项目可以增加必要字段。表格和列表中的示例行按实际对象逐行扩展:
|
||||
|
||||
# 速览卡:《游戏名》
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
+2
-2
@@ -1,7 +1,7 @@
|
||||
# 顶层设计:《星露谷物语》
|
||||
|
||||
## 顶层定位与规模锚点
|
||||
顶层不是做长线农场生产线,也不是做以探索战斗为主的活动清单,而是让玩家每天都在想:
|
||||
顶层设计让玩家每天都在想:
|
||||
> "今天做什么?——下雨天不用浇水,正好下矿井;回来的路上把罗宾的生日礼物送了。"
|
||||
|
||||
| 项 | 定义 |
|
||||
@@ -18,7 +18,7 @@
|
||||
- 规划掌控:时间、体力、季节和资金构成可理解的取舍,提前准备会让未来更高效。
|
||||
- 探索成长:探索区域、战斗和资源发现提供变化与风险,并将成果转化为农场与角色的长期改善。
|
||||
|
||||
三者的关系不是并列小游戏,而是互相供给:农场提供稳定资源与恢复空间,探索提供稀有资源和发现,社交与社区目标提供方向和情感回报。
|
||||
三者互相供给:农场提供稳定资源与恢复空间,探索提供稀有资源和发现,社交与社区目标提供方向和情感回报。
|
||||
|
||||
## 核心推动力
|
||||
玩家每天拥有有限的时间与体力,但可以在一天结束后保留成果,并把收益投入到工具、设施、种子、装备和关系中。短期的"今天做什么"决策,持续转化为长期的"我的生活变得怎样"。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user