客户端更新下载改为自动安装
下载时显示全屏进度遮罩 更新完成后从临时目录静默安装 关于页支持手动检查更新 构建流水线支持指定版本号
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
+1
@@ -1722,6 +1722,7 @@ dependencies = [
|
||||
"oxc_parser",
|
||||
"oxc_semantic",
|
||||
"oxc_span",
|
||||
"percent-encoding",
|
||||
"platform-agent",
|
||||
"platform-llm",
|
||||
"portable-pty",
|
||||
|
||||
@@ -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 = [] }
|
||||
|
||||
@@ -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<u64>,
|
||||
}
|
||||
|
||||
#[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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AppUpdateInfo | null>(null);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [downloadState, setDownloadState] = useState<DownloadState>('idle');
|
||||
const [downloadProgress, setDownloadProgress] = useState<DownloadProgress>({
|
||||
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<DownloadProgress>(
|
||||
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 (
|
||||
<aside className="app-update-notice" role="status" aria-label="发现新版本">
|
||||
<div>
|
||||
<strong>发现新版本 {currentUpdate.version}</strong>
|
||||
<span>当前版本 {APP_VERSION}</span>
|
||||
{currentUpdate.releaseNotes ? (
|
||||
<p>{currentUpdate.releaseNotes}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDownload()}
|
||||
disabled={downloading}
|
||||
<>
|
||||
<aside
|
||||
className="app-update-notice"
|
||||
role="status"
|
||||
aria-label="发现新版本"
|
||||
>
|
||||
{downloading ? '正在打开下载…' : '下载更新'}
|
||||
</button>
|
||||
</aside>
|
||||
<div>
|
||||
<strong>发现新版本 {currentUpdate.version}</strong>
|
||||
<span>当前版本 {APP_VERSION}</span>
|
||||
{currentUpdate.releaseNotes ? (
|
||||
<p>{currentUpdate.releaseNotes}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDownload()}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading ? '正在下载…' : '下载更新'}
|
||||
</button>
|
||||
</aside>
|
||||
{downloadState !== 'idle' ? (
|
||||
<div
|
||||
className="app-update-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="下载更新"
|
||||
>
|
||||
<div className="app-update-progress-dialog">
|
||||
<div className="app-update-progress-icon" aria-hidden="true">
|
||||
{isDownloading ? (
|
||||
<LoaderCircle className="is-spinning" size={24} />
|
||||
) : (
|
||||
<Download size={24} />
|
||||
)}
|
||||
</div>
|
||||
<h2>
|
||||
{isDownloading
|
||||
? `正在下载 ${currentUpdate.version}`
|
||||
: downloadState === 'completed'
|
||||
? '下载完成'
|
||||
: '下载失败'}
|
||||
</h2>
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<div
|
||||
className="app-update-progress-track"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={progress ?? undefined}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: progress === null ? '35%' : `${progress}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p>
|
||||
{progress === null
|
||||
? '正在获取下载进度…'
|
||||
: `${progress}% · ${formatBytes(downloadProgress.downloadedBytes)} / ${formatBytes(totalBytes ?? downloadProgress.downloadedBytes)}`}
|
||||
</p>
|
||||
<small>下载期间请勿关闭客户端或进行其他操作</small>
|
||||
</>
|
||||
) : downloadState === 'completed' ? (
|
||||
<>
|
||||
<p>安装程序已启动,客户端将自动完成更新。</p>
|
||||
<button type="button" onClick={dismiss}>
|
||||
知道了
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>{downloadError || '下载更新失败,请稍后重试。'}</p>
|
||||
<div className="app-update-progress-actions">
|
||||
<button type="button" onClick={() => void handleDownload()}>
|
||||
重试
|
||||
</button>
|
||||
<button type="button" onClick={dismiss}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<RuntimeSettingsSection>('general');
|
||||
const [expandedAgentIds, setExpandedAgentIds] = useState<string[]>([]);
|
||||
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({
|
||||
<dd>桌面客户端</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="runtime-settings-about-update">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void checkAppUpdateManually()}
|
||||
disabled={appUpdateChecking}
|
||||
>
|
||||
{appUpdateChecking ? '正在检查…' : '检查更新'}
|
||||
</button>
|
||||
<span role="status" aria-live="polite">
|
||||
{appUpdateStatus}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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<AppUpdateInfo | null> | 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<AppUpdateInfo | null> {
|
||||
export function checkForAppUpdate(
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<AppUpdateInfo | null> {
|
||||
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<AppUpdateManifest, 'sha256' | 'size'> = {},
|
||||
@@ -122,7 +138,7 @@ export async function downloadAppUpdate(
|
||||
if (typeof window !== 'undefined' && window.__TAURI__) {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (invoke) {
|
||||
await invoke<string>('download_agc_update', {
|
||||
return await invoke<string>('download_agc_update', {
|
||||
downloadUrl: url.toString(),
|
||||
expectedSha256: integrity.sha256,
|
||||
expectedSize: integrity.size,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`,因此回滚或测试旧版本前应确认不会覆盖线上更新入口。
|
||||
|
||||
@@ -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 '''
|
||||
|
||||
Reference in New Issue
Block a user