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:记录该竞态的成因、触发面、处理取舍与变异验证结论。
209 lines
7.8 KiB
TypeScript
209 lines
7.8 KiB
TypeScript
/**
|
||
* AGC 的 Tauri 事件订阅入口:登记与注销收敛到这一处,避免 tauri 2.11 的注销竞态。
|
||
*
|
||
* 背景(`undefined is not an object (evaluating 'listeners[eventId].handlerId')`):
|
||
* tauri 2.11.x 的 JS 侧注销脚本(`crates/tauri/src/event/mod.rs` 的 `unlisten_js_script`)
|
||
* 会先读 webview 内的监听注册表条目
|
||
* (`window.__internal_unstable_listeners_object_id__[event][eventId].handlerId`),再调
|
||
* `unregisterCallback`。这条注册表条目由 `plugin:event|listen` 之后**另一次** eval
|
||
* (`listen_js_script`)写入,和 `plugin:event|listen` 的 IPC 返回之间没有顺序保证
|
||
* (macOS/WKWebView 的 IPC 响应经由自定义协议返回,可以早于那次 eval 落地)。
|
||
* 于是「订阅后立刻注销」会读到一个还不存在的条目并抛错;更糟的是这一抛发生在
|
||
* `_unlisten` 的第一行,`plugin:event|unlisten` 根本发不出去,后端订阅泄漏、
|
||
* 注销过的 handler 之后还会被触发一次。
|
||
*
|
||
* 本仓库 `src/main.tsx` 开着 `React.StrictMode`,挂载即注销(mount → cleanup → mount),
|
||
* 所以任何一处 `listen(...).then(unlisten => disposed && unlisten())` 都会踩这个窗口;
|
||
* 运行模块切换版本时的重渲染/IPC 洪峰会把这个窗口放大成用户可见的报错
|
||
* (经 `unhandledrejection` 上报后还会弹出「发现问题」通知)。
|
||
*
|
||
* 这里的做法是**不依赖那条注册表条目**:直接用 event 插件的 invoke 登记
|
||
* (`plugin:event|listen`),拿回 `eventId` 和 `handlerId`;注销时先摘 JS 回调
|
||
* (`unregisterCallback` 就是 `callbacks.delete`,幂等、缺条目也不抛),再摘后端订阅
|
||
* (Rust 侧 `unlisten` 自身幂等)。竞态因此被消除,而不是把异常吞掉。
|
||
*
|
||
* 上游同因修复:tauri-apps/tauri#15799 / #15800(tauri 2.12 起脚本自带条目判空)。
|
||
* 本项目升级到 tauri >= 2.12 之后,这里可以退回直接用 `@tauri-apps/api/event` 的
|
||
* `listen`,届时删掉 internals 分支即可。
|
||
*
|
||
* 真实 WebView 之外(浏览器预览、Vitest 的 jsdom 替身)没有
|
||
* `window.__TAURI_INTERNALS__`,此时沿用注入的 `window.__TAURI__.event.listen`,
|
||
* 保持各测试套件现有的替身注入点不变。
|
||
*/
|
||
|
||
/** event 插件 `target`:与 `@tauri-apps/api/event` 的 `EventTarget` 一致。 */
|
||
export type TauriEventTarget =
|
||
| { kind: 'Any' }
|
||
| { kind: 'AnyLabel'; label: string }
|
||
| { kind: 'WebviewWindow'; label: string }
|
||
| { kind: 'Webview'; label: string }
|
||
| { kind: 'Window'; label: string };
|
||
|
||
/** 事件回调收到的数据,与 tauri `runCallback(listener.handlerId, eventData)` 的形状一致。 */
|
||
export type TauriEventPayload<Payload> = {
|
||
event: string;
|
||
id: number;
|
||
payload: Payload;
|
||
};
|
||
|
||
/** 注销函数。重复调用安全(只发一次后端注销)。 */
|
||
export type TauriEventUnsubscribe = () => void;
|
||
|
||
export type TauriEventHandler<Payload> = (
|
||
event: TauriEventPayload<Payload>,
|
||
) => void;
|
||
|
||
export type TauriEventSubscribeOptions = {
|
||
/** 默认 `{ kind: 'Any' }`;窗口事件等需要显式限定目标时传字符串标签或 target 对象。 */
|
||
target?: TauriEventTarget | string;
|
||
};
|
||
|
||
type TauriEventInternals = {
|
||
invoke?: (
|
||
command: string,
|
||
args?: Record<string, unknown>,
|
||
) => Promise<unknown>;
|
||
transformCallback?: (
|
||
callback: (eventData: unknown) => void,
|
||
once?: boolean,
|
||
) => number;
|
||
unregisterCallback?: (handlerId: number) => void;
|
||
};
|
||
|
||
type TauriGlobalEventBridge = <Payload = unknown>(
|
||
event: string,
|
||
handler: (event: { payload: Payload }) => void,
|
||
options?: { target?: TauriEventTarget | string },
|
||
) => Promise<() => void>;
|
||
|
||
type InternalsWindow = Window & {
|
||
__TAURI_INTERNALS__?: TauriEventInternals;
|
||
};
|
||
|
||
const NOT_SUBSCRIBED: TauriEventUnsubscribe = () => {};
|
||
|
||
function resolveTauriEventInternals(): TauriEventInternals | null {
|
||
if (typeof window === 'undefined') return null;
|
||
const internals = (window as InternalsWindow).__TAURI_INTERNALS__;
|
||
if (
|
||
!internals ||
|
||
typeof internals.invoke !== 'function' ||
|
||
typeof internals.transformCallback !== 'function' ||
|
||
typeof internals.unregisterCallback !== 'function'
|
||
) {
|
||
return null;
|
||
}
|
||
return internals;
|
||
}
|
||
|
||
function resolveGlobalEventBridge(): TauriGlobalEventBridge | null {
|
||
if (typeof window === 'undefined') return null;
|
||
const bridge = window.__TAURI__?.event?.listen;
|
||
return typeof bridge === 'function'
|
||
? (bridge as TauriGlobalEventBridge)
|
||
: null;
|
||
}
|
||
|
||
function normalizeTarget(
|
||
target: TauriEventTarget | string | undefined,
|
||
): TauriEventTarget {
|
||
if (!target) return { kind: 'Any' };
|
||
return typeof target === 'string'
|
||
? { kind: 'AnyLabel', label: target }
|
||
: target;
|
||
}
|
||
|
||
/**
|
||
* 当前运行时是否能订阅 Tauri 事件。用于替换调用点原来的
|
||
* `const listen = window.__TAURI__?.event?.listen; if (!listen) return;` 守卫。
|
||
*/
|
||
export function canSubscribeTauriEvents(): boolean {
|
||
return Boolean(resolveTauriEventInternals() ?? resolveGlobalEventBridge());
|
||
}
|
||
|
||
/**
|
||
* 订阅一个 Tauri 事件,resolve 出幂等的注销函数;没有任何桥接时 resolve 空操作。
|
||
*/
|
||
export function subscribeTauriEvent<Payload>(
|
||
event: string,
|
||
handler: TauriEventHandler<Payload>,
|
||
options: TauriEventSubscribeOptions = {},
|
||
): Promise<TauriEventUnsubscribe> {
|
||
const internals = resolveTauriEventInternals();
|
||
if (internals) {
|
||
return subscribeThroughTauriInternals(internals, event, handler, options);
|
||
}
|
||
const bridge = resolveGlobalEventBridge();
|
||
if (bridge) {
|
||
return subscribeThroughGlobalBridge(bridge, event, handler, options);
|
||
}
|
||
return Promise.resolve(NOT_SUBSCRIBED);
|
||
}
|
||
|
||
async function subscribeThroughTauriInternals<Payload>(
|
||
internals: TauriEventInternals,
|
||
event: string,
|
||
handler: TauriEventHandler<Payload>,
|
||
options: TauriEventSubscribeOptions,
|
||
): Promise<TauriEventUnsubscribe> {
|
||
const invoke = internals.invoke as NonNullable<TauriEventInternals['invoke']>;
|
||
const transformCallback = internals.transformCallback as NonNullable<
|
||
TauriEventInternals['transformCallback']
|
||
>;
|
||
const unregisterCallback = internals.unregisterCallback as NonNullable<
|
||
TauriEventInternals['unregisterCallback']
|
||
>;
|
||
|
||
const handlerId = transformCallback((eventData) => {
|
||
handler(eventData as TauriEventPayload<Payload>);
|
||
});
|
||
|
||
let eventId: number;
|
||
try {
|
||
eventId = (await invoke('plugin:event|listen', {
|
||
event,
|
||
target: normalizeTarget(options.target),
|
||
handler: handlerId,
|
||
})) as number;
|
||
} catch (error) {
|
||
// 登记失败就别把刚建的 JS 回调留在 callbacks 里。
|
||
unregisterCallback(handlerId);
|
||
throw error;
|
||
}
|
||
|
||
let released = false;
|
||
return () => {
|
||
if (released) return;
|
||
released = true;
|
||
// 先摘 JS 回调:`callbacks.delete` 幂等,即使注册 eval 还没落地也不会抛。
|
||
unregisterCallback(handlerId);
|
||
void invoke('plugin:event|unlisten', { event, eventId }).catch((error) => {
|
||
// 显式记录:后端订阅没摘干净属于必须能看到的异常,不做静默吞掉。
|
||
console.warn(`[AGC] 取消 Tauri 事件订阅失败:${event}`, error);
|
||
});
|
||
};
|
||
}
|
||
|
||
async function subscribeThroughGlobalBridge<Payload>(
|
||
bridge: TauriGlobalEventBridge,
|
||
event: string,
|
||
handler: TauriEventHandler<Payload>,
|
||
options: TauriEventSubscribeOptions,
|
||
): Promise<TauriEventUnsubscribe> {
|
||
const wrapped = (eventData: unknown) =>
|
||
handler(eventData as TauriEventPayload<Payload>);
|
||
// 没有显式 target 时只传两个参数:注入的事件替身与全局桥都按两参签名实现与断言。
|
||
const unlisten =
|
||
options.target === undefined
|
||
? await bridge<Payload>(event, wrapped)
|
||
: await bridge<Payload>(event, wrapped, {
|
||
target: normalizeTarget(options.target),
|
||
});
|
||
let released = false;
|
||
return () => {
|
||
if (released) return;
|
||
released = true;
|
||
unlisten();
|
||
};
|
||
}
|