From da76cb9056c65bbe515a59292942576faad712a8 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 31 Aug 2026 14:32:47 +0800 Subject: [PATCH 01/20] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20AGC=20=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E6=9B=B4=E6=96=B0=E6=A3=80=E6=9F=A5=E4=B8=8E?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E8=83=BD=E5=8A=9B=20=E6=96=B0=E5=A2=9E=20OSS?= =?UTF-8?q?=20=E7=89=88=E6=9C=AC=E6=B8=85=E5=8D=95=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E5=92=8C=E6=9B=B4=E6=96=B0=E6=8F=90=E7=A4=BA=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=8F=97=E6=8E=A7=E5=AE=89=E8=A3=85=E5=8C=85=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E4=B8=8E=E5=AE=8C=E6=95=B4=E6=80=A7=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=20=E8=A1=A5=E5=85=85=20Tauri=20OSS=20=E6=9D=83=E9=99=90?= =?UTF-8?q?=E3=80=81=E6=A0=B7=E5=BC=8F=E3=80=81=E6=B5=8B=E8=AF=95=E5=92=8C?= =?UTF-8?q?=E6=8A=80=E6=9C=AF=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src-tauri/capabilities/main.json | 1 + .../src-tauri/src/main.rs | 92 +++++++++++- .../src-tauri/tauri.conf.json | 4 +- .../src/components/AppUpdateNotice.tsx | 54 +++++++ .../src/components/WindowChrome.tsx | 2 + .../src/services/appUpdate.ts | 140 ++++++++++++++++++ apps/ai-game-creator-shell/src/styles.css | 58 +++++++- .../tests/appUpdate.test.ts | 35 +++++ docs/README.md | 1 + ...方案】AGC客户端更新检查与下载-2026-08-31.md | 32 ++++ 10 files changed, 412 insertions(+), 7 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx create mode 100644 apps/ai-game-creator-shell/src/services/appUpdate.ts create mode 100644 apps/ai-game-creator-shell/tests/appUpdate.test.ts create mode 100644 docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md 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 对象;清单和安装包均使用公开可读对象,不在清单中保存凭据、签名或本地路径。 -- 2.52.0 From 0899118fe1b5aad81b446add2224abb730bcb37a Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 31 Aug 2026 14:55:57 +0800 Subject: [PATCH 02/20] =?UTF-8?q?=E6=9E=84=E5=BB=BA=E6=97=B6=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E7=94=9F=E6=88=90=20AGC=20=E6=9B=B4=E6=96=B0=E6=B8=85?= =?UTF-8?q?=E5=8D=95=20=E6=96=B0=E5=A2=9E=E5=8F=91=E5=B8=83=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E5=8C=85=E8=A3=85=E8=84=9A=E6=9C=AC=E5=B9=B6=E6=89=AB?= =?UTF-8?q?=E6=8F=8F=E5=B9=B3=E5=8F=B0=E5=AE=89=E8=A3=85=E5=8C=85=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E8=AE=A1=E7=AE=97=E4=B8=8B=E8=BD=BD=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E3=80=81=E6=96=87=E4=BB=B6=E5=A4=A7=E5=B0=8F=E5=92=8C?= =?UTF-8?q?=20SHA-256=20=E8=A1=A5=E5=85=85=E6=9E=84=E5=BB=BA=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E6=B5=8B=E8=AF=95=E4=B8=8E=E5=8F=91=E5=B8=83=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/ai-game-creator-shell/package.json | 2 +- .../scripts/build-release.mjs | 107 ++++++++++++++++++ .../scripts/build-release.test.mjs | 36 ++++++ ...方案】AGC客户端更新检查与下载-2026-08-31.md | 4 +- 4 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 apps/ai-game-creator-shell/scripts/build-release.mjs create mode 100644 apps/ai-game-creator-shell/scripts/build-release.test.mjs diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index c7aa09d56..ae8f2b9fc 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -7,7 +7,7 @@ "dev": "node scripts/start-tauri-dev.mjs", "dev-server": "node scripts/start-dev-server.mjs", "dev-stack": "node scripts/start-dev-stack.mjs", - "build": "npm --prefix ../.. exec tauri -- build", + "build": "node scripts/build-release.mjs", "skill-pack:check": "node scripts/check-skill-pack.mjs", "skill-pack:sync": "node scripts/check-skill-pack.mjs --write", "skill-pack:test": "node --test scripts/check-skill-pack.test.mjs", diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs new file mode 100644 index 000000000..0ac7d3a42 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -0,0 +1,107 @@ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const appRoot = fileURLToPath(new URL('..', import.meta.url)); +const bundleRoot = path.join( + appRoot, + 'src-tauri', + 'target', + 'release', + 'bundle', +); +const packageJson = JSON.parse( + fs.readFileSync(path.join(appRoot, 'package.json'), 'utf8'), +); +const defaultOssBaseUrl = + 'https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/agc'; + +function runTauriBuild(args) { + const result = spawnSync( + 'npm', + ['--prefix', '../..', 'exec', 'tauri', '--', 'build', ...args], + { cwd: appRoot, stdio: 'inherit' }, + ); + if (result.error) throw result.error; + if (result.status !== 0) process.exit(result.status ?? 1); +} + +function listFiles(root) { + if (!fs.existsSync(root)) return []; + return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const fullPath = path.join(root, entry.name); + return entry.isDirectory() ? listFiles(fullPath) : [fullPath]; + }); +} + +function artifactPriority(filePath) { + const name = path.basename(filePath).toLowerCase(); + if (process.platform === 'win32') return name.endsWith('.exe') ? 0 : 99; + if (process.platform === 'darwin') return name.endsWith('.dmg') ? 0 : 99; + if (name.endsWith('.appimage')) return 0; + if (name.endsWith('.deb')) return 1; + if (name.endsWith('.rpm')) return 2; + return 99; +} + +export function selectReleaseArtifact(files) { + const explicit = process.env.AGC_UPDATE_ARTIFACT?.trim(); + if (explicit) { + const resolved = path.resolve(explicit); + if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) { + throw new Error(`AGC_UPDATE_ARTIFACT 不是有效文件:${resolved}`); + } + return resolved; + } + return ( + [...files] + .filter((filePath) => artifactPriority(filePath) < 99) + .sort((left, right) => { + const priority = artifactPriority(left) - artifactPriority(right); + return priority || left.localeCompare(right); + })[0] ?? null + ); +} + +export function createUpdateManifest(artifactPath) { + const bytes = fs.readFileSync(artifactPath); + const version = packageJson.version; + const fileName = path.basename(artifactPath); + const baseUrl = ( + process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl + ).replace(/\/+$/u, ''); + const encodedFileName = encodeURIComponent(fileName).replace(/%2F/giu, '/'); + return { + version, + downloadUrl: `${baseUrl}/${encodeURIComponent(version)}/${encodedFileName}`, + sha256: createHash('sha256').update(bytes).digest('hex'), + size: bytes.length, + ...(process.env.AGC_UPDATE_RELEASE_NOTES?.trim() + ? { releaseNotes: process.env.AGC_UPDATE_RELEASE_NOTES.trim() } + : {}), + }; +} + +export function generateUpdateManifest() { + const artifact = selectReleaseArtifact(listFiles(bundleRoot)); + if (!artifact) { + throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`); + } + const manifest = createUpdateManifest(artifact); + const manifestPath = path.join(bundleRoot, 'latest.json'); + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + console.log(`[ai-game-creator-shell] 已生成 ${manifestPath}`); + console.log(`[ai-game-creator-shell] 安装包:${artifact}`); + return { artifact, manifestPath, manifest }; +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + const args = process.argv.slice(2); + runTauriBuild(args); + if (!args.includes('--no-bundle')) generateUpdateManifest(); +} diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs new file mode 100644 index 000000000..e8bf9bb4b --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + createUpdateManifest, + selectReleaseArtifact, +} from './build-release.mjs'; + +test('selects an explicit release artifact when configured', () => { + const artifactPath = new URL('../package.json', import.meta.url).pathname; + const previous = process.env.AGC_UPDATE_ARTIFACT; + process.env.AGC_UPDATE_ARTIFACT = artifactPath; + try { + assert.equal(selectReleaseArtifact([]), artifactPath); + } finally { + if (previous === undefined) delete process.env.AGC_UPDATE_ARTIFACT; + else process.env.AGC_UPDATE_ARTIFACT = previous; + } +}); + +test('does not select unsupported files', () => { + assert.equal( + selectReleaseArtifact(['/tmp/latest.json', '/tmp/readme.txt']), + null, + ); +}); + +test('manifest contains version, download URL and integrity fields', () => { + const manifest = createUpdateManifest( + new URL('../package.json', import.meta.url).pathname, + ); + assert.equal(manifest.version, '0.1.12'); + assert.match(manifest.downloadUrl, /\/agc\/0\.1\.12\/package\.json$/u); + assert.equal(manifest.sha256.length, 64); + assert.equal(typeof manifest.size, 'number'); +}); diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 6252f6ff3..171a9e2fb 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -29,4 +29,6 @@ AGC 每次启动时由根窗口检查一次公开 OSS 更新清单。清单默 ## 发布约定 -每次发布安装包上传完成后,再上传同一版本的 `agc/latest.json`,确保 `downloadUrl` 指向已存在的 OSS 对象;清单和安装包均使用公开可读对象,不在清单中保存凭据、签名或本地路径。 +执行 `npm run ai-game-creator-shell:build` 会在 Tauri 构建完成后自动扫描平台安装包,并在 `apps/ai-game-creator-shell/src-tauri/target/release/bundle/latest.json` 生成包含版本、下载地址、大小和 SHA-256 的清单。可通过 `AGC_UPDATE_ARTIFACT` 指定要发布的安装包,通过 `AGC_UPDATE_OSS_BASE_URL` 指定 OSS 前缀,通过 `AGC_UPDATE_RELEASE_NOTES` 写入发布说明;`--no-bundle` smoke 构建不会生成清单。 + +每次发布安装包上传完成后,再上传同一目录生成的 `latest.json`,确保 `downloadUrl` 指向已存在的 OSS 对象;清单和安装包均使用公开可读对象,不在清单中保存凭据、签名或本地路径。构建脚本不自动上传 OSS,避免把 AccessKey 写入本地配置或 CI 日志。 -- 2.52.0 From f6109b058b715fc4f3d78389a57fc85043e7a6e6 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 31 Aug 2026 15:00:03 +0800 Subject: [PATCH 03/20] =?UTF-8?q?=E5=88=87=E6=8D=A2=20AGC=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=9C=8D=E5=8A=A1=E5=88=B0=20agc-dev=20OSS=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=AE=A2=E6=88=B7=E7=AB=AF=E6=B8=85=E5=8D=95?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E5=9C=B0=E5=9D=80=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E7=99=BD=E5=90=8D=E5=8D=95=E4=B8=8E=20CSP=20?= =?UTF-8?q?=E5=9F=9F=E5=90=8D=20=E4=BF=AE=E6=AD=A3=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E6=B8=85=E5=8D=95=E7=9A=84=20OSS=20=E4=B8=8B=E8=BD=BD=E5=89=8D?= =?UTF-8?q?=E7=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/ai-game-creator-shell/scripts/build-release.mjs | 2 +- apps/ai-game-creator-shell/src-tauri/capabilities/main.json | 2 +- apps/ai-game-creator-shell/src-tauri/src/main.rs | 2 +- apps/ai-game-creator-shell/src-tauri/tauri.conf.json | 4 ++-- apps/ai-game-creator-shell/src/services/appUpdate.ts | 2 +- .../【技术方案】AGC客户端更新检查与下载-2026-08-31.md | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 0ac7d3a42..c11996f8c 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -16,7 +16,7 @@ const packageJson = JSON.parse( fs.readFileSync(path.join(appRoot, 'package.json'), 'utf8'), ); const defaultOssBaseUrl = - 'https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/agc'; + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc'; function runTauriBuild(args) { const result = spawnSync( 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 f5b1e6db6..d4e2ade6a 100644 --- a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json +++ b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json @@ -14,7 +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://agc-dev.oss-rg-china-mainland.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 5c0bd4a55..71dfdeb76 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -43,7 +43,7 @@ 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_OSS_HOST: &str = "agc-dev.oss-rg-china-mainland.aliyuncs.com"; const AGC_UPDATE_MAX_DOWNLOAD_BYTES: u64 = 512 * 1024 * 1024; #[tauri::command] 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 8a5e57edc..1eb8e0319 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' 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:*" + "csp": "default-src 'self' customprotocol: asset:; img-src 'self' asset: data: blob:; media-src 'self' asset: data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.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://agc-dev.oss-rg-china-mainland.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/services/appUpdate.ts b/apps/ai-game-creator-shell/src/services/appUpdate.ts index 442c03416..fe3ac0bbd 100644 --- a/apps/ai-game-creator-shell/src/services/appUpdate.ts +++ b/apps/ai-game-creator-shell/src/services/appUpdate.ts @@ -7,7 +7,7 @@ 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'; + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/latest.json'; export type AppUpdateManifest = { version: string; diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 171a9e2fb..d936904a9 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -3,7 +3,7 @@ ## 交付范围 AGC 每次启动时由根窗口检查一次公开 OSS 更新清单。清单默认位于 -`https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/agc/latest.json`,构建时可用 +`https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/latest.json`,构建时可用 `VITE_AGC_UPDATE_MANIFEST_URL` 覆盖为同一受信任 OSS 域名下的 HTTPS 地址。客户端版本取 `apps/ai-game-creator-shell/package.json`,通过 `version` 与清单版本比较;只有远端版本更高时显示更新提示。 @@ -12,7 +12,7 @@ AGC 每次启动时由根窗口检查一次公开 OSS 更新清单。清单默 ```json { "version": "0.1.13", - "downloadUrl": "https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/agc/0.1.13/Genarrative-AI-Game-Creator.exe", + "downloadUrl": "https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/0.1.13/Genarrative-AI-Game-Creator.exe", "sha256": "<64位十六进制摘要>", "size": 123456789, "releaseNotes": "修复与改进" -- 2.52.0 From 13cc6c2a5d6b92f978f9bbe3acc62bb07b0c1a04 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 31 Aug 2026 15:02:55 +0800 Subject: [PATCH 04/20] =?UTF-8?q?=E5=9B=BA=E5=AE=9A=20AGC=20Windows=20x64?= =?UTF-8?q?=20=E5=8F=91=E5=B8=83=E7=9B=AE=E6=A0=87=20=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E4=BD=BF=E7=94=A8=20x86=5F64-pc-windows-msvc?= =?UTF-8?q?=20=E6=9B=B4=E6=96=B0=E6=B8=85=E5=8D=95=E5=8F=AA=E6=89=AB?= =?UTF-8?q?=E6=8F=8F=20Windows=20NSIS=20=E5=AE=89=E8=A3=85=E5=8C=85=20?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=9E=84=E5=BB=BA=E4=BA=A7=E7=89=A9=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/build-release.mjs | 20 +++++++++++++++++-- ...方案】AGC客户端更新检查与下载-2026-08-31.md | 2 +- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index c11996f8c..c970c8555 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -5,10 +5,14 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); +const defaultReleaseTarget = 'x86_64-pc-windows-msvc'; +const releaseTarget = + process.env.AGC_BUILD_TARGET?.trim() || defaultReleaseTarget; const bundleRoot = path.join( appRoot, 'src-tauri', 'target', + releaseTarget, 'release', 'bundle', ); @@ -19,9 +23,21 @@ const defaultOssBaseUrl = 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc'; function runTauriBuild(args) { + const noBundle = args.includes('--no-bundle'); + const hasTarget = args.includes('--target'); + const targetArgs = noBundle || hasTarget ? [] : ['--target', releaseTarget]; const result = spawnSync( 'npm', - ['--prefix', '../..', 'exec', 'tauri', '--', 'build', ...args], + [ + '--prefix', + '../..', + 'exec', + 'tauri', + '--', + 'build', + ...targetArgs, + ...args, + ], { cwd: appRoot, stdio: 'inherit' }, ); if (result.error) throw result.error; @@ -38,7 +54,7 @@ function listFiles(root) { function artifactPriority(filePath) { const name = path.basename(filePath).toLowerCase(); - if (process.platform === 'win32') return name.endsWith('.exe') ? 0 : 99; + if (releaseTarget.includes('windows')) return name.endsWith('.exe') ? 0 : 99; if (process.platform === 'darwin') return name.endsWith('.dmg') ? 0 : 99; if (name.endsWith('.appimage')) return 0; if (name.endsWith('.deb')) return 1; diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index d936904a9..3d92339e7 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -29,6 +29,6 @@ AGC 每次启动时由根窗口检查一次公开 OSS 更新清单。清单默 ## 发布约定 -执行 `npm run ai-game-creator-shell:build` 会在 Tauri 构建完成后自动扫描平台安装包,并在 `apps/ai-game-creator-shell/src-tauri/target/release/bundle/latest.json` 生成包含版本、下载地址、大小和 SHA-256 的清单。可通过 `AGC_UPDATE_ARTIFACT` 指定要发布的安装包,通过 `AGC_UPDATE_OSS_BASE_URL` 指定 OSS 前缀,通过 `AGC_UPDATE_RELEASE_NOTES` 写入发布说明;`--no-bundle` smoke 构建不会生成清单。 +当前发布目标固定为 Windows x64 NSIS。执行 `npm run ai-game-creator-shell:build` 会向 Tauri 传入 `--target x86_64-pc-windows-msvc`,在构建完成后自动扫描 `.exe` 安装包,并在 `apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/latest.json` 生成包含版本、下载地址、大小和 SHA-256 的清单。可通过 `AGC_BUILD_TARGET` 显式覆盖目标(发布仍应使用 Windows x64),通过 `AGC_UPDATE_ARTIFACT` 指定要发布的安装包,通过 `AGC_UPDATE_OSS_BASE_URL` 指定 OSS 前缀,通过 `AGC_UPDATE_RELEASE_NOTES` 写入发布说明;`--no-bundle` smoke 构建不会生成清单。 每次发布安装包上传完成后,再上传同一目录生成的 `latest.json`,确保 `downloadUrl` 指向已存在的 OSS 对象;清单和安装包均使用公开可读对象,不在清单中保存凭据、签名或本地路径。构建脚本不自动上传 OSS,避免把 AccessKey 写入本地配置或 CI 日志。 -- 2.52.0 From 4a6667754794db2ee25bbb55fa60d23f90fbf9b8 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 31 Aug 2026 15:06:32 +0800 Subject: [PATCH 05/20] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20AGC=20=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E6=9E=84=E5=BB=BA=E4=B8=8A=E4=BC=A0=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=20=E6=9E=84=E5=BB=BA=E5=AE=8C=E6=88=90=E5=90=8E=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E7=94=9F=E6=88=90=E5=B9=B6=E4=B8=8A=E4=BC=A0=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=B8=85=E5=8D=95=20=E9=80=9A=E8=BF=87=20ossutil=20?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=20Windows=20x64=20=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E5=8C=85=20=E4=BF=9D=E7=95=99=E6=9C=AC=E6=9C=BA=E5=87=AD?= =?UTF-8?q?=E6=8D=AE=E4=B8=8E=20OSS=20=E5=8F=82=E6=95=B0=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/ai-game-creator-shell/package.json | 1 + .../scripts/build-release.mjs | 2 +- .../scripts/release-upload.mjs | 34 +++++++++++++++++++ ...方案】AGC客户端更新检查与下载-2026-08-31.md | 2 ++ package.json | 1 + 5 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 apps/ai-game-creator-shell/scripts/release-upload.mjs diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index ae8f2b9fc..50f3050c7 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -8,6 +8,7 @@ "dev-server": "node scripts/start-dev-server.mjs", "dev-stack": "node scripts/start-dev-stack.mjs", "build": "node scripts/build-release.mjs", + "release:upload": "node scripts/release-upload.mjs", "skill-pack:check": "node scripts/check-skill-pack.mjs", "skill-pack:sync": "node scripts/check-skill-pack.mjs --write", "skill-pack:test": "node --test scripts/check-skill-pack.test.mjs", diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index c970c8555..643723e57 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -22,7 +22,7 @@ const packageJson = JSON.parse( const defaultOssBaseUrl = 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc'; -function runTauriBuild(args) { +export function runTauriBuild(args = []) { const noBundle = args.includes('--no-bundle'); const hasTarget = args.includes('--target'); const targetArgs = noBundle || hasTarget ? [] : ['--target', releaseTarget]; diff --git a/apps/ai-game-creator-shell/scripts/release-upload.mjs b/apps/ai-game-creator-shell/scripts/release-upload.mjs new file mode 100644 index 000000000..5b602512a --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/release-upload.mjs @@ -0,0 +1,34 @@ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; + +const bucket = process.env.AGC_OSS_BUCKET?.trim() || 'agc-dev'; +const endpoint = + process.env.AGC_OSS_ENDPOINT?.trim() || 'oss-rg-china-mainland.aliyuncs.com'; +if (!/^[a-z0-9][a-z0-9.-]{1,62}$/u.test(bucket) || /[\r\n\0]/u.test(endpoint)) { + throw new Error('OSS bucket 或 endpoint 配置无效'); +} +process.env.AGC_UPDATE_OSS_BASE_URL ||= `https://${bucket}.${endpoint}/agc`; + +const { generateUpdateManifest, runTauriBuild } = await import( + './build-release.mjs' +); + +function runOssutil(args) { + const binary = process.env.OSSUTIL_BIN?.trim() || 'ossutil'; + const result = spawnSync(binary, [...args, '--endpoint', endpoint], { + stdio: 'inherit', + shell: false, + }); + if (result.error) { + throw new Error(`无法执行 ${binary},请先安装并配置 ossutil`); + } + if (result.status !== 0) process.exit(result.status ?? 1); +} + +runTauriBuild([]); +const { artifact, manifestPath, manifest } = generateUpdateManifest(); +const artifactKey = `agc/${manifest.version}/${path.basename(artifact)}`; +runOssutil(['cp', artifact, `oss://${bucket}/${artifactKey}`]); +runOssutil(['cp', manifestPath, `oss://${bucket}/agc/latest.json`]); +console.log(`[ai-game-creator-shell] 已上传 oss://${bucket}/${artifactKey}`); +console.log(`[ai-game-creator-shell] 已上传 oss://${bucket}/agc/latest.json`); diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 3d92339e7..9c5f26e0b 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -32,3 +32,5 @@ AGC 每次启动时由根窗口检查一次公开 OSS 更新清单。清单默 当前发布目标固定为 Windows x64 NSIS。执行 `npm run ai-game-creator-shell:build` 会向 Tauri 传入 `--target x86_64-pc-windows-msvc`,在构建完成后自动扫描 `.exe` 安装包,并在 `apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/latest.json` 生成包含版本、下载地址、大小和 SHA-256 的清单。可通过 `AGC_BUILD_TARGET` 显式覆盖目标(发布仍应使用 Windows x64),通过 `AGC_UPDATE_ARTIFACT` 指定要发布的安装包,通过 `AGC_UPDATE_OSS_BASE_URL` 指定 OSS 前缀,通过 `AGC_UPDATE_RELEASE_NOTES` 写入发布说明;`--no-bundle` smoke 构建不会生成清单。 每次发布安装包上传完成后,再上传同一目录生成的 `latest.json`,确保 `downloadUrl` 指向已存在的 OSS 对象;清单和安装包均使用公开可读对象,不在清单中保存凭据、签名或本地路径。构建脚本不自动上传 OSS,避免把 AccessKey 写入本地配置或 CI 日志。 + +如需一键构建并上传,可执行 `npm run ai-game-creator-shell:release:upload`。该命令要求本机已安装并配置 `ossutil`,先构建 Windows x64 NSIS,再上传安装包和 `latest.json`。默认上传到 `agc-dev` / `oss-rg-china-mainland.aliyuncs.com`,也可用 `AGC_OSS_BUCKET`、`AGC_OSS_ENDPOINT` 和 `OSSUTIL_BIN` 覆盖;凭据由 ossutil 本机配置读取,不能写入仓库或命令行参数。 diff --git a/package.json b/package.json index 617fb24cd..3002ad3fa 100644 --- a/package.json +++ b/package.json @@ -170,6 +170,7 @@ "ai-game-creator-shell:dev": "npm --prefix apps/ai-game-creator-shell run dev", "ai-game-creator-shell:dev-server": "npm --prefix apps/ai-game-creator-shell run dev-server", "ai-game-creator-shell:build": "npm --prefix apps/ai-game-creator-shell run build --", + "ai-game-creator-shell:release:upload": "npm --prefix apps/ai-game-creator-shell run release:upload", "ai-game-creator-shell:llm-status": "npm --prefix apps/ai-game-creator-shell run llm-status --", "ai-game-creator-shell:agent-task": "npm --prefix apps/ai-game-creator-shell run agent-task --", "agc:chat": "npm --prefix apps/ai-game-creator-shell run chat --", -- 2.52.0 From 9ecc4eeae8c3b2d1b5f4d739fdfdb74d17546b65 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 31 Aug 2026 15:22:38 +0800 Subject: [PATCH 06/20] =?UTF-8?q?=E6=9E=84=E5=BB=BA=E5=89=8D=E6=8C=89=20OS?= =?UTF-8?q?S=20=E7=89=88=E6=9C=AC=E9=80=92=E5=A2=9E=20AGC=20patch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 构建前读取 OSS latest.json 并选择较高版本递增 patch 同步更新 AGC package、Tauri 与 Cargo 版本字段 让一键上传复用版本准备流程并更新发布文档 补充版本计算、配置校验与清单生成测试 --- .../scripts/build-release.mjs | 141 +++++++++++++++++- .../scripts/build-release.test.mjs | 16 +- .../scripts/check-config.mjs | 12 +- .../scripts/release-upload.mjs | 6 +- ...方案】AGC客户端更新检查与下载-2026-08-31.md | 15 +- scripts/check-npm-workspaces.mjs | 14 +- 6 files changed, 183 insertions(+), 21 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 643723e57..cf815d094 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -16,11 +16,143 @@ const bundleRoot = path.join( 'release', 'bundle', ); -const packageJson = JSON.parse( - fs.readFileSync(path.join(appRoot, 'package.json'), 'utf8'), -); +const packageJsonPath = path.join(appRoot, 'package.json'); +const rootPackageLockPath = path.resolve(appRoot, '../..', 'package-lock.json'); +const tauriConfigPath = path.join(appRoot, 'src-tauri', 'tauri.conf.json'); +const cargoManifestPath = path.join(appRoot, 'src-tauri', 'Cargo.toml'); +const cargoLockPath = path.join(appRoot, 'src-tauri', 'Cargo.lock'); const defaultOssBaseUrl = 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc'; +const updateManifestUrl = + process.env.VITE_AGC_UPDATE_MANIFEST_URL?.trim() || + `${process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl}/latest.json`; + +function readPackageJson() { + return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); +} + +export function compareVersions(left, right) { + const leftParts = left.split('.').map(Number); + const rightParts = right.split('.').map(Number); + for (let index = 0; index < 3; index += 1) { + if (leftParts[index] !== rightParts[index]) { + return leftParts[index] > rightParts[index] ? 1 : -1; + } + } + return 0; +} + +function parseVersion(value, label) { + if (typeof value !== 'string' || !/^\d+\.\d+\.\d+$/u.test(value)) { + throw new Error(`${label} 不是有效的三段版本号:${String(value)}`); + } + return value; +} + +export function nextPatchVersion(localVersion, remoteVersion) { + const local = parseVersion(localVersion, '本地版本'); + const remote = + remoteVersion == null ? null : parseVersion(remoteVersion, 'OSS版本'); + const base = remote && compareVersions(remote, local) > 0 ? remote : local; + const [major, minor, patch] = base.split('.').map(Number); + if (patch === Number.MAX_SAFE_INTEGER) { + throw new Error(`版本号 patch 已达到上限:${base}`); + } + return `${major}.${minor}.${patch + 1}`; +} + +async function readRemoteVersion() { + let response; + try { + response = await fetch(updateManifestUrl, { + headers: { Accept: 'application/json' }, + }); + } catch (error) { + throw new Error(`读取 OSS 版本清单失败:${error.message}`); + } + if (response.status === 404) return null; + if (!response.ok) { + throw new Error(`读取 OSS 版本清单失败:HTTP ${response.status}`); + } + let manifest; + try { + manifest = await response.json(); + } catch (error) { + throw new Error(`OSS 版本清单不是有效 JSON:${error.message}`); + } + return parseVersion(manifest?.version, 'OSS版本清单 version'); +} + +function replaceVersionLine(source, version, pattern, label) { + if (!pattern.test(source)) throw new Error(`未找到${label}版本字段`); + return source.replace(pattern, `$1${version}$3`); +} + +export async function prepareReleaseVersion() { + const localVersion = parseVersion(readPackageJson().version, '本地版本'); + const remoteVersion = await readRemoteVersion(); + const nextVersion = nextPatchVersion(localVersion, remoteVersion); + + const packageSource = fs.readFileSync(packageJsonPath, 'utf8'); + fs.writeFileSync( + packageJsonPath, + replaceVersionLine( + packageSource, + nextVersion, + /("version"\s*:\s*")([^"]+)(")/u, + 'package.json', + ), + ); + + const rootPackageLockSource = fs.readFileSync(rootPackageLockPath, 'utf8'); + fs.writeFileSync( + rootPackageLockPath, + replaceVersionLine( + rootPackageLockSource, + nextVersion, + /("apps\/ai-game-creator-shell"\s*:\s*\{\s*\n\s*"name"\s*:\s*"@genarrative\/ai-game-creator-shell"\s*,\s*\n\s*"version"\s*:\s*")([^"]+)(")/u, + 'root package-lock.json', + ), + ); + + const tauriSource = fs.readFileSync(tauriConfigPath, 'utf8'); + fs.writeFileSync( + tauriConfigPath, + replaceVersionLine( + tauriSource, + nextVersion, + /("version"\s*:\s*")([^"]+)(")/u, + 'tauri.conf.json', + ), + ); + + const cargoSource = fs.readFileSync(cargoManifestPath, 'utf8'); + fs.writeFileSync( + cargoManifestPath, + replaceVersionLine( + cargoSource, + nextVersion, + /(^\[package\][\s\S]*?^version\s*=\s*")([^"]+)(")/mu, + 'Cargo.toml', + ), + ); + + const cargoLockSource = fs.readFileSync(cargoLockPath, 'utf8'); + fs.writeFileSync( + cargoLockPath, + replaceVersionLine( + cargoLockSource, + nextVersion, + /(^name\s*=\s*"genarrative-ai-game-creator-shell"\s*\nversion\s*=\s*")([^"]+)(")/mu, + 'Cargo.lock', + ), + ); + + console.log( + `[ai-game-creator-shell] 版本 ${localVersion} / OSS ${remoteVersion ?? '不存在'} -> ${nextVersion}`, + ); + return nextVersion; +} export function runTauriBuild(args = []) { const noBundle = args.includes('--no-bundle'); @@ -83,7 +215,7 @@ export function selectReleaseArtifact(files) { export function createUpdateManifest(artifactPath) { const bytes = fs.readFileSync(artifactPath); - const version = packageJson.version; + const version = readPackageJson().version; const fileName = path.basename(artifactPath); const baseUrl = ( process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl @@ -118,6 +250,7 @@ if ( path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) ) { const args = process.argv.slice(2); + if (!args.includes('--no-bundle')) await prepareReleaseVersion(); runTauriBuild(args); if (!args.includes('--no-bundle')) generateUpdateManifest(); } 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 e8bf9bb4b..ffc8bb8b2 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -2,7 +2,9 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { + compareVersions, createUpdateManifest, + nextPatchVersion, selectReleaseArtifact, } from './build-release.mjs'; @@ -29,8 +31,18 @@ test('manifest contains version, download URL and integrity fields', () => { const manifest = createUpdateManifest( new URL('../package.json', import.meta.url).pathname, ); - assert.equal(manifest.version, '0.1.12'); - assert.match(manifest.downloadUrl, /\/agc\/0\.1\.12\/package\.json$/u); + assert.match(manifest.version, /^\d+\.\d+\.\d+$/u); + assert.match( + manifest.downloadUrl, + new RegExp(`/agc/${manifest.version}/package\\.json$`, 'u'), + ); assert.equal(manifest.sha256.length, 64); assert.equal(typeof manifest.size, 'number'); }); + +test('next release version follows the higher local or OSS version', () => { + assert.equal(compareVersions('0.1.15', '0.1.12'), 1); + assert.equal(nextPatchVersion('0.1.12', '0.1.15'), '0.1.16'); + assert.equal(nextPatchVersion('0.1.18', '0.1.15'), '0.1.19'); + assert.equal(nextPatchVersion('0.1.12', null), '0.1.13'); +}); diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 6817d69bb..65cc17b1d 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1532,13 +1532,17 @@ if ( ); } +const releaseVersions = [ + packageConfig.version, + tauriConfig.version, + cargoPackageVersion, +]; if ( - tauriConfig.version !== '0.1.12' || - packageConfig.version !== '0.1.12' || - cargoPackageVersion !== '0.1.12' + releaseVersions.some((version) => !/^\d+\.\d+\.\d+$/u.test(version ?? '')) || + new Set(releaseVersions).size !== 1 ) { throw new Error( - 'AI game creator standard release must remain version 0.1.12', + 'AI game creator release versions must be valid three-part semver and synchronized', ); } diff --git a/apps/ai-game-creator-shell/scripts/release-upload.mjs b/apps/ai-game-creator-shell/scripts/release-upload.mjs index 5b602512a..43bd53520 100644 --- a/apps/ai-game-creator-shell/scripts/release-upload.mjs +++ b/apps/ai-game-creator-shell/scripts/release-upload.mjs @@ -9,9 +9,8 @@ if (!/^[a-z0-9][a-z0-9.-]{1,62}$/u.test(bucket) || /[\r\n\0]/u.test(endpoint)) { } process.env.AGC_UPDATE_OSS_BASE_URL ||= `https://${bucket}.${endpoint}/agc`; -const { generateUpdateManifest, runTauriBuild } = await import( - './build-release.mjs' -); +const { generateUpdateManifest, prepareReleaseVersion, runTauriBuild } = + await import('./build-release.mjs'); function runOssutil(args) { const binary = process.env.OSSUTIL_BIN?.trim() || 'ossutil'; @@ -25,6 +24,7 @@ function runOssutil(args) { if (result.status !== 0) process.exit(result.status ?? 1); } +await prepareReleaseVersion(); runTauriBuild([]); const { artifact, manifestPath, manifest } = generateUpdateManifest(); const artifactKey = `agc/${manifest.version}/${path.basename(artifact)}`; diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 9c5f26e0b..300ad78ea 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -29,8 +29,19 @@ AGC 每次启动时由根窗口检查一次公开 OSS 更新清单。清单默 ## 发布约定 -当前发布目标固定为 Windows x64 NSIS。执行 `npm run ai-game-creator-shell:build` 会向 Tauri 传入 `--target x86_64-pc-windows-msvc`,在构建完成后自动扫描 `.exe` 安装包,并在 `apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/latest.json` 生成包含版本、下载地址、大小和 SHA-256 的清单。可通过 `AGC_BUILD_TARGET` 显式覆盖目标(发布仍应使用 Windows x64),通过 `AGC_UPDATE_ARTIFACT` 指定要发布的安装包,通过 `AGC_UPDATE_OSS_BASE_URL` 指定 OSS 前缀,通过 `AGC_UPDATE_RELEASE_NOTES` 写入发布说明;`--no-bundle` smoke 构建不会生成清单。 +当前发布目标固定为 Windows x64 NSIS。执行 `npm run ai-game-creator-shell:build` 会先读取 +`VITE_AGC_UPDATE_MANIFEST_URL`(默认 `https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/latest.json`)的 +`latest.json`,取本地与 OSS 的较高版本并递增一个 patch,然后同步更新 package、Tauri 和 Cargo +版本后再向 Tauri 传入 `--target x86_64-pc-windows-msvc` 构建。OSS 清单首次不存在时按本地版本递增; +OSS 请求失败、清单格式错误或版本无效会终止发布,避免覆盖线上版本。构建完成后自动扫描 `.exe` +安装包,并在 `apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/latest.json` +生成包含版本、下载地址、大小和 SHA-256 的清单。可通过 `AGC_BUILD_TARGET` 显式覆盖目标(发布仍应使用 +Windows x64),通过 `AGC_UPDATE_ARTIFACT` 指定要发布的安装包,通过 `AGC_UPDATE_OSS_BASE_URL` 指定 +OSS 前缀,通过 `AGC_UPDATE_RELEASE_NOTES` 写入发布说明;`--no-bundle` smoke 构建不会读取 OSS、修改版本或生成清单。 每次发布安装包上传完成后,再上传同一目录生成的 `latest.json`,确保 `downloadUrl` 指向已存在的 OSS 对象;清单和安装包均使用公开可读对象,不在清单中保存凭据、签名或本地路径。构建脚本不自动上传 OSS,避免把 AccessKey 写入本地配置或 CI 日志。 -如需一键构建并上传,可执行 `npm run ai-game-creator-shell:release:upload`。该命令要求本机已安装并配置 `ossutil`,先构建 Windows x64 NSIS,再上传安装包和 `latest.json`。默认上传到 `agc-dev` / `oss-rg-china-mainland.aliyuncs.com`,也可用 `AGC_OSS_BUCKET`、`AGC_OSS_ENDPOINT` 和 `OSSUTIL_BIN` 覆盖;凭据由 ossutil 本机配置读取,不能写入仓库或命令行参数。 +如需一键构建并上传,可执行 `npm run ai-game-creator-shell:release:upload`。该命令要求本机已安装并配置 `ossutil`, +先按上述规则比较 OSS 版本、递增 patch、构建 Windows x64 NSIS,再上传安装包和 `latest.json`。默认上传到 +`agc-dev` / `oss-rg-china-mainland.aliyuncs.com`,也可用 `AGC_OSS_BUCKET`、`AGC_OSS_ENDPOINT` 和 `OSSUTIL_BIN` +覆盖;凭据由 ossutil 本机配置读取,不能写入仓库或命令行参数。 diff --git a/scripts/check-npm-workspaces.mjs b/scripts/check-npm-workspaces.mjs index 0c55b3b53..09bee0f04 100644 --- a/scripts/check-npm-workspaces.mjs +++ b/scripts/check-npm-workspaces.mjs @@ -173,12 +173,14 @@ export function collectNpmWorkspaceErrors(rootDir) { `${manifestPath}: name must be ${WORKSPACE_NAMES[workspacePath]}, received ${String(manifest.name)}`, ); } - const expectedWorkspaceVersion = - workspacePath === 'apps/ai-game-creator-shell' ? '0.1.12' : '0.1.0'; - if (manifest.version !== expectedWorkspaceVersion) { - errors.push( - `${manifestPath}: workspace version must be ${expectedWorkspaceVersion}`, - ); + if (workspacePath === 'apps/ai-game-creator-shell') { + if (!/^\d+\.\d+\.\d+$/u.test(manifest.version ?? '')) { + errors.push( + `${manifestPath}: workspace version must be a three-part semver`, + ); + } + } else if (manifest.version !== '0.1.0') { + errors.push(`${manifestPath}: workspace version must be 0.1.0`); } for (const nestedLockfile of findNestedLockfiles(rootDir, workspacePath)) { -- 2.52.0 From 0e2bee9fa7ef7980eff313a8908dc5b0b2cda635 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 31 Aug 2026 17:01:37 +0800 Subject: [PATCH 07/20] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20AGC=20Windows=20Jenk?= =?UTF-8?q?ins=20=E6=9E=84=E5=BB=BA=E6=B5=81=E6=B0=B4=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 Windows Agent 构建与上传流水线 补充 AGC Jenkins 节点工具链和 OSS 权限约定 --- ...方案】AGC客户端更新检查与下载-2026-08-31.md | 12 ++ .../Jenkinsfile.ai-game-creator-shell-build | 133 ++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 jenkins/Jenkinsfile.ai-game-creator-shell-build diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 300ad78ea..f2b8e8c2a 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -45,3 +45,15 @@ OSS 前缀,通过 `AGC_UPDATE_RELEASE_NOTES` 写入发布说明;`--no-bundle 先按上述规则比较 OSS 版本、递增 patch、构建 Windows x64 NSIS,再上传安装包和 `latest.json`。默认上传到 `agc-dev` / `oss-rg-china-mainland.aliyuncs.com`,也可用 `AGC_OSS_BUCKET`、`AGC_OSS_ENDPOINT` 和 `OSSUTIL_BIN` 覆盖;凭据由 ossutil 本机配置读取,不能写入仓库或命令行参数。 + +## Jenkins Windows 构建节点 + +AGC 发布流水线使用 `jenkins/Jenkinsfile.ai-game-creator-shell-build`,节点标签固定为 +`windows && genarrative-agc-build`。节点应为 Windows Server 2022 x64 虚拟机,预装 Node.js 22、npm +10.9.7、Rust 1.96.0、Visual Studio Build Tools(MSVC 与 Windows SDK)、NSIS、Git 和 ossutil; +Jenkins Agent 服务必须能在同一用户环境中找到这些命令。流水线执行根 workspace 的 `npm ci`,然后调用 +`npm run ai-game-creator-shell:release:upload`,并归档 Windows 安装包、`latest.json` 与源码 commit。 + +Jenkins 节点上的 ossutil 必须使用具备 `PutObject` 权限的受保护配置;OSS 对客户端保持公共读即可, +公共读本身不授予 Jenkins 上传权限。由于版本号取决于 OSS 当前清单,Job 已关闭并发构建;若 Jenkins +上存在多个 AGC 发布 Job,还应使用同一个 Lockable Resource 串行化发布。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-build b/jenkins/Jenkinsfile.ai-game-creator-shell-build new file mode 100644 index 000000000..274a8d65d --- /dev/null +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-build @@ -0,0 +1,133 @@ +pipeline { + agent { + label 'windows && genarrative-agc-build' + } + + options { + disableConcurrentBuilds() + skipDefaultCheckout(true) + buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) + } + + environment { + GIT_REMOTE_URL = 'ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git' + GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh' + GENARRATIVE_NPM_VERSION = '10.9.7' + AGC_OSS_BUCKET = 'agc-dev' + AGC_OSS_ENDPOINT = 'oss-rg-china-mainland.aliyuncs.com' + } + + parameters { + string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '源码分支') + string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit') + string(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选,写入 latest.json 的发布说明') + string(name: 'OSSUTIL_BIN', defaultValue: 'ossutil', description: 'ossutil 或 ossutil.exe 的绝对路径/命令名') + } + + stages { + stage('Checkout') { + steps { + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [ + [$class: 'CleanBeforeCheckout'], + [$class: 'CloneOption', shallow: false, noTags: false, timeout: 30], + ], + userRemoteConfigs: [[ + url: env.GIT_REMOTE_URL, + credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, + refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}", + ]], + ]) + powershell ''' + $ErrorActionPreference = 'Stop' + $commit = $env:COMMIT_HASH.Trim() + if ($commit) { + if ($commit -notmatch '^[0-9a-fA-F]{7,64}$') { + throw 'COMMIT_HASH 必须是 7-64 位十六进制 Git commit。' + } + git cat-file -e "${commit}^{commit}" + if ($LASTEXITCODE -ne 0) { + throw "COMMIT_HASH 不存在: $commit" + } + $branchRef = "refs/remotes/origin/$($env:SOURCE_BRANCH)" + git merge-base --is-ancestor $commit $branchRef + if ($LASTEXITCODE -ne 0) { + throw "COMMIT_HASH 不属于 SOURCE_BRANCH: $($env:SOURCE_BRANCH)" + } + git checkout --detach $commit + } + $resolved = (git rev-parse HEAD).Trim() + Set-Content -Path '.jenkins-source-commit' -Value $resolved -NoNewline + Write-Host "源码 commit=$resolved" + ''' + } + } + + stage('Toolchain preflight') { + steps { + withEnv(["OSSUTIL_BIN=${params.OSSUTIL_BIN}"]) { + powershell ''' + $ErrorActionPreference = 'Stop' + $actualNpm = (npm --version).Trim() + if ($actualNpm -ne $env:GENARRATIVE_NPM_VERSION) { + throw "npm 版本不匹配:期望 $($env:GENARRATIVE_NPM_VERSION),实际 $actualNpm" + } + foreach ($commandName in @('node', 'npm', 'rustc', 'cargo', 'makensis')) { + if (-not (Get-Command $commandName -ErrorAction SilentlyContinue)) { + throw "Windows AGC 节点缺少 $commandName,请先安装并加入 Jenkins Agent PATH。" + } + } + $ossutil = $env:OSSUTIL_BIN.Trim() + if (-not $ossutil) { $ossutil = 'ossutil' } + if (-not (Get-Command $ossutil -ErrorAction SilentlyContinue)) { + throw "Windows AGC 节点缺少 ossutil:$ossutil" + } + Write-Host "node=$((node --version).Trim())" + Write-Host "npm=$actualNpm" + Write-Host "rustc=$((rustc --version).Trim())" + Write-Host "cargo=$((cargo --version).Trim())" + Write-Host "makensis=$((makensis -VERSION | Select-Object -First 1).Trim())" + ''' + } + } + } + + stage('Install dependencies') { + steps { + powershell ''' + $ErrorActionPreference = 'Stop' + npm ci --no-audit --no-fund + ''' + } + } + + stage('Build and upload') { + steps { + withEnv([ + "OSSUTIL_BIN=${params.OSSUTIL_BIN}", + "AGC_UPDATE_RELEASE_NOTES=${params.AGC_UPDATE_RELEASE_NOTES}", + ]) { + powershell ''' + $ErrorActionPreference = 'Stop' + npm run ai-game-creator-shell:release:upload + ''' + } + } + } + + stage('Archive release') { + steps { + archiveArtifacts artifacts: 'apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.exe,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/latest.json,.jenkins-source-commit', fingerprint: true + } + } + } + + post { + success { + echo 'AGC Windows x64 安装包已构建并上传 OSS。' + } + } +} -- 2.52.0 From c4d99a1f355a2a1bb9ec4ec62b9556a31fc8cc9d Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 1 Sep 2026 10:38:37 +0800 Subject: [PATCH 08/20] =?UTF-8?q?=E5=AF=B9=E9=BD=90=20AGC=20Windows=20?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用现有 windows 与 win2022 标签调度构建 同步更新 Jenkins 节点文档 --- .../【技术方案】AGC客户端更新检查与下载-2026-08-31.md | 4 ++-- jenkins/Jenkinsfile.ai-game-creator-shell-build | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index f2b8e8c2a..e3128e7fe 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -48,8 +48,8 @@ OSS 前缀,通过 `AGC_UPDATE_RELEASE_NOTES` 写入发布说明;`--no-bundle ## Jenkins Windows 构建节点 -AGC 发布流水线使用 `jenkins/Jenkinsfile.ai-game-creator-shell-build`,节点标签固定为 -`windows && genarrative-agc-build`。节点应为 Windows Server 2022 x64 虚拟机,预装 Node.js 22、npm +AGC 发布流水线使用 `jenkins/Jenkinsfile.ai-game-creator-shell-build`,当前节点标签为 +`windows && win2022`。节点应为 Windows Server 2022 x64 虚拟机,预装 Node.js 22、npm 10.9.7、Rust 1.96.0、Visual Studio Build Tools(MSVC 与 Windows SDK)、NSIS、Git 和 ossutil; Jenkins Agent 服务必须能在同一用户环境中找到这些命令。流水线执行根 workspace 的 `npm ci`,然后调用 `npm run ai-game-creator-shell:release:upload`,并归档 Windows 安装包、`latest.json` 与源码 commit。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-build b/jenkins/Jenkinsfile.ai-game-creator-shell-build index 274a8d65d..e22667100 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-build @@ -1,6 +1,6 @@ pipeline { agent { - label 'windows && genarrative-agc-build' + label 'windows && win2022' } options { -- 2.52.0 From 56aac66f02bf77fe0b344a562545cc5b996984c8 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 1 Sep 2026 11:34:47 +0800 Subject: [PATCH 09/20] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=20Windows=E6=B5=81?= =?UTF-8?q?=E6=B0=B4=E7=BA=BF=E5=B7=A5=E5=85=B7=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为Windows x64构建注入Git、Node、Rust、NSIS和ossutil路径 --- jenkins/Jenkinsfile.ai-game-creator-shell-build | 1 + 1 file changed, 1 insertion(+) diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-build b/jenkins/Jenkinsfile.ai-game-creator-shell-build index e22667100..7d0cc0947 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-build @@ -15,6 +15,7 @@ pipeline { GENARRATIVE_NPM_VERSION = '10.9.7' AGC_OSS_BUCKET = 'agc-dev' AGC_OSS_ENDPOINT = 'oss-rg-china-mainland.aliyuncs.com' + PATH+AGC_TOOLS = 'C:\\Tools\\Git\\cmd;C:\\Program Files\\nodejs;C:\\Users\\Administrator\\.cargo\\bin;C:\\Program Files (x86)\\NSIS;C:\\Tools\\ossutil' } parameters { -- 2.52.0 From df5ae8f927a1d5834cdee713a086aaf61657fb3e Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 1 Sep 2026 11:36:34 +0800 Subject: [PATCH 10/20] =?UTF-8?q?=E4=BF=AE=E6=AD=A3AGC=20Windows=E6=B5=81?= =?UTF-8?q?=E6=B0=B4=E7=BA=BF=E7=8E=AF=E5=A2=83=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通过withEnv注入Windows构建工具路径 覆盖源码检出、依赖安装和构建上传阶段 --- .../Jenkinsfile.ai-game-creator-shell-build | 87 ++++++++++--------- 1 file changed, 46 insertions(+), 41 deletions(-) diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-build b/jenkins/Jenkinsfile.ai-game-creator-shell-build index 7d0cc0947..0619ec54e 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-build @@ -15,7 +15,7 @@ pipeline { GENARRATIVE_NPM_VERSION = '10.9.7' AGC_OSS_BUCKET = 'agc-dev' AGC_OSS_ENDPOINT = 'oss-rg-china-mainland.aliyuncs.com' - PATH+AGC_TOOLS = 'C:\\Tools\\Git\\cmd;C:\\Program Files\\nodejs;C:\\Users\\Administrator\\.cargo\\bin;C:\\Program Files (x86)\\NSIS;C:\\Tools\\ossutil' + AGC_WINDOWS_PATH = 'C:\\Tools\\Git\\cmd;C:\\Program Files\\nodejs;C:\\Users\\Administrator\\.cargo\\bin;C:\\Program Files (x86)\\NSIS;C:\\Tools\\ossutil;C:\\Windows\\system32;C:\\Windows;C:\\Windows\\System32\\Wbem;C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\;C:\\Windows\\System32\\OpenSSH\\' } parameters { @@ -28,48 +28,50 @@ pipeline { stages { stage('Checkout') { steps { - checkout([ - $class: 'GitSCM', - branches: [[name: "*/${params.SOURCE_BRANCH}"]], - doGenerateSubmoduleConfigurations: false, - extensions: [ - [$class: 'CleanBeforeCheckout'], - [$class: 'CloneOption', shallow: false, noTags: false, timeout: 30], - ], - userRemoteConfigs: [[ - url: env.GIT_REMOTE_URL, - credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, - refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}", - ]], - ]) - powershell ''' - $ErrorActionPreference = 'Stop' - $commit = $env:COMMIT_HASH.Trim() - if ($commit) { - if ($commit -notmatch '^[0-9a-fA-F]{7,64}$') { - throw 'COMMIT_HASH 必须是 7-64 位十六进制 Git commit。' + withEnv(["PATH=${env.AGC_WINDOWS_PATH}"]) { + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [ + [$class: 'CleanBeforeCheckout'], + [$class: 'CloneOption', shallow: false, noTags: false, timeout: 30], + ], + userRemoteConfigs: [[ + url: env.GIT_REMOTE_URL, + credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, + refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}", + ]], + ]) + powershell ''' + $ErrorActionPreference = 'Stop' + $commit = $env:COMMIT_HASH.Trim() + if ($commit) { + if ($commit -notmatch '^[0-9a-fA-F]{7,64}$') { + throw 'COMMIT_HASH 必须是 7-64 位十六进制 Git commit。' + } + git cat-file -e "${commit}^{commit}" + if ($LASTEXITCODE -ne 0) { + throw "COMMIT_HASH 不存在: $commit" + } + $branchRef = "refs/remotes/origin/$($env:SOURCE_BRANCH)" + git merge-base --is-ancestor $commit $branchRef + if ($LASTEXITCODE -ne 0) { + throw "COMMIT_HASH 不属于 SOURCE_BRANCH: $($env:SOURCE_BRANCH)" + } + git checkout --detach $commit } - git cat-file -e "${commit}^{commit}" - if ($LASTEXITCODE -ne 0) { - throw "COMMIT_HASH 不存在: $commit" - } - $branchRef = "refs/remotes/origin/$($env:SOURCE_BRANCH)" - git merge-base --is-ancestor $commit $branchRef - if ($LASTEXITCODE -ne 0) { - throw "COMMIT_HASH 不属于 SOURCE_BRANCH: $($env:SOURCE_BRANCH)" - } - git checkout --detach $commit - } - $resolved = (git rev-parse HEAD).Trim() - Set-Content -Path '.jenkins-source-commit' -Value $resolved -NoNewline - Write-Host "源码 commit=$resolved" - ''' + $resolved = (git rev-parse HEAD).Trim() + Set-Content -Path '.jenkins-source-commit' -Value $resolved -NoNewline + Write-Host "源码 commit=$resolved" + ''' + } } } stage('Toolchain preflight') { steps { - withEnv(["OSSUTIL_BIN=${params.OSSUTIL_BIN}"]) { + withEnv(["PATH=${env.AGC_WINDOWS_PATH}", "OSSUTIL_BIN=${params.OSSUTIL_BIN}"]) { powershell ''' $ErrorActionPreference = 'Stop' $actualNpm = (npm --version).Trim() @@ -98,16 +100,19 @@ pipeline { stage('Install dependencies') { steps { - powershell ''' - $ErrorActionPreference = 'Stop' - npm ci --no-audit --no-fund - ''' + withEnv(["PATH=${env.AGC_WINDOWS_PATH}"]) { + powershell ''' + $ErrorActionPreference = 'Stop' + npm ci --no-audit --no-fund + ''' + } } } stage('Build and upload') { steps { withEnv([ + "PATH=${env.AGC_WINDOWS_PATH}", "OSSUTIL_BIN=${params.OSSUTIL_BIN}", "AGC_UPDATE_RELEASE_NOTES=${params.AGC_UPDATE_RELEASE_NOTES}", ]) { -- 2.52.0 From 322cc841151bc26027b1907630a8ce0a3647d266 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 1 Sep 2026 11:47:05 +0800 Subject: [PATCH 11/20] =?UTF-8?q?=E6=94=B9=E7=94=A8=E6=98=BE=E5=BC=8FGit?= =?UTF-8?q?=E5=AE=8C=E6=88=90AGC=E6=A3=80=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在Windows节点通过sshagent调用已安装的Git可执行文件 保留分支与指定commit校验 --- .../Jenkinsfile.ai-game-creator-shell-build | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-build b/jenkins/Jenkinsfile.ai-game-creator-shell-build index 0619ec54e..bfbe19901 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-build @@ -28,40 +28,40 @@ pipeline { stages { stage('Checkout') { steps { - withEnv(["PATH=${env.AGC_WINDOWS_PATH}"]) { - checkout([ - $class: 'GitSCM', - branches: [[name: "*/${params.SOURCE_BRANCH}"]], - doGenerateSubmoduleConfigurations: false, - extensions: [ - [$class: 'CleanBeforeCheckout'], - [$class: 'CloneOption', shallow: false, noTags: false, timeout: 30], - ], - userRemoteConfigs: [[ - url: env.GIT_REMOTE_URL, - credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, - refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}", - ]], - ]) + sshagent(credentials: [env.GIT_REMOTE_CREDENTIAL_ID]) { powershell ''' $ErrorActionPreference = 'Stop' + $git = 'C:\\Tools\\Git\\cmd\\git.exe' + if (-not (Test-Path $git)) { throw "找不到 Git: $git" } + if (Test-Path '.git') { + & $git fetch --tags --force --prune origin "+refs/heads/$($env:SOURCE_BRANCH):refs/remotes/origin/$($env:SOURCE_BRANCH)" + if ($LASTEXITCODE -ne 0) { throw 'Git fetch 失败' } + & $git clean -fdx + & $git reset --hard "origin/$($env:SOURCE_BRANCH)" + } else { + & $git clone --no-single-branch $env:GIT_REMOTE_URL . + if ($LASTEXITCODE -ne 0) { throw 'Git clone 失败' } + & $git fetch --tags --force origin "refs/heads/$($env:SOURCE_BRANCH):refs/remotes/origin/$($env:SOURCE_BRANCH)" + if ($LASTEXITCODE -ne 0) { throw 'Git fetch 分支失败' } + & $git checkout --detach "origin/$($env:SOURCE_BRANCH)" + } $commit = $env:COMMIT_HASH.Trim() if ($commit) { if ($commit -notmatch '^[0-9a-fA-F]{7,64}$') { throw 'COMMIT_HASH 必须是 7-64 位十六进制 Git commit。' } - git cat-file -e "${commit}^{commit}" + & $git cat-file -e "${commit}^{commit}" if ($LASTEXITCODE -ne 0) { throw "COMMIT_HASH 不存在: $commit" } $branchRef = "refs/remotes/origin/$($env:SOURCE_BRANCH)" - git merge-base --is-ancestor $commit $branchRef + & $git merge-base --is-ancestor $commit $branchRef if ($LASTEXITCODE -ne 0) { throw "COMMIT_HASH 不属于 SOURCE_BRANCH: $($env:SOURCE_BRANCH)" } - git checkout --detach $commit + & $git checkout --detach $commit } - $resolved = (git rev-parse HEAD).Trim() + $resolved = (& $git rev-parse HEAD).Trim() Set-Content -Path '.jenkins-source-commit' -Value $resolved -NoNewline Write-Host "源码 commit=$resolved" ''' -- 2.52.0 From 5351b5f87c3b72582d4d16904636dfb78a080a79 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 1 Sep 2026 11:48:32 +0800 Subject: [PATCH 12/20] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E6=B5=81=E6=B0=B4?= =?UTF-8?q?=E7=BA=BFSSH=E5=87=AD=E6=8D=AE=E6=B3=A8=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改用Jenkins凭据绑定支持Windows源码检出 使用显式Git和临时SSH密钥连接Gitea --- jenkins/Jenkinsfile.ai-game-creator-shell-build | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-build b/jenkins/Jenkinsfile.ai-game-creator-shell-build index bfbe19901..e8cdb7096 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-build @@ -28,8 +28,9 @@ pipeline { stages { stage('Checkout') { steps { - sshagent(credentials: [env.GIT_REMOTE_CREDENTIAL_ID]) { - powershell ''' + withCredentials([sshUserPrivateKey(credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, keyFileVariable: 'GIT_SSH_KEY', usernameVariable: 'GIT_SSH_USER')]) { + withEnv(["GIT_SSH_COMMAND=ssh -i \"${env.GIT_SSH_KEY}\" -o StrictHostKeyChecking=no"]) { + powershell ''' $ErrorActionPreference = 'Stop' $git = 'C:\\Tools\\Git\\cmd\\git.exe' if (-not (Test-Path $git)) { throw "找不到 Git: $git" } @@ -64,7 +65,8 @@ pipeline { $resolved = (& $git rev-parse HEAD).Trim() Set-Content -Path '.jenkins-source-commit' -Value $resolved -NoNewline Write-Host "源码 commit=$resolved" - ''' + ''' + } } } } -- 2.52.0 From bccc9698558a6921cea2a6a797ae72d34c5f2292 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 1 Sep 2026 11:53:06 +0800 Subject: [PATCH 13/20] =?UTF-8?q?=E4=BF=AE=E5=A4=8DWindows=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E8=B0=83=E7=94=A8npm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在Windows平台使用npm.cmd启动Tauri构建 --- apps/ai-game-creator-shell/scripts/build-release.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index cf815d094..e9b0d8f0d 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -158,8 +158,9 @@ export function runTauriBuild(args = []) { const noBundle = args.includes('--no-bundle'); const hasTarget = args.includes('--target'); const targetArgs = noBundle || hasTarget ? [] : ['--target', releaseTarget]; + const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const result = spawnSync( - 'npm', + npmCommand, [ '--prefix', '../..', -- 2.52.0 From b913aa9ed318e4d58df45abcd3f0ce638141bbbd Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 1 Sep 2026 11:54:53 +0800 Subject: [PATCH 14/20] =?UTF-8?q?=E4=BF=AE=E5=A4=8DWindows=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E8=84=9A=E6=9C=AC=E5=90=AF=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用shell模式执行npm.cmd以兼容Windows子进程 --- apps/ai-game-creator-shell/scripts/build-release.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index e9b0d8f0d..82a4ee2ce 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -171,7 +171,7 @@ export function runTauriBuild(args = []) { ...targetArgs, ...args, ], - { cwd: appRoot, stdio: 'inherit' }, + { cwd: appRoot, stdio: 'inherit', shell: process.platform === 'win32' }, ); if (result.error) throw result.error; if (result.status !== 0) process.exit(result.status ?? 1); -- 2.52.0 From 25b4da1ab7ef498985c74be4556caf8e4861f11e Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 1 Sep 2026 12:09:12 +0800 Subject: [PATCH 15/20] =?UTF-8?q?=E6=B6=88=E9=99=A4AGC=E6=B5=81=E6=B0=B4?= =?UTF-8?q?=E7=BA=BF=E5=AF=86=E9=92=A5=E6=8F=92=E5=80=BC=E8=AD=A6=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在Windows PowerShell内设置临时Git SSH命令 --- jenkins/Jenkinsfile.ai-game-creator-shell-build | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-build b/jenkins/Jenkinsfile.ai-game-creator-shell-build index e8cdb7096..c2e74bcfd 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-build @@ -29,9 +29,9 @@ pipeline { stage('Checkout') { steps { withCredentials([sshUserPrivateKey(credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, keyFileVariable: 'GIT_SSH_KEY', usernameVariable: 'GIT_SSH_USER')]) { - withEnv(["GIT_SSH_COMMAND=ssh -i \"${env.GIT_SSH_KEY}\" -o StrictHostKeyChecking=no"]) { - powershell ''' + powershell ''' $ErrorActionPreference = 'Stop' + $env:GIT_SSH_COMMAND = "ssh -i `"$env:GIT_SSH_KEY`" -o StrictHostKeyChecking=no" $git = 'C:\\Tools\\Git\\cmd\\git.exe' if (-not (Test-Path $git)) { throw "找不到 Git: $git" } if (Test-Path '.git') { @@ -65,8 +65,7 @@ pipeline { $resolved = (& $git rev-parse HEAD).Trim() Set-Content -Path '.jenkins-source-commit' -Value $resolved -NoNewline Write-Host "源码 commit=$resolved" - ''' - } + ''' } } } -- 2.52.0 From e746559dc05365099a577fcdbfdab19649347bb9 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 1 Sep 2026 14:23:04 +0800 Subject: [PATCH 16/20] =?UTF-8?q?=E6=8E=A5=E5=85=A5Jenkins=20OSS=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E5=87=AD=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通过受保护凭据注入阿里云AccessKey 让Windows x64发布流水线完成安装包和latest.json上传 --- .../scripts/release-upload.mjs | 20 ++++++++++++++---- ...方案】AGC客户端更新检查与下载-2026-08-31.md | 10 +++++---- .../Jenkinsfile.ai-game-creator-shell-build | 21 ++++++++++++------- 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/release-upload.mjs b/apps/ai-game-creator-shell/scripts/release-upload.mjs index 43bd53520..e2085345a 100644 --- a/apps/ai-game-creator-shell/scripts/release-upload.mjs +++ b/apps/ai-game-creator-shell/scripts/release-upload.mjs @@ -14,10 +14,22 @@ const { generateUpdateManifest, prepareReleaseVersion, runTauriBuild } = function runOssutil(args) { const binary = process.env.OSSUTIL_BIN?.trim() || 'ossutil'; - const result = spawnSync(binary, [...args, '--endpoint', endpoint], { - stdio: 'inherit', - shell: false, - }); + const accessKeyId = process.env.AGC_OSS_ACCESS_KEY_ID?.trim(); + const accessKeySecret = process.env.AGC_OSS_ACCESS_KEY_SECRET; + if (Boolean(accessKeyId) !== Boolean(accessKeySecret)) { + throw new Error('OSS AccessKey ID 和 Secret 必须同时提供'); + } + const credentialArgs = accessKeyId + ? ['--access-key-id', accessKeyId, '--access-key-secret', accessKeySecret] + : []; + const result = spawnSync( + binary, + [...args, '--endpoint', endpoint, ...credentialArgs], + { + stdio: 'inherit', + shell: false, + }, + ); if (result.error) { throw new Error(`无法执行 ${binary},请先安装并配置 ossutil`); } diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index e3128e7fe..01ca8122e 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -39,12 +39,12 @@ OSS 请求失败、清单格式错误或版本无效会终止发布,避免覆 Windows x64),通过 `AGC_UPDATE_ARTIFACT` 指定要发布的安装包,通过 `AGC_UPDATE_OSS_BASE_URL` 指定 OSS 前缀,通过 `AGC_UPDATE_RELEASE_NOTES` 写入发布说明;`--no-bundle` smoke 构建不会读取 OSS、修改版本或生成清单。 -每次发布安装包上传完成后,再上传同一目录生成的 `latest.json`,确保 `downloadUrl` 指向已存在的 OSS 对象;清单和安装包均使用公开可读对象,不在清单中保存凭据、签名或本地路径。构建脚本不自动上传 OSS,避免把 AccessKey 写入本地配置或 CI 日志。 +每次发布安装包上传完成后,再上传同一目录生成的 `latest.json`,确保 `downloadUrl` 指向已存在的 OSS 对象;清单和安装包均使用公开可读对象,不在清单中保存凭据、签名或本地路径。构建脚本本身不负责上传 OSS,发布流水线通过 `release:upload` 完成上传。 如需一键构建并上传,可执行 `npm run ai-game-creator-shell:release:upload`。该命令要求本机已安装并配置 `ossutil`, 先按上述规则比较 OSS 版本、递增 patch、构建 Windows x64 NSIS,再上传安装包和 `latest.json`。默认上传到 `agc-dev` / `oss-rg-china-mainland.aliyuncs.com`,也可用 `AGC_OSS_BUCKET`、`AGC_OSS_ENDPOINT` 和 `OSSUTIL_BIN` -覆盖;凭据由 ossutil 本机配置读取,不能写入仓库或命令行参数。 +覆盖;本机执行时凭据由 ossutil 本机配置读取,不能写入仓库或命令行参数。 ## Jenkins Windows 构建节点 @@ -54,6 +54,8 @@ AGC 发布流水线使用 `jenkins/Jenkinsfile.ai-game-creator-shell-build`, Jenkins Agent 服务必须能在同一用户环境中找到这些命令。流水线执行根 workspace 的 `npm ci`,然后调用 `npm run ai-game-creator-shell:release:upload`,并归档 Windows 安装包、`latest.json` 与源码 commit。 -Jenkins 节点上的 ossutil 必须使用具备 `PutObject` 权限的受保护配置;OSS 对客户端保持公共读即可, -公共读本身不授予 Jenkins 上传权限。由于版本号取决于 OSS 当前清单,Job 已关闭并发构建;若 Jenkins +Jenkins Job 在“Build and upload”阶段通过受保护凭据 ID `AliyunAccessKeyId` 和 +`AliyunaccessKeySecret` 注入 AccessKey,仅在当前进程运行时传给 ossutil,不写入仓库、workspace 或构建日志; +本机运行仍使用 ossutil 配置。凭据必须具备 `PutObject` 权限;OSS 对客户端保持公共读即可,公共读本身不授予 +Jenkins 上传权限。由于版本号取决于 OSS 当前清单,Job 已关闭并发构建;若 Jenkins 上存在多个 AGC 发布 Job,还应使用同一个 Lockable Resource 串行化发布。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-build b/jenkins/Jenkinsfile.ai-game-creator-shell-build index c2e74bcfd..f8fd93c8a 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-build @@ -112,15 +112,20 @@ pipeline { stage('Build and upload') { steps { - withEnv([ - "PATH=${env.AGC_WINDOWS_PATH}", - "OSSUTIL_BIN=${params.OSSUTIL_BIN}", - "AGC_UPDATE_RELEASE_NOTES=${params.AGC_UPDATE_RELEASE_NOTES}", + withCredentials([ + string(credentialsId: 'AliyunAccessKeyId', variable: 'AGC_OSS_ACCESS_KEY_ID'), + string(credentialsId: 'AliyunaccessKeySecret', variable: 'AGC_OSS_ACCESS_KEY_SECRET'), ]) { - powershell ''' - $ErrorActionPreference = 'Stop' - npm run ai-game-creator-shell:release:upload - ''' + withEnv([ + "PATH=${env.AGC_WINDOWS_PATH}", + "OSSUTIL_BIN=${params.OSSUTIL_BIN}", + "AGC_UPDATE_RELEASE_NOTES=${params.AGC_UPDATE_RELEASE_NOTES}", + ]) { + powershell ''' + $ErrorActionPreference = 'Stop' + npm run ai-game-creator-shell:release:upload + ''' + } } } } -- 2.52.0 From de3dbd330e96b8ce29c3355bfbe7d348c87179c5 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 1 Sep 2026 16:25:40 +0800 Subject: [PATCH 17/20] =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E4=B8=8B=E8=BD=BD=E6=94=B9=E4=B8=BA=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=AE=89=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 下载时显示全屏进度遮罩 更新完成后从临时目录静默安装 关于页支持手动检查更新 构建流水线支持指定版本号 --- .../scripts/build-release.mjs | 9 +- .../src-tauri/Cargo.lock | 1 + .../src-tauri/Cargo.toml | 1 + .../src-tauri/src/main.rs | 115 +++++++++--- .../src/components/AppUpdateNotice.tsx | 174 ++++++++++++++++-- .../runtime-config/RuntimeConfigDialog.tsx | 33 ++++ .../src/services/appUpdate.ts | 30 ++- apps/ai-game-creator-shell/src/styles.css | 110 +++++++++++ ...方案】AGC客户端更新检查与下载-2026-08-31.md | 8 +- .../Jenkinsfile.ai-game-creator-shell-build | 2 + 10 files changed, 423 insertions(+), 60 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 82a4ee2ce..03a61ba9d 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -91,7 +91,10 @@ function replaceVersionLine(source, version, pattern, label) { export async function prepareReleaseVersion() { const localVersion = parseVersion(readPackageJson().version, '本地版本'); const remoteVersion = await readRemoteVersion(); - const nextVersion = nextPatchVersion(localVersion, remoteVersion); + const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim(); + const nextVersion = requestedVersion + ? parseVersion(requestedVersion, '指定版本') + : nextPatchVersion(localVersion, remoteVersion); const packageSource = fs.readFileSync(packageJsonPath, 'utf8'); fs.writeFileSync( @@ -149,7 +152,9 @@ export async function prepareReleaseVersion() { ); console.log( - `[ai-game-creator-shell] 版本 ${localVersion} / OSS ${remoteVersion ?? '不存在'} -> ${nextVersion}`, + requestedVersion + ? `[ai-game-creator-shell] 使用指定版本 ${nextVersion}(本地 ${localVersion} / OSS ${remoteVersion ?? '不存在'})` + : `[ai-game-creator-shell] 版本 ${localVersion} / OSS ${remoteVersion ?? '不存在'} -> ${nextVersion}`, ); return nextVersion; } diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index a6936c44b..3215ccc82 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1722,6 +1722,7 @@ dependencies = [ "oxc_parser", "oxc_semantic", "oxc_span", + "percent-encoding", "platform-agent", "platform-llm", "portable-pty", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 318951ed9..055a317d7 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -42,6 +42,7 @@ similar = "2.7" platform-llm = { path = "../../../server-rs/crates/platform-llm" } platform-agent = { path = "../../../server-rs/crates/platform-agent" } portable-pty = "0.9" +percent-encoding = "2" reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] } shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false } tauri = { version = "2.11.2", features = [] } 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 92bf0dfe4..8610ef687 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -6,11 +6,13 @@ use std::fs::{File, OpenOptions}; use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}; use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; +use std::process::Command; use std::sync::atomic::AtomicBool; use std::sync::{mpsc, Arc, Mutex, OnceLock}; use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use futures::StreamExt; use platform_agent::{ build_game_creation_seed_task_graph, plan_game_creation_agent_pass, route_game_creation_repair_issues, @@ -21,6 +23,7 @@ use platform_llm::{ }; use reqwest::header; use serde::{Deserialize, Serialize}; +use sha2::Digest; use shared_contracts::game_creation_app::{ new_game_creation_app_manifest, new_game_creation_app_seed_tasks, validate_game_iteration_versions, GameCreationAgentArtifactTrace, @@ -45,6 +48,14 @@ use tauri_plugin_opener::OpenerExt; const AGC_UPDATE_OSS_HOST: &str = "agc-dev.oss-rg-china-mainland.aliyuncs.com"; const AGC_UPDATE_MAX_DOWNLOAD_BYTES: u64 = 512 * 1024 * 1024; +const AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT: &str = "agc-update-download-progress"; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgcUpdateDownloadProgress { + downloaded_bytes: u64, + total_bytes: Option, +} #[tauri::command] async fn download_agc_update( @@ -57,15 +68,22 @@ async fn download_agc_update( if parsed.scheme() != "https" || parsed.host_str() != Some(AGC_UPDATE_OSS_HOST) { return Err("更新下载地址必须来自受信任的 OSS".to_string()); } - let filename = parsed + let encoded_filename = parsed .path_segments() .and_then(|segments| segments.last()) - .filter(|value| !value.is_empty() && value.len() <= 128) + .filter(|value| !value.is_empty()) .ok_or_else(|| "更新下载地址缺少文件名".to_string())? .to_string(); + let filename = percent_encoding::percent_decode_str(&encoded_filename) + .decode_utf8() + .map_err(|_| "更新文件名无效".to_string())? + .into_owned(); if filename.contains('/') || filename.contains('\\') || filename.contains("..") { return Err("更新文件名无效".to_string()); } + if filename.is_empty() || filename.len() > 128 { + return Err("更新文件名无效".to_string()); + } let response = reqwest::Client::new() .get(parsed) .send() @@ -80,15 +98,65 @@ async fn download_agc_update( { 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()); + let download_dir = app + .path() + .temp_dir() + .map_err(|_| "无法定位临时目录".to_string())? + .join("genarrative-agc-update"); + fs::create_dir_all(&download_dir).map_err(|_| "无法创建临时目录".to_string())?; + let target = download_dir.join(&filename); + let temporary = download_dir.join(format!( + "{}.{}.download", + filename, + uuid::Uuid::new_v4().simple() + )); + let mut file = File::create(&temporary).map_err(|_| "保存更新文件失败".to_string())?; + let mut hasher = sha2::Sha256::new(); + let total_bytes = response.content_length(); + let mut downloaded_bytes = 0_u64; + let _ = app.emit( + AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT, + AgcUpdateDownloadProgress { + downloaded_bytes, + total_bytes, + }, + ); + let mut stream = response.bytes_stream(); + while let Some(chunk_result) = stream.next().await { + let chunk = match chunk_result { + Ok(chunk) => chunk, + Err(_) => { + let _ = fs::remove_file(&temporary); + return Err("读取更新文件失败".to_string()); + } + }; + downloaded_bytes = match downloaded_bytes.checked_add(chunk.len() as u64) { + Some(value) if value <= AGC_UPDATE_MAX_DOWNLOAD_BYTES => value, + _ => { + let _ = fs::remove_file(&temporary); + return Err("更新文件超过大小限制".to_string()); + } + }; + hasher.update(&chunk); + if file.write_all(&chunk).is_err() { + let _ = fs::remove_file(&temporary); + return Err("保存更新文件失败".to_string()); + } + let _ = app.emit( + AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT, + AgcUpdateDownloadProgress { + downloaded_bytes, + total_bytes, + }, + ); + } + if file.flush().is_err() { + let _ = fs::remove_file(&temporary); + return Err("保存更新文件失败".to_string()); } if let Some(expected_size) = expected_size { - if bytes.len() as u64 != expected_size { + if downloaded_bytes != expected_size { + let _ = fs::remove_file(&temporary); return Err("更新文件大小校验失败".to_string()); } } @@ -97,38 +165,27 @@ async fn download_agc_update( if !expected_sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) || expected_sha256.len() != 64 { + let _ = fs::remove_file(&temporary); return Err("更新文件摘要无效".to_string()); } - use sha2::{Digest, Sha256}; - let actual = format!("{:x}", Sha256::digest(&bytes)); + let actual = format!("{:x}", hasher.finalize()); if actual != expected_sha256 { + let _ = fs::remove_file(&temporary); 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 _ = fs::remove_file(&target); } - 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}")); } + Command::new(&target) + .arg("/S") + .spawn() + .map_err(|_| "无法启动更新安装程序".to_string())?; + app.exit(0); Ok(target.to_string_lossy().into_owned()) } diff --git a/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx index 63132ab48..7cc1c8444 100644 --- a/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx +++ b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx @@ -1,54 +1,190 @@ +import { listen } from '@tauri-apps/api/event'; +import { Download, LoaderCircle } from 'lucide-react'; import { useEffect, useState } from 'react'; import { APP_VERSION } from '../app/appMetadata'; import { + AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT, type AppUpdateInfo, checkForAppUpdate, downloadAppUpdate, + subscribeToAppUpdate, } from '../services/appUpdate'; +type DownloadProgress = { + downloadedBytes: number; + totalBytes?: number; +}; + +type DownloadState = 'idle' | 'downloading' | 'completed' | 'error'; + +function formatBytes(bytes: number) { + if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + export function AppUpdateNotice() { const [update, setUpdate] = useState(null); - const [downloading, setDownloading] = useState(false); + const [downloadState, setDownloadState] = useState('idle'); + const [downloadProgress, setDownloadProgress] = useState({ + downloadedBytes: 0, + }); + const [downloadError, setDownloadError] = useState(''); useEffect(() => { let mounted = true; + const unsubscribe = subscribeToAppUpdate((result) => { + if (mounted) setUpdate(result); + }); void checkForAppUpdate().then((result) => { if (mounted) setUpdate(result); }); return () => { mounted = false; + unsubscribe(); }; }, []); + useEffect(() => { + if (typeof window === 'undefined' || !window.__TAURI__ || !update) return; + let disposed = false; + let unlisten: (() => void) | undefined; + void listen( + AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT, + (event) => { + if (!disposed) setDownloadProgress(event.payload); + }, + ).then((cleanup) => { + if (disposed) cleanup(); + else unlisten = cleanup; + }); + return () => { + disposed = true; + unlisten?.(); + }; + }, [update]); + if (!update) return null; const currentUpdate = update; + const isDownloading = downloadState === 'downloading'; + const totalBytes = downloadProgress.totalBytes ?? currentUpdate.size; + const progress = totalBytes + ? Math.min( + 100, + Math.round((downloadProgress.downloadedBytes / totalBytes) * 100), + ) + : null; + async function handleDownload() { - setDownloading(true); + if (isDownloading) return; + setDownloadError(''); + setDownloadProgress({ downloadedBytes: 0, totalBytes }); + setDownloadState('downloading'); try { await downloadAppUpdate(currentUpdate.downloadUrl, currentUpdate); - } finally { - setDownloading(false); + setDownloadState('completed'); + } catch (error) { + setDownloadError(error instanceof Error ? error.message : String(error)); + setDownloadState('error'); } } + function dismiss() { + if (isDownloading) return; + setUpdate(null); + setDownloadState('idle'); + } + return ( - +
+ 发现新版本 {currentUpdate.version} + 当前版本 {APP_VERSION} + {currentUpdate.releaseNotes ? ( +

{currentUpdate.releaseNotes}

+ ) : null} +
+ + + {downloadState !== 'idle' ? ( +
+
+ +

+ {isDownloading + ? `正在下载 ${currentUpdate.version}` + : downloadState === 'completed' + ? '下载完成' + : '下载失败'} +

+ {isDownloading ? ( + <> +
+ +
+

+ {progress === null + ? '正在获取下载进度…' + : `${progress}% · ${formatBytes(downloadProgress.downloadedBytes)} / ${formatBytes(totalBytes ?? downloadProgress.downloadedBytes)}`} +

+ 下载期间请勿关闭客户端或进行其他操作 + + ) : downloadState === 'completed' ? ( + <> +

安装程序已启动,客户端将自动完成更新。

+ + + ) : ( + <> +

{downloadError || '下载更新失败,请稍后重试。'}

+
+ + +
+ + )} +
+
+ ) : null} + ); } diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index 386c6d2cf..4d1a1b421 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -33,6 +33,7 @@ import { type RuntimeAgentLlmProviderPresetId, type RuntimeLlmProviderPresetId, } from '../../app/types'; +import { checkForAppUpdate } from '../../services/appUpdate'; const runtimeAgentReasoningEffortDefaults = { 'project-supervisor': 'high', @@ -383,6 +384,8 @@ export function RuntimeConfigDialog({ const [activeSection, setActiveSection] = useState('general'); const [expandedAgentIds, setExpandedAgentIds] = useState([]); + const [appUpdateStatus, setAppUpdateStatus] = useState(''); + const [appUpdateChecking, setAppUpdateChecking] = useState(false); const runtimeConfigBusyRef = useRef(false); useEscapeToClose(onClose); @@ -588,6 +591,24 @@ export function RuntimeConfigDialog({ setRuntimeConfigStatus('已恢复默认配置,保存后生效'); } + async function checkAppUpdateManually() { + if (appUpdateChecking) return; + setAppUpdateChecking(true); + setAppUpdateStatus('正在检查更新…'); + try { + const update = await checkForAppUpdate({ force: true }); + setAppUpdateStatus( + update + ? `发现新版本 v${update.version},可在右上角下载` + : '当前已是最新版本', + ); + } catch { + setAppUpdateStatus('检查更新失败,请稍后重试'); + } finally { + setAppUpdateChecking(false); + } + } + const selectedSection = runtimeSettingsSections.find((section) => section.id === activeSection) ?? runtimeSettingsSections[0]; @@ -1308,6 +1329,18 @@ export function RuntimeConfigDialog({
桌面客户端
+
+ + + {appUpdateStatus} + +
) : null}
diff --git a/apps/ai-game-creator-shell/src/services/appUpdate.ts b/apps/ai-game-creator-shell/src/services/appUpdate.ts index fe3ac0bbd..f9021f3d0 100644 --- a/apps/ai-game-creator-shell/src/services/appUpdate.ts +++ b/apps/ai-game-creator-shell/src/services/appUpdate.ts @@ -8,6 +8,8 @@ import { resolveTauriInvoke } from '../app/tauri'; export const AGC_UPDATE_MANIFEST_URL = import.meta.env.VITE_AGC_UPDATE_MANIFEST_URL?.trim() || 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/latest.json'; +export const AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT = + 'agc-update-download-progress'; export type AppUpdateManifest = { version: string; @@ -22,6 +24,7 @@ export type AppUpdateInfo = AppUpdateManifest & { }; let updateCheckPromise: Promise | null = null; +const updateListeners = new Set<(update: AppUpdateInfo | null) => void>(); function parseVersion(value: string) { const match = value @@ -100,19 +103,32 @@ async function fetchUpdateManifest() { } /** 同一客户端生命周期内只请求一次,避免 StrictMode 或多窗口重复检测。 */ -export function checkForAppUpdate(): Promise { +export function checkForAppUpdate( + options: { force?: boolean } = {}, +): Promise { + if (options.force) updateCheckPromise = null; if (!updateCheckPromise) { updateCheckPromise = fetchUpdateManifest() - .then((manifest) => - manifest && isNewerVersion(manifest.version, APP_VERSION) - ? { ...manifest, currentVersion: APP_VERSION } - : null, - ) + .then((manifest) => { + const update = + manifest && isNewerVersion(manifest.version, APP_VERSION) + ? { ...manifest, currentVersion: APP_VERSION } + : null; + updateListeners.forEach((listener) => listener(update)); + return update; + }) .catch(() => null); } return updateCheckPromise; } +export function subscribeToAppUpdate( + listener: (update: AppUpdateInfo | null) => void, +) { + updateListeners.add(listener); + return () => updateListeners.delete(listener); +} + export async function downloadAppUpdate( downloadUrl: string, integrity: Pick = {}, @@ -122,7 +138,7 @@ export async function downloadAppUpdate( if (typeof window !== 'undefined' && window.__TAURI__) { const invoke = resolveTauriInvoke(); if (invoke) { - await invoke('download_agc_update', { + return await invoke('download_agc_update', { downloadUrl: url.toString(), expectedSha256: integrity.sha256, expectedSize: integrity.size, diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index a4f9c7b2c..d36ca4236 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -75,6 +75,90 @@ body { opacity: 0.65; } +.app-update-overlay { + position: fixed; + z-index: 260; + inset: 0; + display: grid; + padding: 24px; + background: rgb(35 20 12 / 48%); + place-items: center; + pointer-events: auto; +} + +.app-update-progress-dialog { + display: grid; + width: min(420px, calc(100vw - 48px)); + gap: 12px; + padding: 28px; + border: 1px solid #efc9ae; + border-radius: 18px; + background: #fffaf5; + box-shadow: 0 20px 60px rgb(38 18 8 / 28%); + color: #4a220f; + text-align: center; +} + +.app-update-progress-icon { + display: grid; + width: 52px; + height: 52px; + margin: 0 auto; + border-radius: 16px; + background: #f6dfd0; + color: #c7653d; + place-items: center; +} + +.app-update-progress-dialog h2, +.app-update-progress-dialog p, +.app-update-progress-dialog small { + margin: 0; +} + +.app-update-progress-dialog h2 { + font-size: 18px; +} + +.app-update-progress-dialog p, +.app-update-progress-dialog small { + color: #8d6a58; + font-size: 12px; +} + +.app-update-progress-track { + height: 8px; + overflow: hidden; + border-radius: 99px; + background: #f1ded2; +} + +.app-update-progress-track span { + display: block; + height: 100%; + border-radius: inherit; + background: #c7653d; + transition: width 180ms ease; +} + +.app-update-progress-dialog button { + justify-self: center; + min-width: 96px; + padding: 8px 16px; + border: 0; + border-radius: 8px; + background: #c7653d; + color: #fff; + cursor: pointer; + font-weight: 700; +} + +.app-update-progress-actions { + display: flex; + justify-content: center; + gap: 10px; +} + :root { /* 网页内自绘标题栏占用的顶部高度;portal 到 body 的固定弹层也要从它下方开始。 */ --window-chrome-height: 50px; @@ -3730,6 +3814,32 @@ h2 { font-weight: 800; } +.runtime-settings-about-update { + display: flex; + align-items: center; + gap: 12px; +} + +.runtime-settings-about-update button { + padding: 8px 14px; + border: 0; + border-radius: 8px; + background: var(--platform-accent); + color: #fff; + cursor: pointer; + font-weight: 700; +} + +.runtime-settings-about-update button:disabled { + cursor: wait; + opacity: 0.65; +} + +.runtime-settings-about-update span { + color: var(--platform-text-soft); + font-size: 12px; +} + .runtime-agent-list { grid-column: 1 / -1; display: grid; diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 01ca8122e..7c3e3cebd 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -19,7 +19,7 @@ AGC 每次启动时由根窗口检查一次公开 OSS 更新清单。清单默 } ``` -`downloadUrl` 必须是 HTTPS;如提供 `sha256` / `size`,Tauri 下载时会校验摘要和字节数。点击“下载更新”后,客户端把安装包保存到系统下载目录,安装包安装/替换由操作系统安装器完成。 +`downloadUrl` 必须是 HTTPS;如提供 `sha256` / `size`,Tauri 下载时会校验摘要和字节数。点击“下载更新”后,客户端将安装包流式写入系统临时目录并显示进度,校验成功后自动启动 NSIS 静默安装并退出旧客户端。 ## 启动与失败策略 @@ -37,7 +37,8 @@ OSS 请求失败、清单格式错误或版本无效会终止发布,避免覆 安装包,并在 `apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/latest.json` 生成包含版本、下载地址、大小和 SHA-256 的清单。可通过 `AGC_BUILD_TARGET` 显式覆盖目标(发布仍应使用 Windows x64),通过 `AGC_UPDATE_ARTIFACT` 指定要发布的安装包,通过 `AGC_UPDATE_OSS_BASE_URL` 指定 -OSS 前缀,通过 `AGC_UPDATE_RELEASE_NOTES` 写入发布说明;`--no-bundle` smoke 构建不会读取 OSS、修改版本或生成清单。 +OSS 前缀,通过 `AGC_RELEASE_VERSION` 指定三段版本号(仅在明确需要复现指定版本时使用),通过 +`AGC_UPDATE_RELEASE_NOTES` 写入发布说明;`--no-bundle` smoke 构建不会读取 OSS、修改版本或生成清单。 每次发布安装包上传完成后,再上传同一目录生成的 `latest.json`,确保 `downloadUrl` 指向已存在的 OSS 对象;清单和安装包均使用公开可读对象,不在清单中保存凭据、签名或本地路径。构建脚本本身不负责上传 OSS,发布流水线通过 `release:upload` 完成上传。 @@ -58,4 +59,5 @@ Jenkins Job 在“Build and upload”阶段通过受保护凭据 ID `AliyunAcces `AliyunaccessKeySecret` 注入 AccessKey,仅在当前进程运行时传给 ossutil,不写入仓库、workspace 或构建日志; 本机运行仍使用 ossutil 配置。凭据必须具备 `PutObject` 权限;OSS 对客户端保持公共读即可,公共读本身不授予 Jenkins 上传权限。由于版本号取决于 OSS 当前清单,Job 已关闭并发构建;若 Jenkins -上存在多个 AGC 发布 Job,还应使用同一个 Lockable Resource 串行化发布。 +上存在多个 AGC 发布 Job,还应使用同一个 Lockable Resource 串行化发布。Job 参数 +`AGC_RELEASE_VERSION` 留空时自动递增,填写后会使用指定版本并更新对应的 `latest.json`,因此回滚或测试旧版本前应确认不会覆盖线上更新入口。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-build b/jenkins/Jenkinsfile.ai-game-creator-shell-build index f8fd93c8a..e541f2abe 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-build @@ -21,6 +21,7 @@ pipeline { parameters { string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '源码分支') string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit') + string(name: 'AGC_RELEASE_VERSION', defaultValue: '', description: '可选,指定三段版本号;留空则按 OSS 与本地版本自动递增 patch') string(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选,写入 latest.json 的发布说明') string(name: 'OSSUTIL_BIN', defaultValue: 'ossutil', description: 'ossutil 或 ossutil.exe 的绝对路径/命令名') } @@ -119,6 +120,7 @@ pipeline { withEnv([ "PATH=${env.AGC_WINDOWS_PATH}", "OSSUTIL_BIN=${params.OSSUTIL_BIN}", + "AGC_RELEASE_VERSION=${params.AGC_RELEASE_VERSION}", "AGC_UPDATE_RELEASE_NOTES=${params.AGC_UPDATE_RELEASE_NOTES}", ]) { powershell ''' -- 2.52.0 From 72d972c37a95d82f32fdb19fc1537c75b0a163e0 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 1 Sep 2026 17:01:39 +0800 Subject: [PATCH 18/20] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E5=8A=A0=E8=BD=BD=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为下载遮罩加载图标添加持续旋转动画 --- apps/ai-game-creator-shell/src/styles.css | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index d36ca4236..f618f5e2b 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -110,6 +110,16 @@ body { place-items: center; } +.app-update-progress-icon .is-spinning { + animation: app-update-spin 0.9s linear infinite; +} + +@keyframes app-update-spin { + to { + transform: rotate(360deg); + } +} + .app-update-progress-dialog h2, .app-update-progress-dialog p, .app-update-progress-dialog small { -- 2.52.0 From 9eae0a0a878e7c27690073f586d2da45c69280fa Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 1 Sep 2026 17:04:54 +0800 Subject: [PATCH 19/20] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E6=8F=90=E6=9D=83=E5=90=AF=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通过 Windows UAC 启动 NSIS 静默安装 释放临时安装包句柄后再提交并启动 同步更新客户端升级技术方案 --- .../src-tauri/src/main.rs | 45 +++++++++++++++++-- ...方案】AGC客户端更新检查与下载-2026-08-31.md | 2 +- 2 files changed, 42 insertions(+), 5 deletions(-) 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 8610ef687..8cccbcefd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -57,6 +57,45 @@ struct AgcUpdateDownloadProgress { total_bytes: Option, } +#[cfg(windows)] +fn launch_agc_installer(path: &Path) -> Result<(), String> { + use std::os::windows::process::CommandExt; + + let executable = path.to_string_lossy().replace('\'', "''"); + let script = format!( + "$ErrorActionPreference = 'Stop'; Start-Process -Verb RunAs -FilePath '{executable}' -ArgumentList @('/S')" + ); + let status = Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-Command", + script.as_str(), + ]) + .creation_flags(0x0800_0000) + .status() + .map_err(|error| format!("无法启动更新安装程序:{error}"))?; + if status.success() { + Ok(()) + } else { + Err(format!( + "无法启动更新安装程序(exit code {:?})", + status.code() + )) + } +} + +#[cfg(not(windows))] +fn launch_agc_installer(path: &Path) -> Result<(), String> { + Command::new(path) + .arg("/S") + .spawn() + .map(|_| ()) + .map_err(|error| format!("无法启动更新安装程序:{error}")) +} + #[tauri::command] async fn download_agc_update( app: tauri::AppHandle, @@ -154,6 +193,7 @@ async fn download_agc_update( let _ = fs::remove_file(&temporary); return Err("保存更新文件失败".to_string()); } + drop(file); if let Some(expected_size) = expected_size { if downloaded_bytes != expected_size { let _ = fs::remove_file(&temporary); @@ -181,10 +221,7 @@ async fn download_agc_update( let _ = fs::remove_file(&temporary); return Err(format!("提交更新文件失败:{error}")); } - Command::new(&target) - .arg("/S") - .spawn() - .map_err(|_| "无法启动更新安装程序".to_string())?; + launch_agc_installer(&target)?; app.exit(0); Ok(target.to_string_lossy().into_owned()) } diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 7c3e3cebd..0221e9304 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -19,7 +19,7 @@ AGC 每次启动时由根窗口检查一次公开 OSS 更新清单。清单默 } ``` -`downloadUrl` 必须是 HTTPS;如提供 `sha256` / `size`,Tauri 下载时会校验摘要和字节数。点击“下载更新”后,客户端将安装包流式写入系统临时目录并显示进度,校验成功后自动启动 NSIS 静默安装并退出旧客户端。 +`downloadUrl` 必须是 HTTPS;如提供 `sha256` / `size`,Tauri 下载时会校验摘要和字节数。点击“下载更新”后,客户端将安装包流式写入系统临时目录并显示进度,校验成功后通过 Windows UAC 提权启动 NSIS 静默安装并退出旧客户端。 ## 启动与失败策略 -- 2.52.0 From 51f19f3b72fedcd5eaa62bf441b8ba78fb6c267f Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 1 Sep 2026 18:11:53 +0800 Subject: [PATCH 20/20] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E6=9B=B4=E6=96=B0=E6=B5=81=E7=A8=8B=E4=B8=8E=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充安装完成后的客户端自动重启 修正关于页检查更新按钮被裁切 同步更新提权安装流程文档 --- .../src-tauri/src/main.rs | 26 ++++++++----------- apps/ai-game-creator-shell/src/styles.css | 9 ++++--- 2 files changed, 16 insertions(+), 19 deletions(-) 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 8cccbcefd..79de25e22 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -58,14 +58,15 @@ struct AgcUpdateDownloadProgress { } #[cfg(windows)] -fn launch_agc_installer(path: &Path) -> Result<(), String> { +fn launch_agc_installer(path: &Path, relaunch_path: &Path) -> Result<(), String> { use std::os::windows::process::CommandExt; let executable = path.to_string_lossy().replace('\'', "''"); + let relaunch_executable = relaunch_path.to_string_lossy().replace('\'', "''"); let script = format!( - "$ErrorActionPreference = 'Stop'; Start-Process -Verb RunAs -FilePath '{executable}' -ArgumentList @('/S')" + "$ErrorActionPreference = 'Stop'; $installer = Start-Process -Verb RunAs -Wait -PassThru -FilePath '{executable}' -ArgumentList @('/S'); if ($installer.ExitCode -eq 0 -and (Test-Path -LiteralPath '{relaunch_executable}')) {{ Start-Process -FilePath '{relaunch_executable}' }}; exit $installer.ExitCode" ); - let status = Command::new("powershell.exe") + Command::new("powershell.exe") .args([ "-NoProfile", "-NonInteractive", @@ -75,20 +76,13 @@ fn launch_agc_installer(path: &Path) -> Result<(), String> { script.as_str(), ]) .creation_flags(0x0800_0000) - .status() - .map_err(|error| format!("无法启动更新安装程序:{error}"))?; - if status.success() { - Ok(()) - } else { - Err(format!( - "无法启动更新安装程序(exit code {:?})", - status.code() - )) - } + .spawn() + .map(|_| ()) + .map_err(|error| format!("无法启动更新安装程序:{error}")) } #[cfg(not(windows))] -fn launch_agc_installer(path: &Path) -> Result<(), String> { +fn launch_agc_installer(path: &Path, _relaunch_path: &Path) -> Result<(), String> { Command::new(path) .arg("/S") .spawn() @@ -221,7 +215,9 @@ async fn download_agc_update( let _ = fs::remove_file(&temporary); return Err(format!("提交更新文件失败:{error}")); } - launch_agc_installer(&target)?; + let relaunch_path = std::env::current_exe() + .map_err(|error| format!("无法定位客户端程序:{error}"))?; + launch_agc_installer(&target, &relaunch_path)?; app.exit(0); Ok(target.to_string_lossy().into_owned()) } diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index f618f5e2b..6ca5b5623 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -3508,8 +3508,8 @@ h2 { grid-template-rows: auto minmax(0, 1fr) auto; gap: 0; width: min(1080px, 100%); - height: min(760px, calc(100dvh - 48px)); - max-height: calc(100dvh - 48px); + height: min(760px, calc(100dvh - var(--window-chrome-height) - 48px)); + max-height: calc(100dvh - var(--window-chrome-height) - 48px); padding: 0; overflow: hidden; border-color: var(--platform-modal-border); @@ -4619,6 +4619,7 @@ iframe.preview-frame { @media (max-width: 760px) { .settings-overlay { padding: 0; + overflow: auto; } .runtime-settings-toast { @@ -4630,8 +4631,8 @@ iframe.preview-frame { .runtime-settings-panel { width: 100%; - height: 100dvh; - max-height: 100dvh; + height: calc(100dvh - var(--window-chrome-height)); + max-height: calc(100dvh - var(--window-chrome-height)); border: 0; border-radius: 0; } -- 2.52.0