添加 AGC 客户端更新检查与下载能力
新增 OSS 版本清单检查和更新提示 增加受控安装包下载与完整性校验 补充 Tauri OSS 权限、样式、测试和技术文档
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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 对象;清单和安装包均使用公开可读对象,不在清单中保存凭据、签名或本地路径。
|
||||
Reference in New Issue
Block a user