diff --git a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json index 2c0510b8c..f5b1e6db6 100644 --- a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json +++ b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json @@ -14,6 +14,7 @@ "allow": [ { "url": "https://dev.genarrative.world/api/*" }, { "url": "https://www.genarrative.world/api/*" }, + { "url": "https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/*" }, { "url": "https://*/api/*" }, { "url": "http://localhost:*/*" }, { "url": "http://127.0.0.1:*/*" } diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index ad1499c0b..5c0bd4a55 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -43,6 +43,95 @@ use tauri::{Emitter, Manager}; use tauri_plugin_dialog::DialogExt; use tauri_plugin_opener::OpenerExt; +const AGC_UPDATE_OSS_HOST: &str = "genarrative-assets.oss-cn-shanghai.aliyuncs.com"; +const AGC_UPDATE_MAX_DOWNLOAD_BYTES: u64 = 512 * 1024 * 1024; + +#[tauri::command] +async fn download_agc_update( + app: tauri::AppHandle, + download_url: String, + expected_sha256: Option, + expected_size: Option, +) -> Result { + let parsed = url::Url::parse(download_url.trim()).map_err(|_| "更新下载地址无效".to_string())?; + if parsed.scheme() != "https" || parsed.host_str() != Some(AGC_UPDATE_OSS_HOST) { + return Err("更新下载地址必须来自受信任的 OSS".to_string()); + } + let filename = parsed + .path_segments() + .and_then(|segments| segments.last()) + .filter(|value| !value.is_empty() && value.len() <= 128) + .ok_or_else(|| "更新下载地址缺少文件名".to_string())? + .to_string(); + if filename.contains('/') || filename.contains('\\') || filename.contains("..") { + return Err("更新文件名无效".to_string()); + } + let response = reqwest::Client::new() + .get(parsed) + .send() + .await + .map_err(|_| "下载更新失败".to_string())?; + if !response.status().is_success() { + return Err("下载更新失败".to_string()); + } + if response + .content_length() + .is_some_and(|length| length > AGC_UPDATE_MAX_DOWNLOAD_BYTES) + { + return Err("更新文件超过大小限制".to_string()); + } + let bytes = response + .bytes() + .await + .map_err(|_| "读取更新文件失败".to_string())?; + if bytes.len() as u64 > AGC_UPDATE_MAX_DOWNLOAD_BYTES { + return Err("更新文件超过大小限制".to_string()); + } + if let Some(expected_size) = expected_size { + if bytes.len() as u64 != expected_size { + return Err("更新文件大小校验失败".to_string()); + } + } + if let Some(expected_sha256) = expected_sha256 { + let expected_sha256 = expected_sha256.trim().to_ascii_lowercase(); + if !expected_sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) + || expected_sha256.len() != 64 + { + return Err("更新文件摘要无效".to_string()); + } + use sha2::{Digest, Sha256}; + let actual = format!("{:x}", Sha256::digest(&bytes)); + if actual != expected_sha256 { + return Err("更新文件完整性校验失败".to_string()); + } + } + let download_dir = app + .path() + .download_dir() + .map_err(|_| "无法定位下载目录".to_string())?; + fs::create_dir_all(&download_dir).map_err(|_| "无法创建下载目录".to_string())?; + let mut target = download_dir.join(&filename); + if target.exists() { + let unique_name = format!( + "{}-{}.{}", + target + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("agc-update"), + &uuid::Uuid::new_v4().simple(), + target.extension().and_then(|value| value.to_str()).unwrap_or("bin") + ); + target = download_dir.join(unique_name); + } + let temporary = target.with_extension(format!("{}.download", uuid::Uuid::new_v4())); + fs::write(&temporary, &bytes).map_err(|_| "保存更新文件失败".to_string())?; + if let Err(error) = fs::rename(&temporary, &target) { + let _ = fs::remove_file(&temporary); + return Err(format!("提交更新文件失败:{error}")); + } + Ok(target.to_string_lossy().into_owned()) +} + // 调试落盘模块(保存 LLM 原始输出 / 失败输入,排查截断、空返回等)放在 debug_drafts.rs。 // 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。 mod agent; @@ -2292,7 +2381,8 @@ fn main() { commit_local_project_asset, commit_local_project_asset_canvas_candidate, get_local_game_project_revision, - get_local_game_manifest + get_local_game_manifest, + download_agc_update, ]) .build(tauri_context); let app = match app { 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 4ea2a7e05..8a5e57edc 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -24,8 +24,8 @@ } ], "security": { - "csp": "default-src 'self' customprotocol: asset:; img-src 'self' asset: data: blob:; media-src 'self' asset: data: blob:; connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*", - "devCsp": "default-src 'self' customprotocol: asset: http://127.0.0.1:*; img-src 'self' asset: http://127.0.0.1:* data: blob:; media-src 'self' asset: http://127.0.0.1:* data: blob:; connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*" + "csp": "default-src 'self' customprotocol: asset:; img-src 'self' asset: data: blob:; media-src 'self' asset: data: blob:; connect-src 'self' https://genarrative-assets.oss-cn-shanghai.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*", + "devCsp": "default-src 'self' customprotocol: asset: http://127.0.0.1:*; img-src 'self' asset: http://127.0.0.1:* data: blob:; media-src 'self' asset: http://127.0.0.1:* data: blob:; connect-src 'self' https://genarrative-assets.oss-cn-shanghai.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*" } }, "bundle": { diff --git a/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx new file mode 100644 index 000000000..63132ab48 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react'; + +import { APP_VERSION } from '../app/appMetadata'; +import { + type AppUpdateInfo, + checkForAppUpdate, + downloadAppUpdate, +} from '../services/appUpdate'; + +export function AppUpdateNotice() { + const [update, setUpdate] = useState(null); + const [downloading, setDownloading] = useState(false); + + useEffect(() => { + let mounted = true; + void checkForAppUpdate().then((result) => { + if (mounted) setUpdate(result); + }); + return () => { + mounted = false; + }; + }, []); + + if (!update) return null; + const currentUpdate = update; + + async function handleDownload() { + setDownloading(true); + try { + await downloadAppUpdate(currentUpdate.downloadUrl, currentUpdate); + } finally { + setDownloading(false); + } + } + + return ( + + ); +} diff --git a/apps/ai-game-creator-shell/src/components/WindowChrome.tsx b/apps/ai-game-creator-shell/src/components/WindowChrome.tsx index 77c69f1a0..265de4f43 100644 --- a/apps/ai-game-creator-shell/src/components/WindowChrome.tsx +++ b/apps/ai-game-creator-shell/src/components/WindowChrome.tsx @@ -3,6 +3,7 @@ import { Copy, Minus, Square, X } from 'lucide-react'; import { type ReactNode, useCallback, useEffect, useState } from 'react'; import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png'; +import { AppUpdateNotice } from './AppUpdateNotice'; import { WINDOW_CHROME_DEFAULT_TITLE, WindowChromeContext, @@ -121,6 +122,7 @@ export function WindowChrome({ children }: WindowChromeProps) { return (
+
diff --git a/apps/ai-game-creator-shell/src/services/appUpdate.ts b/apps/ai-game-creator-shell/src/services/appUpdate.ts new file mode 100644 index 000000000..442c03416 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/appUpdate.ts @@ -0,0 +1,140 @@ +import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http'; +import { openUrl } from '@tauri-apps/plugin-opener'; + +import { APP_VERSION } from '../app/appMetadata'; +import { resolveTauriInvoke } from '../app/tauri'; + +/** OSS 上的 AGC 更新清单;发布时可覆盖为同一受信任 OSS 域名下的地址。 */ +export const AGC_UPDATE_MANIFEST_URL = + import.meta.env.VITE_AGC_UPDATE_MANIFEST_URL?.trim() || + 'https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/agc/latest.json'; + +export type AppUpdateManifest = { + version: string; + downloadUrl: string; + sha256?: string; + size?: number; + releaseNotes?: string; +}; + +export type AppUpdateInfo = AppUpdateManifest & { + currentVersion: string; +}; + +let updateCheckPromise: Promise | null = null; + +function parseVersion(value: string) { + const match = value + .trim() + .replace(/^v/iu, '') + .match(/^(\d+)\.(\d+)(?:\.(\d+))?/u); + return match + ? [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)] + : null; +} + +export function isNewerVersion(candidate: string, current: string) { + const next = parseVersion(candidate); + const installed = parseVersion(current); + if (!next || !installed) return false; + for (let index = 0; index < next.length; index += 1) { + const nextValue = next[index] ?? 0; + const installedValue = installed[index] ?? 0; + if (nextValue !== installedValue) return nextValue > installedValue; + } + return false; +} + +export function parseAppUpdateManifest( + value: unknown, +): AppUpdateManifest | null { + if (!value || typeof value !== 'object') return null; + const record = value as Record; + const version = + typeof record.version === 'string' ? record.version.trim() : ''; + const downloadUrl = + typeof record.downloadUrl === 'string' ? record.downloadUrl.trim() : ''; + if (!version || !downloadUrl) return null; + try { + const url = new URL(downloadUrl); + if (url.protocol !== 'https:') return null; + } catch { + return null; + } + const sha256 = + typeof record.sha256 === 'string' + ? record.sha256.trim().toLowerCase() + : undefined; + if (sha256 && !/^[a-f0-9]{64}$/u.test(sha256)) return null; + const size = + typeof record.size === 'number' && + Number.isSafeInteger(record.size) && + record.size > 0 + ? record.size + : undefined; + const releaseNotes = + typeof record.releaseNotes === 'string' + ? record.releaseNotes.trim() + : undefined; + return { + version, + downloadUrl, + ...(sha256 ? { sha256 } : {}), + ...(size ? { size } : {}), + ...(releaseNotes ? { releaseNotes } : {}), + }; +} + +async function fetchUpdateManifest() { + const response = + typeof window !== 'undefined' && window.__TAURI__ + ? await tauriHttpFetch(AGC_UPDATE_MANIFEST_URL, { + method: 'GET', + headers: { Accept: 'application/json' }, + }) + : await fetch(AGC_UPDATE_MANIFEST_URL, { + headers: { Accept: 'application/json' }, + }); + if (!response.ok) throw new Error(`更新清单请求失败:${response.status}`); + return parseAppUpdateManifest(await response.json()); +} + +/** 同一客户端生命周期内只请求一次,避免 StrictMode 或多窗口重复检测。 */ +export function checkForAppUpdate(): Promise { + if (!updateCheckPromise) { + updateCheckPromise = fetchUpdateManifest() + .then((manifest) => + manifest && isNewerVersion(manifest.version, APP_VERSION) + ? { ...manifest, currentVersion: APP_VERSION } + : null, + ) + .catch(() => null); + } + return updateCheckPromise; +} + +export async function downloadAppUpdate( + downloadUrl: string, + integrity: Pick = {}, +) { + const url = new URL(downloadUrl); + if (url.protocol !== 'https:') throw new Error('更新下载地址必须使用 HTTPS'); + if (typeof window !== 'undefined' && window.__TAURI__) { + const invoke = resolveTauriInvoke(); + if (invoke) { + await invoke('download_agc_update', { + downloadUrl: url.toString(), + expectedSha256: integrity.sha256, + expectedSize: integrity.size, + }); + } else { + await openUrl(url.toString()); + } + return; + } + window.open(url.toString(), '_blank', 'noopener,noreferrer'); +} + +export function resetAppUpdateCheckForTests() { + updateCheckPromise = null; +} diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index b150722b5..dab43b8f6 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -22,6 +22,59 @@ body { min-height: 0; } +.app-update-notice { + position: fixed; + z-index: 40; + top: calc(var(--window-chrome-height) + 12px); + right: 16px; + display: flex; + align-items: center; + gap: 16px; + max-width: min(520px, calc(100vw - 32px)); + padding: 12px 14px; + border: 1px solid #efc9ae; + border-radius: 12px; + background: #fffaf5; + box-shadow: 0 8px 24px rgb(100 49 26 / 16%); +} + +.app-update-notice > div { + display: grid; + gap: 3px; + min-width: 0; +} +.app-update-notice strong { + color: #4a220f; + font-size: 13px; +} +.app-update-notice span, +.app-update-notice p { + margin: 0; + color: #8d6a58; + font-size: 11px; +} +.app-update-notice p { + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.app-update-notice button { + flex: 0 0 auto; + padding: 7px 12px; + border: 0; + border-radius: 8px; + background: #c7653d; + color: white; + cursor: pointer; + font-size: 12px; + font-weight: 700; +} +.app-update-notice button:disabled { + cursor: wait; + opacity: 0.65; +} + :root { /* 网页内自绘标题栏占用的顶部高度;portal 到 body 的固定弹层也要从它下方开始。 */ --window-chrome-height: 50px; @@ -308,10 +361,7 @@ body { padding-bottom: 0; } -.window-chrome__content - > .launcher-shell - .launcher-main - > .platform-theme { +.window-chrome__content > .launcher-shell .launcher-main > .platform-theme { height: 100%; min-height: 0; overflow: auto; diff --git a/apps/ai-game-creator-shell/tests/appUpdate.test.ts b/apps/ai-game-creator-shell/tests/appUpdate.test.ts new file mode 100644 index 000000000..1348ce9c2 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/appUpdate.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + isNewerVersion, + parseAppUpdateManifest, + resetAppUpdateCheckForTests, +} from '../src/services/appUpdate'; + +afterEach(() => resetAppUpdateCheckForTests()); + +describe('AGC update manifest', () => { + it('compares semantic versions and accepts v prefixes', () => { + expect(isNewerVersion('v0.1.13', '0.1.12')).toBe(true); + expect(isNewerVersion('0.1.12', '0.1.12')).toBe(false); + expect(isNewerVersion('0.1.11', '0.1.12')).toBe(false); + }); + + it('validates an OSS manifest and rejects non-HTTPS downloads', () => { + expect( + parseAppUpdateManifest({ + version: '0.1.13', + downloadUrl: 'https://oss.example/agc.exe', + }), + ).toMatchObject({ + version: '0.1.13', + downloadUrl: 'https://oss.example/agc.exe', + }); + expect( + parseAppUpdateManifest({ + version: '0.1.13', + downloadUrl: 'http://oss.example/agc.exe', + }), + ).toBeNull(); + }); +}); diff --git a/docs/README.md b/docs/README.md index f937a964d..8fc203af3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,7 @@ ## AI 游戏创作与 Agent Runtime - [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。 +- [AGC 客户端更新检查与下载](./technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md):启动版本检测、OSS 清单格式和下载约定。 - [项目开发工作台 PRD](./prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md):当前工作台页面和验收边界。 - [立项策划 Agent(Fast GDD)](<./technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md>):当前策划入口、审批和恢复合同。 - [GameAgent 资源自由画板与快速编辑](./technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md) diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md new file mode 100644 index 000000000..6252f6ff3 --- /dev/null +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -0,0 +1,32 @@ +# AGC 客户端更新检查与下载 + +## 交付范围 + +AGC 每次启动时由根窗口检查一次公开 OSS 更新清单。清单默认位于 +`https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/agc/latest.json`,构建时可用 +`VITE_AGC_UPDATE_MANIFEST_URL` 覆盖为同一受信任 OSS 域名下的 HTTPS 地址。客户端版本取 +`apps/ai-game-creator-shell/package.json`,通过 `version` 与清单版本比较;只有远端版本更高时显示更新提示。 + +清单格式: + +```json +{ + "version": "0.1.13", + "downloadUrl": "https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/agc/0.1.13/Genarrative-AI-Game-Creator.exe", + "sha256": "<64位十六进制摘要>", + "size": 123456789, + "releaseNotes": "修复与改进" +} +``` + +`downloadUrl` 必须是 HTTPS;如提供 `sha256` / `size`,Tauri 下载时会校验摘要和字节数。点击“下载更新”后,客户端把安装包保存到系统下载目录,安装包安装/替换由操作系统安装器完成。 + +## 启动与失败策略 + +- 检查挂在 `WindowChrome` 根组件,覆盖首页、工作台和调试窗口;网络错误、格式错误或版本不高于当前版本均静默忽略,不阻塞客户端启动。 +- 更新请求使用单例 Promise,React StrictMode 或同一窗口重复挂载不会重复请求。 +- Tauri HTTP capability 与 CSP 仅放行默认 OSS 域名;若更换域名,需同步更新 `capabilities/main.json`、`tauri.conf.json` 和发布环境配置。 + +## 发布约定 + +每次发布安装包上传完成后,再上传同一版本的 `agc/latest.json`,确保 `downloadUrl` 指向已存在的 OSS 对象;清单和安装包均使用公开可读对象,不在清单中保存凭据、签名或本地路径。