添加 AGC 客户端更新检查与下载能力
Project CI / Native shell tests (pull_request) Failing after 17m13s
Project CI / Repository checks (pull_request) Successful in 9m13s
Project CI / Frontend tests (pull_request) Failing after 2m56s
Project CI / Backend tests (pull_request) Successful in 8m27s

新增 OSS 版本清单检查和更新提示
增加受控安装包下载与完整性校验
补充 Tauri OSS 权限、样式、测试和技术文档
This commit is contained in:
2026-08-31 14:32:47 +08:00
parent c5200c7d45
commit da76cb9056
10 changed files with 412 additions and 7 deletions
@@ -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:*/*" }
@@ -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<String>,
expected_size: Option<u64>,
) -> Result<String, String> {
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 {
@@ -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": {
@@ -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<AppUpdateInfo | null>(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 (
<aside className="app-update-notice" role="status" aria-label="发现新版本">
<div>
<strong> {currentUpdate.version}</strong>
<span> {APP_VERSION}</span>
{currentUpdate.releaseNotes ? (
<p>{currentUpdate.releaseNotes}</p>
) : null}
</div>
<button
type="button"
onClick={() => void handleDownload()}
disabled={downloading}
>
{downloading ? '正在打开下载…' : '下载更新'}
</button>
</aside>
);
}
@@ -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 (
<WindowChromeContext.Provider value={contextValue}>
<div className="window-chrome">
<AppUpdateNotice />
<header className="window-chrome__bar" aria-label="窗口标题栏">
<div className="window-chrome__leading">
<div className="window-chrome__brand" aria-label="陶泥儿 GameAgent">
@@ -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<AppUpdateInfo | null> | 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<string, unknown>;
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<AppUpdateInfo | null> {
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<AppUpdateManifest, 'sha256' | 'size'> = {},
) {
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<string>('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;
}
+54 -4
View File
@@ -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;
@@ -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();
});
});
+1
View File
@@ -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):当前工作台页面和验收边界。
- [立项策划 AgentFast GDD](<./technical/【技术方案】立项策划AgentFast GDD-2026-08-10.md>):当前策划入口、审批和恢复合同。
- [GameAgent 资源自由画板与快速编辑](./technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.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` 根组件,覆盖首页、工作台和调试窗口;网络错误、格式错误或版本不高于当前版本均静默忽略,不阻塞客户端启动。
- 更新请求使用单例 PromiseReact StrictMode 或同一窗口重复挂载不会重复请求。
- Tauri HTTP capability 与 CSP 仅放行默认 OSS 域名;若更换域名,需同步更新 `capabilities/main.json``tauri.conf.json` 和发布环境配置。
## 发布约定
每次发布安装包上传完成后,再上传同一版本的 `agc/latest.json`,确保 `downloadUrl` 指向已存在的 OSS 对象;清单和安装包均使用公开可读对象,不在清单中保存凭据、签名或本地路径。