diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index bfb958806..d35efd97a 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -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; } 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 4b6fc20cc..a77c64b5b 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -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` 合并,用来守住"渠道配置不得丢窗口契约"。 diff --git a/apps/ai-game-creator-shell/scripts/channel-identity.mjs b/apps/ai-game-creator-shell/scripts/channel-identity.mjs index 90ed39529..9af13d7e1 100644 --- a/apps/ai-game-creator-shell/scripts/channel-identity.mjs +++ b/apps/ai-game-creator-shell/scripts/channel-identity.mjs @@ -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}`; +} diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 31dd72bae..5754f5098 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -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', ); diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json index cf24da0c5..63cd157e0 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -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": { diff --git a/apps/ai-game-creator-shell/src-tauri/windows/installer-hooks.nsh b/apps/ai-game-creator-shell/src-tauri/windows/installer-hooks.nsh index a5dadc3bd..16b2c3a80 100644 --- a/apps/ai-game-creator-shell/src-tauri/windows/installer-hooks.nsh +++ b/apps/ai-game-creator-shell/src-tauri/windows/installer-hooks.nsh @@ -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 diff --git a/apps/ai-game-creator-shell/src-tauri/windows/release-installer-hooks.nsh b/apps/ai-game-creator-shell/src-tauri/windows/release-installer-hooks.nsh new file mode 100644 index 000000000..f3b89735c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/windows/release-installer-hooks.nsh @@ -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 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 57c52c190..a5efe61de 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -27,6 +27,14 @@ - 影响范围:`apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts`(新增)、`src/App.tsx`、`src/view/project-development/chat/DirectProjectChatView.tsx`、`.../chat/components/DirectProjectComposer/DirectProjectComposer.tsx`、`.../planning/PlanningChatView.tsx`、`tests/activeChatComposer.test.ts`(新增,钉注册表合同)、`tests/resourceCanvasChatReferenceDrop.test.tsx`、`tests/appSurface/{project-development,design-agent}.suite.ts`、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`、`pitfalls.md`、本文件。 - 验证(合并 master 后的最终一轮):`npx vitest run apps/ai-game-creator-shell/tests`(197 passed / 1 skipped 文件,1918 passed / 17 skipped 用例)、`npx vitest run tests/activeChatComposer.test.ts tests/resourceCanvasChatReferenceDrop.test.tsx`(2 files / 12 passed,含注册表合同:空批次、无输入区、句柄报落空、注销身份校验、重复注册告警、乱序注销)、`npx vitest run tests/appSurface.test.ts -t 引用`(4 passed)、`npm run agc:typecheck`(含 `check:tests:types`,exit 0)、`npm run check:encoding`、`git diff --check`、eslint `--max-warnings 0`(改动文件)。反向证伪:去掉注册调用后端到端用例变红;去掉空批次短路 / 重复注册告警后对应新用例各红一处。 +## 2026-10-04 AGC 渠道更新进程与快捷方式隔离 + +- 背景:Tauri 2.11 的 Windows NSIS 模板通过 `MAINBINARYNAME` 查找并结束进程。dev 与 release 过去共用 `genarrative-ai-game-creator-shell.exe`,更新任一渠道都会结束另一渠道;release 从「陶泥儿 Release」改为「陶泥儿」后,`/UPDATE` 又不会自动重建旧快捷方式。 +- 决策:dev 保留历史主程序文件名以维持升级链;release 使用 `genarrative-ai-game-creator-shell-release.exe`,其它非默认渠道使用带渠道后缀的主程序名。构建期把 `mainBinaryName` 与渠道端点、productName、identifier 同批注入,NSIS 因文件名隔离而只匹配自身渠道进程。 +- 迁移:release Windows 包通过独立安装钩子读取旧 `陶泥儿 Release` 卸载项的安装目录,在原目录安装新包,迁移旧桌面/开始菜单快捷方式并清理旧主程序与孤儿卸载项;dev 继续使用原有旧展示名迁移钩子。 +- 影响范围:AGC 渠道身份脚本、发布构建配置、Tauri 基线配置、Windows NSIS 钩子与渠道发布测试;不改变 OSS 分区、更新端点或客户端数据目录合同。 +- 验证方式:渠道发布脚本定向测试、`check-config.mjs`、真实 NSIS 编译与双渠道安装/更新 smoke;真机安装仍需发布环境执行。 + ## 2026-10-03 AGC 栏目画布上传素材按入口栏目登记(Issue 359) - 背景:AGC 客户端在资源栏目子画布(「UI 交互 / 角色与对象 / 场景与环境 / 音频」)左下角工具栏点「上传」后,提示条给出「已上传 1 个素材」,但当前栏目计数不变(仍「0 项」)、素材出现在「待归类」,用户看到的是"上传成功了但它从这一页消失了"。原因是上传登记的 manifest `kind` 只由**内容证据**推导(`assets.rs::uploaded_asset_kind`:图片 / 视频 / 代码 → `unclassified`,音频 → `audio`,文档 / 字体 → `document`),kind 派生分类与栏目词汇(`ui-interaction` / `character` / `scene` / `audio`)不是同一套,而 `upload_local_asset` 原先不接受入口栏目。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 8ffa666dc..20a8f87ba 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -10,6 +10,15 @@ - **判据/取证**:`npm run test -- apps/ai-game-creator-shell/tests` 里的 `resourceCanvasChatReferenceDrop.test.tsx`、`appSurface/project-development.suite.ts`(工具条「引用」)、`appSurface/design-agent.suite.ts`(策划链路)都断言**真实输入盒草稿**里出现 `[data-resource-reference-id=""]`,不再是「事件被派发」;临时去掉注册调用后这三条会红,证明用例钉的是真链路。详见 [`【功能说明】AGC聊天素材引用-2026-09-08`](../../【功能说明】AGC聊天素材引用-2026-09-08.md) 文首一节。 - **边界**:只要还保留「window 事件 + 模块外 ref 约定」这种形态,新增聊天面就必须一起进注册表;更彻底的形态是画布与聊天的共同宿主(`ProjectDevelopmentView`)用 context 下发插入能力,注册表语义与它一致,将来换实现不必动画布。 +## 2026-10-04 AGC 渠道更新按固定主程序名互相查杀 + +- **现象**:开发版和 release 同时运行时,更新其中一个渠道会把另一个进程一起结束;release 从旧产品名升级后,旧 `陶泥儿 Release.lnk` 仍指向旧安装目录,更新后的 release 没有可用快捷方式。 +- **原因**:Tauri 2.11 的 NSIS `CheckIfAppIsRunning` / `KillProcess` 只按 `MAINBINARYNAME` 匹配。所有渠道都使用 `genarrative-ai-game-creator-shell.exe`,所以插件无法按安装目录区分进程;更新模式还会跳过快捷方式创建,产品名变化后旧图标不会自动迁移。 +- **处理**:`channel-identity.mjs` 新增 `resolveChannelMainBinaryName`:dev 保留旧文件名,release 与其它渠道使用后缀文件名;`createChannelConfig` 同批注入 Tauri `mainBinaryName`。release Windows 包使用独立 `release-installer-hooks.nsh`,从旧 `陶泥儿 Release` 卸载项恢复安装目录,迁移旧快捷方式并删除旧主程序/卸载项;dev 的历史改名钩子保持不变。 +- **不要踩的坑**:只改 `productName` 或 `identifier` 不能阻止 NSIS 互相查杀;只改安装包文件名也不能让 updater 选中正确的进程,必须把 `mainBinaryName` 写入构建期 Tauri 配置,并保证 dev 的历史文件名不变。NSIS 钩子仍只能在 `!macro` 内引用模板常量/插件,且文件必须 UTF-8 with BOM。 +- **判据/验证**:`node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` 覆盖渠道主程序名、release 钩子与宏位置;`node apps/ai-game-creator-shell/scripts/check-config.mjs` 校验基线;真实 Windows NSIS 编译与 dev/release 同机更新 smoke 仍需发布环境执行。 +- **关联**:`apps/ai-game-creator-shell/scripts/channel-identity.mjs`、`apps/ai-game-creator-shell/scripts/build-release.mjs`、`apps/ai-game-creator-shell/src-tauri/windows/{installer-hooks.nsh,release-installer-hooks.nsh}`、`docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md`。 + ## 2026-10-03 AGC 栏目画布上传素材落「待归类」:kind 派生分类不等于入口栏目 - **现象**(Issue 359):在 AGC 资源栏目子画布(如「UI 交互」「角色与对象」)左下角工具栏点「上传」选图片 / 视频 / 代码类文件,提示条给出「已上传 1 个素材」,但当前栏目计数纹丝不动(仍「0 项」),素材出现在「待归类」。用户看到的是"上传成功了,可它就消失在这个页面里"。 @@ -6323,6 +6332,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **验证**:`cargo test --locked -p api-server --bin api-server app::tests::http_tracing`(默认并发与 `--test-threads=1` 各连跑 20 次)、`cargo test -p platform-llm observability_tests`;更接近 CI 并发的是整段 `app::tests::`(91 用例同进程)与 `--skip bgfilter_worker --skip wallet_refund_outbox` 的全量 bin(1133 用例)连跑。 - **关联**:`server-rs/crates/api-server/src/app.rs`、`server-rs/crates/platform-llm/src/observability_tests.rs`。 + ## 2026-10-04 AGC 通知计数与 graceful terminate 的断言偶发都来自"跨线程 / 跨用例串台" - **现象**:`agent::thread_manager::tests::active_turn_changes_publish_one_notification_per_real_change` 偶发 `left: 8 / right: 7`(进度内容变化必须通知一次);`process_session::tests::process_session_graceful_terminate_keeps_wrapper_alive_for_target_cleanup` 偶发 `left: "exited" / right: "terminated"`;两者都在 `AI game creator shell Rust lane 2/2` 分片里红。 diff --git a/docs/project-memory/shared-memory/project-overview.md b/docs/project-memory/shared-memory/project-overview.md index 4634ed447..0674da792 100644 --- a/docs/project-memory/shared-memory/project-overview.md +++ b/docs/project-memory/shared-memory/project-overview.md @@ -58,7 +58,7 @@ SpacetimeDB crate、SDK、CLI / standalone 与生成 bindings 按 `2.8.3` 对齐 - DirectProject 对话先在完整历史中按回合/原始 item 身份关联,再分页渲染;每个回合只有一个呈现入口。有流按 item `seq` 交替文本和工具,无流采用历史正文;禁止位置猜配或同时展示累计回复与 item 正文。流写入单调归并,收尾等待落盘任务,不按磁盘“最后一段”猜最终回复位置。详见 AGC 实施计划的“DirectProject 回合展示唯一归属”。 - 回合生命周期只由活动 client 回合快照和 Direct 事件恢复;Provider 的历史终态通知不能创建活动 client 回合。消息发送时间保存在历史信封,原始 item 不混入宿主字段;完成后的中间文本和工具默认收进“执行过程”,最终回复及失败提示保持可见。 -- AGC 安装产品名由渠道身份决定:`dev` 显示“陶泥儿开发版”,`release` 复用正式产品名“陶泥儿”,自定义渠道显示“陶泥儿 <渠道显示名>”;Tauri `productName` 控制安装项、快捷方式与 EXE 产品描述,identifier 继续按 `<基线>.<渠道>` 派生,保证渠道数据与运行身份隔离(详见《AGC客户端更新检查与下载》的渠道与安装身份合同)。Windows 内置 Codex 安装到顶层 `coding-agent/win-x64/`,打包资源映射与运行时查找路径必须一致。内部可执行文件名保持稳定。 +- AGC 安装产品名由渠道身份决定:`dev` 显示“陶泥儿开发版”,`release` 复用正式产品名“陶泥儿”,自定义渠道显示“陶泥儿 <渠道显示名>”;Tauri `productName` 控制安装项、快捷方式与 EXE 产品描述,identifier 继续按 `<基线>.<渠道>` 派生,主程序文件名也按渠道隔离(dev 保留历史名,release 使用 `-release` 后缀),保证渠道数据、运行身份与更新进程隔离(详见《AGC客户端更新检查与下载》的渠道与安装身份合同)。Windows 内置 Codex 安装到顶层 `coding-agent/win-x64/`,打包资源映射与运行时查找路径必须一致。 - 新 Web 游戏为 `game/` 下的 npm + Vite + Phaser 4.2.1 工程,使用包导入且允许其它依赖;npm 预览与导出只读取 dist,运行素材需纳入构建。单 HTML → Phaser 迁移固定走 DirectProject:文件落盘后先用受控 `project.bootstrap` 在 `game` 执行无参数 `npm install`,再用支持相对 cwd 的 `project.verify` 构建并确认 `game/dist/index.html`,已有单 HTML/Godot 不通过 JSON Generator 伪装成 npm 工程。 diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 59ef49d5b..996793ecb 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -21,6 +21,7 @@ ### 渠道与安装身份合同 - 渠道同时决定**更新端点**与**安装身份**,两者都由构建期写入产物。默认渠道 `dev` 保持基线身份 `productName = 陶泥儿开发版`、`identifier = world.genarrative.ai-game-creator`;`release` 渠道使用正式产品名 `陶泥儿` 并派生 `identifier = world.genarrative.ai-game-creator.release`;其它自定义渠道派生 `productName = 陶泥儿 <渠道显示名>`(如 `beta-2` → `陶泥儿 Beta-2`)与 `identifier = world.genarrative.ai-game-creator.<渠道>`。渠道显示名按连字符分段首字母大写,不改动渠道本身。 +- Windows 主程序文件名也是渠道安装身份的一部分:`dev` 保留 `genarrative-ai-game-creator-shell.exe` 以延续既有升级链,`release` 使用 `genarrative-ai-game-creator-shell-release.exe`,其它渠道使用 `genarrative-ai-game-creator-shell-.exe`。NSIS 更新/卸载按该文件名查找进程,保证更新一个渠道不会结束另一个渠道。 - 默认渠道身份**不可变更**:既有安装目录、卸载项、快捷方式与已发布客户端的升级链都建立在基线身份上。渠道身份由 `apps/ai-game-creator-shell/scripts/channel-identity.mjs` 单点定义,构建入口、macOS 发布入口与配置门禁共同消费;基线 `tauri.conf.json` 必须逐字等于默认渠道身份。 - 安装身份决定的持久与可见事实:Windows 安装目录 `%LOCALAPPDATA%\<产品名>`、卸载项与 `HKCU\Software\genarrative\<产品名>`、WebView2 数据目录 `%LOCALAPPDATA%\`、客户端数据目录 `%APPDATA%\`;macOS `.app` 名、bundle id、DMG 卷名与菜单栏应用名。 - 同机并存:不同渠道的包体可以在同一台设备上同时安装并同时运行,互不覆盖、互不顶掉;同一渠道的新版本仍是原地升级,因为更新端点与安装身份同属一个渠道。 @@ -124,19 +125,20 @@ - 渠道安装身份映射(`` 为 `dev`、`release` 或自定义名称;`` 为渠道显示名): -| 渠道 | productName | identifier | Windows 安装目录 | 客户端数据目录 | -| -------------------- | ------------------ | ---------------------------------------------- | ----------------------------------- | ----------------------------------------------- | -| `dev`(默认) | `陶泥儿` | `world.genarrative.ai-game-creator` | `%LOCALAPPDATA%\陶泥儿` | `%APPDATA%\world.genarrative.ai-game-creator` | -| `release` / 自定义 | `陶泥儿 ` | `world.genarrative.ai-game-creator.` | `%LOCALAPPDATA%\陶泥儿 ` | `%APPDATA%\world.genarrative.ai-game-creator.` | +| 渠道 | productName | 主程序文件名 | identifier | Windows 安装目录 | 客户端数据目录 | +| -------------------- | ------------------ | ------------------------------------------------- | ---------------------------------------------- | ----------------------------------- | ----------------------------------------------- | +| `dev`(默认) | `陶泥儿开发版` | `genarrative-ai-game-creator-shell.exe` | `world.genarrative.ai-game-creator` | `%LOCALAPPDATA%\陶泥儿开发版` | `%APPDATA%\world.genarrative.ai-game-creator` | +| `release` | `陶泥儿` | `genarrative-ai-game-creator-shell-release.exe` | `world.genarrative.ai-game-creator.release` | `%LOCALAPPDATA%\陶泥儿` | `%APPDATA%\world.genarrative.ai-game-creator.release` | +| 自定义渠道 | `陶泥儿 ` | `genarrative-ai-game-creator-shell-.exe` | `world.genarrative.ai-game-creator.` | `%LOCALAPPDATA%\陶泥儿 ` | `%APPDATA%\world.genarrative.ai-game-creator.` | -- 安装身份迁移:`dev` 客户端保持原身份,升级链路连续;`release` 与自定义渠道首次以新身份安装,**不接管也不迁移**任何既有 `dev` 安装、本地项目或登录态,设备上因此可以同时存在两个渠道的客户端,由用户自行决定是否卸载其一。 +- 安装身份迁移:`dev` 客户端保持原 identifier 与主程序文件名,升级链路连续;dev Windows 包继续清理历史展示名 `陶泥儿` / `Genarrative AI Game Creator` 的旧快捷方式。release 从旧展示名 `陶泥儿 Release` 迁移安装目录与快捷方式到当前 `陶泥儿` 身份;release 与自定义渠道不接管也不迁移其它渠道的数据或登录态。 ## 构建与发布 - 发布入口:`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`、`mainBinaryName`、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` 记录“本次没有可用的更新摘要”。 @@ -184,10 +186,11 @@ | 条款 | 验收方式 | 结果 | | --- | --- | --- | -| 渠道身份派生与默认渠道不变 | `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` | 通过;`dev` 逐字等于基线 `陶泥儿开发版` / `world.genarrative.ai-game-creator`,`release` 使用 `陶泥儿` 并保持独立 identifier,`beta-2` 派生带后缀的产品名与独立 identifier,非法渠道失败关闭 | +| 渠道身份派生与默认渠道不变 | `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` | 通过;`dev` 逐字等于基线 `陶泥儿开发版` / `world.genarrative.ai-game-creator` / `genarrative-ai-game-creator-shell`,`release` 使用 `陶泥儿` 并保持独立 identifier 与 `-release` 主程序名,`beta-2` 派生带后缀的产品名、identifier 与主程序名,非法渠道失败关闭 | | 身份与端点同批注入 | 同上的渠道 `--config` 用例 | 通过;`productName` / `identifier` 与 `/-win|mac/latest.json` 来自同一次解析 | | 渠道产物首装包选择 | 同上的渠道 DMG 夹具用例 | 通过;`陶泥儿_<版本>_aarch64.dmg` 仍按 `<版本>_<架构>.dmg` 唯一匹配 | | 基线配置等于默认渠道身份 | `node apps/ai-game-creator-shell/scripts/check-config.mjs` | 通过;基线漂移与非默认渠道身份不隔离都会失败关闭 | +| Windows 更新进程与快捷方式隔离 | `build-release.test.mjs` 渠道配置/NSIS 钩子用例 | 通过;dev/release 主程序文件名不同,release 迁移旧 `陶泥儿 Release` 安装目录与快捷方式 | | 全量发布脚本回归 | `node --test build-release.test.mjs release-oss.test.mjs prepare-macos-codex.test.mjs cargo-features.test.mjs` | 通过(64/64,含 macOS 入口按渠道解析产品名的守卫) | | AGC 自有 AppData 提权 ACL 范围 | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell -- config::private_path_elevation_policy_tests` | 通过(12/12;含基线、`<基线>.release`、`<基线>.beta-2` 与相似前缀 `-backup` 的反向断言) | | 渠道身份进入真实构建产物 | `AGC_UPDATE_CHANNEL=release npm --prefix apps/ai-game-creator-shell run build -- --no-bundle --debug` | 通过;Tauri 接受 `productName=陶泥儿` / `identifier=world.genarrative.ai-game-creator.release` 并完成构建;产物字符串实测 `陶泥儿` × 1、`agc/release-win/latest.json` × 1、`world.genarrative.ai-game-creator.release` × 1、`agc/dev-win/latest.json` × 0 |