diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 2ecf7f2a4..b32659b50 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,13 @@ # 踩坑与排障记录 +## release 安装包文件名中的编码空格不能按控制字符拒绝 + +- **现象**:release 环境点击“下载客户端”只显示无法获取最新版本,`GET /api/client-downloads` 返回 `502 UPSTREAM_ERROR`;dev 环境正常。 +- **原因**:release 渠道产品名为“陶泥儿 Release”,NSIS 首装包名因此包含空格,OSS `latest.json` 中的 URL 使用 `%20`。`validate_download_url` 的百分号解码校验把 `0x20` 当成控制字符拒绝,Windows 清单被判非法;release macOS 清单当时又未发布,聚合后两端都无下载项并返回 502。 +- **处理(现行口径)**:清单 URL 校验允许百分号编码的空格,继续拒绝其它控制字符、`/`、`\`、DEL、非法编码和跨目录文件名;不得通过改写真实产物名绕过校验。 +- **验证**:`platform-oss` 用 `陶泥儿%20Release_0.1.110_x64-setup.exe` 做清单解析回归;线上修复后接口应返回 release Windows 下载项,macOS 未发布时进入 `unavailablePlatforms`。 +- **关联**:`server-rs/crates/platform-oss/src/client_downloads.rs`、`apps/ai-game-creator-shell/scripts/build-release.mjs`、`docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md`。 + ## 客户端图标素材描述不能先包装再截断 - 现象:资源画布填写了具体图标需求,平台实际收到的 `iconDescriptions` 却只包含泛化的小游戏美术指令,生成结果不遵循输入。 diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 68388ccc4..7702b9f39 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -1,6 +1,6 @@ # AGC 客户端更新检查与下载 -更新时间:`2026-09-21` +更新时间:`2026-09-22` 本文件是 AGC 客户端自动更新的主规范:更新能力由 Tauri 官方插件 `tauri-plugin-updater` 承担,并按下文渠道分发。 @@ -27,6 +27,7 @@ - 数据不跨渠道共享:本地项目、工程快照、模板、登录态、诊断日志与 Runner/项目锁按渠道身份分目录。切渠道等于换一个客户端,不迁移、不合并本地数据;渠道内的 origin 隔离规则不变。 - 主窗口与工作区/启动器窗口标题取构建期产品名,让同机并存的渠道客户端在任务栏与 Alt-Tab 中可区分;默认渠道标题仍是「陶泥儿」。 - 首装包与更新包的对象名包含产品名(如 `陶泥儿 Release_0.1.96_x64-setup.exe`、`陶泥儿 Release_0.1.96_aarch64.dmg`)。清单 `downloads` 地址由发布脚本按本次真实产物派生,禁止写死产品名;首装包选择按 `<版本>_<架构>.dmg` 唯一匹配,不依赖产品名字面量。 +- 产品名中的空格在清单 URL 中必须使用标准百分号编码(如 `%20`);网站服务端允许这种合法文件名编码,但仍拒绝控制字符、路径分隔符、DEL 和非法百分号编码,不得通过改写真实产物名绕过校验。 - Windows 提权 ACL 修复助手按目录名识别安装身份:`<基线>` 与 `<基线>.<渠道>` 都在 AGC 自有的 managed 范围内;相似前缀(例如 `world.genarrative.ai-game-creator-backup`)不在范围内,落回 user-selected 范围或直接拒绝。 ## 非目标 diff --git a/server-rs/crates/platform-oss/src/client_downloads.rs b/server-rs/crates/platform-oss/src/client_downloads.rs index d8476fc44..b9bc5f0aa 100644 --- a/server-rs/crates/platform-oss/src/client_downloads.rs +++ b/server-rs/crates/platform-oss/src/client_downloads.rs @@ -304,7 +304,8 @@ fn has_safe_percent_encoding(file_name: &str) -> bool { let Ok(byte) = u8::from_str_radix(hex, 16) else { return false; }; - if byte <= b' ' || matches!(byte, b'/' | b'\\' | 127) { + // 渠道安装身份可以包含空格,OSS 清单中的文件名会将空格编码为 `%20`。 + if byte < b' ' || matches!(byte, b'/' | b'\\' | 127) { return false; } remaining = &encoded[2..]; @@ -541,6 +542,29 @@ mod tests { } } + #[test] + fn percent_encoded_spaces_in_release_installer_names_are_allowed() { + let version = "0.1.110"; + let installer = download_url( + DownloadPlatform::Windows, + version, + "%E9%99%B6%E6%B3%A5%E5%84%BF%20Release_0.1.110_x64-setup.exe", + ) + .replace("/dev-win/", "/release-win/"); + let manifest = manifest_with_downloads( + version, + json!({ + "windows-x86_64": { "url": installer } + }), + ); + + let parsed = + super::parse_manifest(&manifest, DownloadPlatform::Windows, "release").unwrap(); + + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].download_url, installer); + } + #[test] fn macos_requires_explicit_dmg_metadata_and_preserves_available_architectures() { let updater_only = json!({