修复 AGC 渠道更新互相终止与快捷方式迁移
Project CI / AI game creator shell Rust crates (push) Successful in 3m5s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 4m24s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 5m20s
Project CI / Backend tests (push) Successful in 6m42s
Project CI / Frontend tests (push) Successful in 3m2s
Project CI / Native shell tests (push) Successful in 7m16s
Project CI / AI game creator shell web tests (push) Successful in 3m17s
Project CI / Repository checks (push) Successful in 6m25s

为 release 与自定义渠道注入独立主程序名,隔离 Windows 更新进程。

新增 release 旧安装目录与快捷方式迁移钩子,保护 dev 历史目录清理。

同步渠道规范、共享决策与排障记录,补充发布脚本回归测试。
This commit is contained in:
kdletters
2026-10-04 11:43:36 +08:00
parent f689b97d8a
commit 13a15fed5c
11 changed files with 242 additions and 22 deletions
@@ -18,6 +18,7 @@ import {
import {
AGC_DEFAULT_CHANNEL,
resolveChannelInstallIdentity,
resolveChannelMainBinaryName,
resolveReleaseChannel,
} from './channel-identity.mjs';
import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs';
@@ -38,6 +39,9 @@ const appRoot = fileURLToPath(new URL('..', import.meta.url));
const devChannelInstallerHooks = fileURLToPath(
new URL('../src-tauri/windows/installer-hooks.nsh', import.meta.url),
);
const releaseChannelInstallerHooks = fileURLToPath(
new URL('../src-tauri/windows/release-installer-hooks.nsh', import.meta.url),
);
// Git 命令用于读取 release revision,必须在仓库根执行,不能在应用目录里执行。
const repoRoot = path.resolve(appRoot, '..', '..');
const defaultReleaseTarget = 'x86_64-pc-windows-msvc';
@@ -423,6 +427,7 @@ export function createChannelConfig(
const config = {
productName,
identifier,
mainBinaryName: resolveChannelMainBinaryName(channel),
app: {
windows: [{ ...baseClientWindow, title: productName }],
},
@@ -432,10 +437,20 @@ export function createChannelConfig(
},
},
};
// 只有 dev 渠道改过展示名:它的包需要自带旧身份迁移;其它渠道不能拿这份旧名表去删安装。
if (channel === AGC_DEFAULT_CHANNEL && target.includes('windows'))
// dev 渠道迁移改名前的旧身份;release 渠道迁移曾使用过「陶泥儿 Release」的安装与快捷方式。
if (
target.includes('windows') &&
(channel === AGC_DEFAULT_CHANNEL || channel === 'release')
)
config.bundle = {
windows: { nsis: { installerHooks: devChannelInstallerHooks } },
windows: {
nsis: {
installerHooks:
channel === AGC_DEFAULT_CHANNEL
? devChannelInstallerHooks
: releaseChannelInstallerHooks,
},
},
};
return config;
}
@@ -35,8 +35,10 @@ import {
} from './build-release.mjs';
import {
AGC_APP_IDENTIFIER,
AGC_MAIN_BINARY_NAME,
AGC_PRODUCT_NAME,
resolveChannelInstallIdentity,
resolveChannelMainBinaryName,
} from './channel-identity.mjs';
import { supportedHostTarget } from './prepare-bundled-resources.mjs';
@@ -188,6 +190,7 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
assert.deepEqual(createChannelConfig('dev', 'aarch64-apple-darwin'), {
productName: `${AGC_PRODUCT_NAME}开发版`,
identifier: AGC_APP_IDENTIFIER,
mainBinaryName: AGC_MAIN_BINARY_NAME,
app: {
windows: [
{
@@ -236,6 +239,15 @@ test('channel install identity isolates co-installed builds and keeps the defaul
productName: '陶泥儿 Beta-2',
identifier: `${AGC_APP_IDENTIFIER}.beta-2`,
});
assert.equal(resolveChannelMainBinaryName('dev'), AGC_MAIN_BINARY_NAME);
assert.equal(
resolveChannelMainBinaryName('release'),
`${AGC_MAIN_BINARY_NAME}-release`,
);
assert.equal(
resolveChannelMainBinaryName('beta-2'),
`${AGC_MAIN_BINARY_NAME}-beta-2`,
);
// release 与其它非默认渠道通过独立 identifier 隔离运行身份与数据目录;
// 自定义渠道另外保留可区分的产品名。
@@ -269,6 +281,7 @@ test('channel install identity is baked into the same build-time config as the e
const config = createChannelConfig('release', windowsTarget);
assert.equal(config.productName, AGC_PRODUCT_NAME);
assert.equal(config.identifier, `${AGC_APP_IDENTIFIER}.release`);
assert.equal(config.mainBinaryName, `${AGC_MAIN_BINARY_NAME}-release`);
assert.match(
config.plugins.updater.endpoints[0],
/\/release-win\/latest\.json$/u,
@@ -280,7 +293,7 @@ test('channel install identity is baked into the same build-time config as the e
* dev 渠道展示名改过两次(Genarrative AI Game Creator → 陶泥儿 → 陶泥儿开发版),而更新路径
* (/UPDATE)不会重建快捷方式:改名后的包必须自带旧身份迁移,否则桌面快捷方式会一直指向旧安装。
*/
test('dev 渠道的 Windows 包注入改名迁移钩子,其它渠道与目标不注入', () => {
test('渠道 Windows 包注入各自迁移钩子,且主进程文件名按渠道隔离', () => {
withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => {
const devWindows = createChannelConfig('dev', windowsTarget);
const hooksPath = devWindows.bundle.windows.nsis.installerHooks;
@@ -296,10 +309,27 @@ test('dev 渠道的 Windows 包注入改名迁移钩子,其它渠道与目标
`迁移钩子缺少旧身份:${legacyIdentity}`,
);
}
assert.match(
hooksSource,
/AGC_RELEASE_MAIN_BINARY\s+"genarrative-ai-game-creator-shell-release"/u,
);
// 改名只发生在 dev:其它渠道注入会拿 dev 的旧名表去删别人的安装。
const releaseWindows = createChannelConfig('release', windowsTarget);
const releaseHooksPath = releaseWindows.bundle.windows.nsis.installerHooks;
assert.ok(releaseHooksPath);
assert.equal(existsSync(releaseHooksPath), true);
assert.match(readFileSync(releaseHooksPath, 'utf8'), /陶泥儿 Release/u);
assert.equal(
createChannelConfig('release', windowsTarget).bundle,
releaseWindows.mainBinaryName,
`${AGC_MAIN_BINARY_NAME}-release`,
);
assert.notEqual(
devWindows.mainBinaryName,
releaseWindows.mainBinaryName,
'dev 与 release 的 NSIS 进程匹配名必须不同',
);
assert.equal(
createChannelConfig('beta-2', windowsTarget).bundle,
undefined,
);
// macOS 包没有 NSIS 安装器,不能带上 Windows 钩子。
@@ -395,6 +425,44 @@ test('安装钩子的可执行逻辑必须全在 !macro 内,顶层不得引用
);
});
test('release 安装钩子迁移旧 Release 身份且保持编译期位置纪律', () => {
const hooksPath = createChannelConfig('release', windowsTarget).bundle.windows
.nsis.installerHooks;
const hooksSource = readFileSync(hooksPath, 'utf8');
assert.deepEqual(
readFileSync(hooksPath).subarray(0, 3),
Buffer.from([0xef, 0xbb, 0xbf]),
'NSIS 中文钩子必须保留 UTF-8 BOM',
);
assert.match(hooksSource, /陶泥儿 Release/u);
assert.match(hooksSource, /AGC_RELEASE_LEGACY_BINARY/u);
assert.match(hooksSource, /NSIS_HOOK_PREINSTALL/u);
assert.match(hooksSource, /NSIS_HOOK_POSTINSTALL/u);
let macroDepth = 0;
const offenders = [];
for (const [index, rawLine] of hooksSource.split('\n').entries()) {
const line = rawLine.trim();
if (line === '' || line.startsWith(';')) continue;
if (/^!macroend\b/u.test(line)) {
macroDepth = Math.max(0, macroDepth - 1);
continue;
}
if (/^!macro\b/u.test(line)) {
macroDepth += 1;
continue;
}
if (macroDepth > 0) continue;
if (
line.includes('::') ||
/^(?:Function(?:End)?|Return|Delete|ReadRegStr|StrCpy|CreateShortCut)\b/u.test(
line,
)
)
offenders.push(`第 ${index + 1} 行出现顶层可执行语句:${line}`);
}
assert.deepEqual(offenders, []);
});
/**
* RFC 7386(tauri-utils 用 `json_patch::merge`)语义:对象递归合并,数组整体替换。
* 这里按同样语义复现 Tauri CLI 的 `--config` 合并,用来守住"渠道配置不得丢窗口契约"。
@@ -16,6 +16,12 @@
export const AGC_DEFAULT_CHANNEL = 'dev';
export const AGC_PRODUCT_NAME = '陶泥儿';
export const AGC_APP_IDENTIFIER = 'world.genarrative.ai-game-creator';
/**
* Tauri/NSIS 默认按主可执行文件名查找并结束进程。
* dev 保留历史文件名以维持既有更新链;其它渠道使用独立文件名,
* 这样更新一个渠道时不会把另一个渠道的进程一起结束。
*/
export const AGC_MAIN_BINARY_NAME = 'genarrative-ai-game-creator-shell';
const reservedChannelNames = new Set([
'win',
@@ -75,3 +81,11 @@ export function resolveChannelInstallIdentity(channel = AGC_DEFAULT_CHANNEL) {
: `${AGC_APP_IDENTIFIER}.${channel}`,
});
}
/** 渠道对应的主可执行文件名(不含平台扩展名)。 */
export function resolveChannelMainBinaryName(channel = AGC_DEFAULT_CHANNEL) {
validateReleaseChannel(channel);
return channel === AGC_DEFAULT_CHANNEL
? AGC_MAIN_BINARY_NAME
: `${AGC_MAIN_BINARY_NAME}-${channel}`;
}
@@ -25,7 +25,9 @@ execFileSync(
import {
AGC_APP_IDENTIFIER,
AGC_MAIN_BINARY_NAME,
resolveChannelInstallIdentity,
resolveChannelMainBinaryName,
} from './channel-identity.mjs';
import {
askHidden,
@@ -1299,9 +1301,14 @@ if (tauriConfig.identifier !== AGC_APP_IDENTIFIER) {
throw new Error('AI game creator shell identifier drifted');
}
if (tauriConfig.mainBinaryName !== AGC_MAIN_BINARY_NAME) {
throw new Error('AI game creator shell mainBinaryName drifted');
}
if (
tauriConfig.productName !== defaultChannelIdentity.productName ||
tauriConfig.identifier !== defaultChannelIdentity.identifier
tauriConfig.identifier !== defaultChannelIdentity.identifier ||
tauriConfig.mainBinaryName !== resolveChannelMainBinaryName('dev')
) {
throw new Error(
'AI game creator shell baseline config must match the default channel identity',
@@ -1314,6 +1321,8 @@ for (const channel of ['release', 'beta-2']) {
if (
identity.productName === defaultChannelIdentity.productName ||
identity.identifier === defaultChannelIdentity.identifier ||
resolveChannelMainBinaryName(channel) ===
resolveChannelMainBinaryName('dev') ||
!identity.identifier.startsWith(`${AGC_APP_IDENTIFIER}.`)
) {
throw new Error(`channel install identity not isolated: ${channel}`);
@@ -1631,13 +1640,11 @@ if (!viteConfigSource.includes('allow: [repoRoot]')) {
);
}
if (
!(
tauriConfig.build?.beforeDevCommand?.includes(
'run ai-game-creator-shell:dev-server',
) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve')
)
) {
if (!(
tauriConfig.build?.beforeDevCommand?.includes(
'run ai-game-creator-shell:dev-server',
) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve')
)) {
throw new Error(
'AI game creator shell beforeDevCommand must start the selected Vite dev server',
);