修复 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',
);
@@ -1,6 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "陶泥儿开发版",
"mainBinaryName": "genarrative-ai-game-creator-shell",
"version": "0.1.67",
"identifier": "world.genarrative.ai-game-creator",
"build": {
@@ -30,16 +30,19 @@
; 旧展示名表:以后再改展示名,必须在这里追加旧名,否则升级后旧快捷方式会继续指向旧安装。
!define AGC_LEGACY_IDENTITY_1 "陶泥儿"
!define AGC_LEGACY_IDENTITY_2 "Genarrative AI Game Creator"
!define AGC_RELEASE_MAIN_BINARY "genarrative-ai-game-creator-shell-release"
Var AgcLegacyIdentity
Var AgcLegacyInstallDir
Var AgcMigratedLegacyDesktopShortcut
Var AgcLegacyOwnedByRelease
; 清理一个旧身份。调用方先把旧展示名写进 $AgcLegacyIdentity,再 !insertmacro 本宏;宏不返回错误。
; 只在「旧展示名目录下确实存在我们的主程序」时才动手,避免误删同名目录;
; 当前安装目录 $INSTDIR 永不进入删除路径。
!macro AgcMigrateLegacyIdentity
StrCpy $AgcLegacyInstallDir ""
StrCpy $AgcLegacyOwnedByRelease 0
${If} ${FileExists} "$LOCALAPPDATA\$AgcLegacyIdentity\${MAINBINARYNAME}.exe"
StrCpy $AgcLegacyInstallDir "$LOCALAPPDATA\$AgcLegacyIdentity"
@@ -49,6 +52,15 @@ Var AgcMigratedLegacyDesktopShortcut
${If} $AgcLegacyInstallDir != ""
${AndIf} $AgcLegacyInstallDir != $INSTDIR
; release 新包使用独立主程序名;同一旧展示名目录若已有该文件,不能当作旧 dev 清理。
${If} ${FileExists} "$AgcLegacyInstallDir\${AGC_RELEASE_MAIN_BINARY}.exe"
StrCpy $AgcLegacyOwnedByRelease 1
${EndIf}
${EndIf}
${If} $AgcLegacyInstallDir != ""
${AndIf} $AgcLegacyInstallDir != $INSTDIR
${AndIf} $AgcLegacyOwnedByRelease = 0
; 桌面图标:只有目标正好指向旧目录的主程序时才删,避免误删同名的用户快捷方式。
!insertmacro IsShortcutTarget "$DESKTOP\$AgcLegacyIdentity.lnk" "$AgcLegacyInstallDir\${MAINBINARYNAME}.exe"
Pop $0
@@ -0,0 +1,82 @@
; AGC release 渠道的 Windows 安装迁移钩子。
;
; release 曾使用「陶泥儿 Release」作为 productName,后来统一为「陶泥儿」。
; Tauri 的 NSIS 更新模式不会为改名后的身份重建快捷方式,也不会自动读取旧身份的安装目录,
; 所以这里把旧安装目录、旧快捷方式迁移到当前 release 身份。
;
; release 渠道同时使用独立的 MAINBINARYNAME(genarrative-ai-game-creator-shell-release),
; NSIS 默认的按文件名查杀逻辑因此不会碰 dev 渠道的主进程。
;
!define AGC_RELEASE_LEGACY_IDENTITY "陶泥儿 Release"
!define AGC_RELEASE_LEGACY_BINARY "genarrative-ai-game-creator-shell"
Var AgcReleaseLegacyInstallDir
Var AgcReleaseMigratedDesktopShortcut
; Tauri 模板会在 Section Install 开头先执行 SetOutPath,再展开 PREINSTALL 宏。
; 此处仍可在复制文件前改写 $INSTDIR,让旧 release 安装原地升级。
!macro NSIS_HOOK_PREINSTALL
StrCpy $AgcReleaseLegacyInstallDir ""
ReadRegStr $R0 SHCTX "Software\Microsoft\Windows\CurrentVersion\Uninstall\${AGC_RELEASE_LEGACY_IDENTITY}" "InstallLocation"
; Tauri 将 InstallLocation 写成带引号的 REG_SZ;去掉包裹引号后再交给 FileExists。
StrCpy $R1 $R0 1
${If} $R1 == '$\"'
StrCpy $R0 $R0 "" 1
StrLen $R1 $R0
IntOp $R1 $R1 - 1
StrCpy $R0 $R0 $R1
${EndIf}
${If} $R0 != ""
${AndIf} ${FileExists} "$R0\${AGC_RELEASE_LEGACY_BINARY}.exe"
StrCpy $AgcReleaseLegacyInstallDir $R0
StrCpy $INSTDIR $R0
${EndIf}
!macroend
!macro NSIS_HOOK_POSTINSTALL
; 更新模式下模板不会重建快捷方式。先把旧名称和旧主程序名指向当前 release。
!insertmacro IsShortcutTarget "$DESKTOP\${AGC_RELEASE_LEGACY_IDENTITY}.lnk" "$INSTDIR\${AGC_RELEASE_LEGACY_BINARY}.exe"
Pop $0
${If} $0 = 1
StrCpy $AgcReleaseMigratedDesktopShortcut 1
!insertmacro UnpinShortcut "$DESKTOP\${AGC_RELEASE_LEGACY_IDENTITY}.lnk"
Delete "$DESKTOP\${AGC_RELEASE_LEGACY_IDENTITY}.lnk"
${EndIf}
!insertmacro IsShortcutTarget "$SMPROGRAMS\${AGC_RELEASE_LEGACY_IDENTITY}.lnk" "$INSTDIR\${AGC_RELEASE_LEGACY_BINARY}.exe"
Pop $0
${If} $0 = 1
!insertmacro UnpinShortcut "$SMPROGRAMS\${AGC_RELEASE_LEGACY_IDENTITY}.lnk"
Delete "$SMPROGRAMS\${AGC_RELEASE_LEGACY_IDENTITY}.lnk"
${EndIf}
; 用户可能早已把快捷方式改成当前名称,但仍指向旧主程序名。
!insertmacro IsShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${AGC_RELEASE_LEGACY_BINARY}.exe"
Pop $0
${If} $0 = 1
!insertmacro SetShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe"
${EndIf}
!insertmacro IsShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${AGC_RELEASE_LEGACY_BINARY}.exe"
Pop $0
${If} $0 = 1
!insertmacro SetShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe"
${EndIf}
; 开始菜单图标始终存在;桌面图标只在迁移前存在时补建,保持用户选择。
StrCpy $R0 "$INSTDIR\${MAINBINARYNAME}.exe"
${IfNot} ${FileExists} "$SMPROGRAMS\${PRODUCTNAME}.lnk"
CreateShortCut "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$R0"
!insertmacro SetLnkAppUserModelId "$SMPROGRAMS\${PRODUCTNAME}.lnk"
${EndIf}
${If} $AgcReleaseMigratedDesktopShortcut = 1
CreateShortCut "$DESKTOP\${PRODUCTNAME}.lnk" "$R0"
!insertmacro SetLnkAppUserModelId "$DESKTOP\${PRODUCTNAME}.lnk"
${EndIf}
; 新 release 已接管旧安装目录后,删除旧主程序,避免 dev 的迁移钩子把该目录误判为旧 dev。
${If} $AgcReleaseLegacyInstallDir != ""
Delete "$INSTDIR\${AGC_RELEASE_LEGACY_BINARY}.exe"
DeleteRegKey SHCTX "Software\Microsoft\Windows\CurrentVersion\Uninstall\${AGC_RELEASE_LEGACY_IDENTITY}"
DeleteRegKey HKCU "Software\genarrative\${AGC_RELEASE_LEGACY_IDENTITY}"
${EndIf}
!macroend