修复渠道配置替换窗口契约导致的系统标题栏回归与登录请求被拒

- 渠道 --config 改为从基线 tauri.conf.json 读取完整 client 窗口对象后展开、只覆盖 title,避免 Tauri JSON Merge Patch 整体替换 app.windows 丢掉 label / decorations / 尺寸
- build-release.test.mjs 新增合并守卫用例:按同一 merge patch 语义复现 Tauri 合并,断言 label=client、decorations=false、1280x800、min 1280x720,且承载 http:default 的 capability 必须包含该 label
- check-config.mjs 增补基线 client 窗口 decorations 必须为 false 的门禁
- 更新 AGC 客户端更新检查与下载技术方案,写明渠道配置必须下发完整窗口对象的约定
- pitfalls.md 记录本次回归的现象、根因、现行口径与验证证据
This commit is contained in:
kdletters
2026-09-23 19:53:45 +08:00
parent 9bd4a1734c
commit eb192eb161
5 changed files with 129 additions and 4 deletions
@@ -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: {
@@ -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 {
@@ -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',