diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 38d6d1c5c..fe13fade7 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -373,6 +373,24 @@ export function buildTauriBuildArguments( ]; } +/** + * 基线 client 窗口契约:渠道配置只允许覆盖标题,其余字段必须逐字沿用。 + * + * `tauri build --config` 走 JSON Merge Patch(tauri-utils 用 `json_patch::merge`): + * 对象递归合并,**数组整体替换**。只下发 `{ title }` 会让 + * `label` / `decorations` / 尺寸全部回落到 Tauri 默认值(label=main、 + * decorations=true、800x600),结果是原生系统标题栏重新出现,并且按 label + * 绑定的 capability(平台 HTTP 权限等)一起失效。 + */ +function readBaseClientWindow() { + const base = JSON.parse(fs.readFileSync(tauriConfigPath, 'utf8')); + const clientWindow = base.app?.windows?.[0]; + if (!clientWindow || typeof clientWindow.label !== 'string') { + throw new Error('AGC 基线配置缺少 client 主窗口,渠道配置无法安全合并'); + } + return clientWindow; +} + /** * 渠道端点与安装身份必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道, * 而 `productName` / `identifier` 决定安装目录、卸载项与客户端数据目录, @@ -381,13 +399,14 @@ export function buildTauriBuildArguments( export function createChannelConfig( channel = resolveReleaseChannel(), target = defaultTarget(), + baseClientWindow = readBaseClientWindow(), ) { const { productName, identifier } = resolveChannelInstallIdentity(channel); return { productName, identifier, app: { - windows: [{ title: productName }], + windows: [{ ...baseClientWindow, title: productName }], }, plugins: { updater: { diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index 4c62cd8b4..3cdd735c1 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -1,5 +1,11 @@ import assert from 'node:assert/strict'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; @@ -180,7 +186,18 @@ test('channel manifest URL and build-time endpoint follow the channel', () => { productName: `${AGC_PRODUCT_NAME}开发版`, identifier: AGC_APP_IDENTIFIER, app: { - windows: [{ title: `${AGC_PRODUCT_NAME}开发版` }], + windows: [ + { + label: 'client', + title: `${AGC_PRODUCT_NAME}开发版`, + url: 'index.html', + width: 1280, + height: 800, + decorations: false, + minWidth: 1280, + minHeight: 720, + }, + ], }, plugins: { updater: { @@ -245,6 +262,78 @@ test('channel install identity is baked into the same build-time config as the e }); }); +/** + * RFC 7386(tauri-utils 用 `json_patch::merge`)语义:对象递归合并,数组整体替换。 + * 这里按同样语义复现 Tauri CLI 的 `--config` 合并,用来守住"渠道配置不得丢窗口契约"。 + */ +function applyJsonMergePatch(base, patch) { + if (Array.isArray(patch) || typeof patch !== 'object' || patch === null) { + return patch; + } + const merged = + typeof base === 'object' && base !== null && !Array.isArray(base) + ? { ...base } + : {}; + for (const [key, value] of Object.entries(patch)) { + if (value === null) delete merged[key]; + else merged[key] = applyJsonMergePatch(merged[key], value); + } + return merged; +} + +function readBaseTauriConfig() { + return JSON.parse( + readFileSync( + new URL('../src-tauri/tauri.conf.json', import.meta.url), + 'utf8', + ), + ); +} + +test('channel config keeps the client window contract across the Tauri config merge', () => { + const base = readBaseTauriConfig(); + const merged = applyJsonMergePatch(base, { + ...createChannelConfig('release', windowsTarget), + version: base.version, + }); + const [clientWindow] = merged.app.windows; + assert.deepEqual(clientWindow, { + ...base.app.windows[0], + title: '陶泥儿 Release', + }); + // 原生标题栏、尺寸与默认窗口标签都是回归点:任何一项回落都会让自绘标题栏失效, + // 并让按 label 绑定的 capability(平台 HTTP 权限)不再命中。 + assert.equal(clientWindow.label, 'client'); + assert.equal(clientWindow.decorations, false); + assert.equal(clientWindow.width, 1280); + assert.equal(clientWindow.height, 800); + assert.equal(clientWindow.minWidth, 1280); + assert.equal(clientWindow.minHeight, 720); + + const capabilitiesDirectory = new URL( + '../src-tauri/capabilities/', + import.meta.url, + ); + const capabilities = readdirSync(capabilitiesDirectory) + .filter((name) => name.endsWith('.json')) + .map((name) => + JSON.parse(readFileSync(new URL(name, capabilitiesDirectory), 'utf8')), + ); + const httpCapability = capabilities.find((capability) => + (capability.permissions ?? []).some( + (permission) => + permission === 'http:default' || + (typeof permission === 'object' && + permission?.identifier === 'http:default'), + ), + ); + assert.ok(httpCapability, '客户端必须保留承载平台 HTTP 权限的 capability'); + assert.ok( + (httpCapability.windows ?? []).includes(clientWindow.label), + `平台 HTTP capability 必须绑定 ${clientWindow.label} 窗口,实际:${httpCapability.windows}`, + ); +}); + test('channel products keep first-install selection working under the channel product name', () => { const root = mkdtempSync(path.join(os.tmpdir(), 'agc-channel-dmg-')); try { diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 31b7d52f4..b22a44081 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1567,6 +1567,14 @@ if ( ); } +// 窗口外壳由前端 WindowChrome 自绘:基线配置一旦放开 decorations, +// 打包产物会出现系统标题栏与自绘标题栏并存。 +if (clientWindow.decorations !== false) { + throw new Error( + 'AI game creator shell client window must keep native decorations disabled', + ); +} + if (tauriConfig.build?.devUrl !== 'http://127.0.0.1:3080/') { throw new Error( 'AI game creator shell Tauri config must retain the non-launcher fallback devUrl', diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 877437f62..2efa4c511 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5939,3 +5939,11 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **现象**:模板清单里的封面 URL 失效(或离线)时,卡片封面上出现浏览器的破碎图片图标,比没有封面更难看。 - **处理**:`TemplateCard` 的 `img` 加 `onError` 直接把自身 `visibility` 设为 `hidden`(不进 state,卡片是 memo 的纯展示组件),留下封面容器本身的中性底色;单测用 `fireEvent.error(cover)` 钉住。 - **关联**:`apps/ai-game-creator-shell/src/view/template-library/TemplateCard.tsx`、`apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx`。 + +## 2026-09-23 渠道 `--config` 只写窗口标题,打包产物系统标题栏回来了且登录请求被 ACL 拒绝 + +- **现象**:dev 渠道 0.1.129 安装包启动后,窗口顶部同时出现系统标题栏(浅蓝条 + 原生最小化/最大化/关闭)与前端自绘 `WindowChrome`;窗口缩到 816x639(约 800x600 客户区);登录页常驻「无法连接登录服务,请确认配套后端或 API 代理已启动后重试」。应用日志同一秒出现 `startup.window-title.failed: 缺少 client 主窗口`,而 `https://dev.genarrative.world` 在浏览器/curl 下可正常响应。 +- **原因**:`ebb288a6a`(2026-09-23 18:49)为统一渠道产品名,在渠道配置里加了 `app: { windows: [{ title: productName }] }`。Tauri 的 `--config` 合并是 JSON Merge Patch(`tauri-utils/build.rs` 用 `json_patch::merge`):对象递归合并、**数组整体替换**。基线窗口数组被整条换掉后,`label` 回落到默认 `main`(不是 `client`)、`decorations` 回落到 `true`、尺寸回落到 800x600。三条症状同源:① `decorations: true` → 系统标题栏;② 尺寸回落 → 816x639;③ label 不再是 `client` → `capabilities/main.json`(`windows: ["client"]`,承载 `http:default` 与平台 API scope、dialog/opener/updater/剪贴板权限)整条不命中,前端 `fetchClientHttp` 走 `@tauri-apps/plugin-http` 时被 ACL 拒绝并抛错,登录状态检查就报成"连不上服务器"。判断关键:**这类"连不上服务"是权限拒绝,不是网络故障——先看窗口 label 与 capability 的 `windows` 是否还对得上,别去查后端与代理**。 +- **处理(现行口径)**:`createChannelConfig()` 从基线 `src-tauri/tauri.conf.json` 读完整 client 窗口对象后展开、只覆盖 `title`(`readBaseClientWindow()`),渠道配置不得再出现"只写 `title`"的窗口对象。新增守卫:`build-release.test.mjs` 用同语义的 merge patch 复现 Tauri 合并并断言 `label=client` / `decorations=false` / 1280x800 / min 1280x720 且承载 `http:default` 的 capability 必须包含该 label;`check-config.mjs` 增补基线 `decorations !== false` 失败关闭。 +- **验证**:`node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs scripts/cargo-features.test.mjs scripts/release-oss.test.mjs scripts/prepare-macos-codex.test.mjs`(60/60)、`node apps/ai-game-creator-shell/scripts/check-config.mjs` 通过;`createChannelConfig('dev', …)` 实测输出含 `label: client` 与 `decorations: false`。修复后的安装包尚未重新构建与安装,真机观感与登录链未复核。 +- **关联**:`apps/ai-game-creator-shell/scripts/build-release.mjs`、`apps/ai-game-creator-shell/scripts/build-release.test.mjs`、`apps/ai-game-creator-shell/scripts/check-config.mjs`、`apps/ai-game-creator-shell/src-tauri/capabilities/main.json`、`docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md`。 diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 66fe3e5cb..8bbdbe95a 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -136,7 +136,8 @@ - 发布入口:`npm run ai-game-creator-shell:release:upload`(构建 + 按渠道上传);仅构建不发布的 smoke 使用 `--no-bundle` 分支,不读远端版本、不改版本、不生成清单。 - 发布入口只解析一次目标,优先级为 CLI `--target value` / `--target=value` / `-t value`、`AGC_BUILD_TARGET`、Windows 默认值;重复/空目标与不支持目标失败关闭。版本高水位、构建 feature/渠道端点、bundle 路径、产物后缀、清单平台键及摘要必须消费同一个发布上下文,不能分别回读默认目标。 - 渠道由 `AGC_UPDATE_CHANNEL` 显式指定,默认 dev;Windows 与 macOS 目标均支持 dev、release 和自定义渠道,目标校验独立进行。 -- 渠道 `--config` 在 Tauri 构建前最后合并,同时注入 `productName`、`identifier` 与 updater 端点:安装身份与更新端点必须来自同一个渠道,不能各自回读默认值。macOS 发布入口构建 `*.app`、updater 归档与 DMG 前先按发布渠道解析产品名,产物名一律派生而不写死。 +- 渠道 `--config` 在 Tauri 构建前最后合并,同时注入 `productName`、`identifier`、updater 端点与窗口标题:安装身份与更新端点必须来自同一个渠道,不能各自回读默认值。macOS 发布入口构建 `*.app`、updater 归档与 DMG 前先按发布渠道解析产品名,产物名一律派生而不写死。 +- 渠道配置走 Tauri 的 JSON Merge Patch 语义:对象递归合并,**数组整体替换**。因此 `app.windows` 必须按基线 `tauri.conf.json` 的完整 client 窗口对象下发、只覆盖 `title`(脚本从基线读取后展开);任何"只写 `{ title }`"的写法都会让 `label` / `decorations` / 尺寸回落成 Tauri 默认值(`label=main`、`decorations=true`、800x600),表现为打包产物重新出现系统标题栏,并按 label 连带失效承载平台 HTTP 权限等 capability。守卫用例:`build-release.test.mjs` 的渠道配置合并用例与 `check-config.mjs` 的 `decorations` 门禁。 - 定时调度分别判断服务端与客户端 scope:dev 小时调度在提交含 AGC 相关路径时发布对应渠道,纯文档或流水线自身的提交仍只跑 Full Build;release 每日调度在服务端相关路径变化时发布正式 Full Build,在 AGC 相关路径变化时发布 release 客户端,并在同一调度内等待、汇总各 lane 结果,失败 lane 下一轮补发。判定失败或勾选强制触发时按"需要发布"处理。 - 更新摘要不再自动生成:发布脚本不读取提交记录生成 `notes`;只有 `AGC_UPDATE_RELEASE_NOTES` 非空时,才把显式手动文案写入渠道清单和旧协议清单的 `releaseNotes`。未设置时清单不携带更新说明,归档文件 `release-notes.txt` 记录“本次没有可用的更新摘要”。 - 清单里的 `commit` 是非标准字段:更新插件忽略未知字段;发布脚本只为线上排障保留源码 revision,不驱动更新摘要。