Files
Genarrative/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx
T
suzmii 6618207b5b 修复运行模块切换版本报 listeners[eventId].handlerId:事件订阅改为自建登记 + 幂等注销
- 新增 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:记录该竞态的成因、触发面、处理取舍与变异验证结论。
2026-09-12 17:23:22 +08:00

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}
</>
);
}