6618207b5b
- 新增 apps/ai-game-creator-shell/src/services/tauriEventSubscription.ts:AGC 唯一的事件订阅入口。真实 WebView 内用 plugin:event|listen + transformCallback 自建登记,注销时先 unregisterCallback(handlerId)(callbacks.delete,幂等、缺条目也不抛)再发 plugin:event|unlisten,并对重复注销去重,注销失败只在 console.warn 显式记录;非原生环境沿用注入的 event.listen(两参调用形状不变),未接桥接时返回空操作。tauri 2.11 的注销脚本会先读注册表条目再摘回调,而条目由注册 eval 异步写入,与 IPC 返回无序,这一层自建登记正好绕开该竞态(上游 tauri-apps/tauri#15799 / #15800,2.12 起脚本自带判空,升级后可删掉 internals 分支)。 - App.tsx:5 处事件订阅(game-creator-direct-turn-update / agent-progress / agent-runtime-update / planning-session-v2-stream / manifest-invalidated)与角色 Agent 流式回复监听改用订阅入口,守卫由 window.__TAURI__?.event?.listen 换成 canSubscribeTauriEvents()。 - features/app-shell/useDeveloperAgentPanel.ts:Agent Runtime 与角色 Agent 流式回复两处订阅改用订阅入口。 - services/errorReportingBridge.ts:error-report-updated 订阅改用订阅入口,不再直接依赖库内 listen。 - components/AppUpdateNotice.tsx:更新下载进度订阅改用订阅入口。 - components/WindowChrome.tsx:窗口尺寸监听不再走 nativeWindow.onResized,改为订阅 tauri://resize(限定当前窗口),避开库内注销竞态。 - 新增 tests/tauriEventFake.ts:与 tauri 2.11.3 等价的 Tauri 事件替身(注册表条目由注册 eval 异步写入、库内注销脚本读缺失条目即抛并留痕、全局桥 event.listen 按库内实现返回会读条目的注销函数)。 - 新增 tests/tauriEventSubscription.test.ts(7 例):注册 eval 未落地就注销、重复注销只摘一次、注册落地后能投递且注销后不再投递、真实 WebView 不走库内注销脚本、无 internals 时回落注入桥接、无桥接时空操作。 - 新增 tests/runVersionSwitchEventSubscription.test.tsx(2 例):真实 launcher + 运行模块连续切换两次版本(断言 start_local_game_preview 被调用)、卸载后后端订阅与 JS 回调都不泄漏、切换版本后清单失效事件仍能送达并重读清单。 - docs/project-memory/shared-memory/pitfalls.md:记录该竞态的成因、触发面、处理取舍与变异验证结论。
204 lines
6.1 KiB
TypeScript
204 lines
6.1 KiB
TypeScript
import { Download, LoaderCircle, X } 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';
|
|
import {
|
|
canSubscribeTauriEvents,
|
|
subscribeTauriEvent,
|
|
} from '../services/tauriEventSubscription';
|
|
|
|
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 [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 (!canSubscribeTauriEvents() || !update) return;
|
|
let disposed = false;
|
|
let unlisten: (() => void) | undefined;
|
|
void subscribeTauriEvent<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() {
|
|
if (isDownloading) return;
|
|
setDownloadError('');
|
|
setDownloadProgress({ downloadedBytes: 0, totalBytes });
|
|
setDownloadState('downloading');
|
|
try {
|
|
await downloadAppUpdate(currentUpdate.downloadUrl, currentUpdate);
|
|
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={isDownloading}
|
|
>
|
|
{isDownloading ? '正在下载…' : '下载更新'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="app-update-close"
|
|
onClick={dismiss}
|
|
disabled={isDownloading}
|
|
aria-label="关闭更新提示"
|
|
title="关闭"
|
|
>
|
|
<X size={18} aria-hidden="true" />
|
|
</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}
|
|
</>
|
|
);
|
|
}
|