/** * 与 tauri 2.11.x 等价的 Tauri 事件替身,用于回归「注销竞态」这一类 bug。 * * 现有各套件的替身(`window.__TAURI__ = { core: { invoke }, event: { listen } }`) * 里的 `listen` 返回的注销函数不读任何注册表,所以**结构上抓不到** * `undefined is not an object (evaluating 'listeners[eventId].handlerId')` * (tauri-apps/tauri#15799)。这里按 tauri 2.11.3 的真实实现建三层: * * - `window.__TAURI_INTERNALS__`:core 注入的底层桥(`invoke` / `transformCallback` * / `unregisterCallback`),AGC 的 `subscribeTauriEvent` 走这一层。 * - `window.__TAURI__`:`withGlobalTauri` 的全局桥,`event.listen` 按库内实现返回 * 「先读注册表条目再摘回调」的注销函数(就是会抛的那条路径)。 * - `window.__TAURI_EVENT_PLUGIN_INTERNALS__.unregisterListener`:库内注销脚本本体, * 条目缺失时抛 `TypeError`,并在 `racyUnregisterViolations` 里留痕,便于断言。 * * 注册表条目由 `plugin:event|listen` 之后**另一次** eval 写入:替身把它排进 * `pendingRegistrationEvals`,只有在 `flushRegistrationEvals()` 之后才可见。 */ export type TauriEventFakeEvent = { event: string; id: number; payload: unknown; }; type FakeListener = { event: string; eventId: number; handlerId: number; target: unknown; }; export type TauriEventFake = { /** 已登记在「后端」的事件订阅(模拟 `Listeners::js_event_listeners`)。 */ listeners: FakeListener[]; /** 库内注销脚本读不到注册表条目时的记录(每次都是一次竞态命中)。 */ racyUnregisterViolations: string[]; /** 库内注销脚本被调用的次数。 */ racyUnregisterCalls: number; /** 全局桥 `event.listen` 被调用的次数。 */ bridgeListenCalls: number; /** 每个事件的 `plugin:event|unlisten` 调用次数。 */ unlistenInvokeCounts: Map; /** 每个事件的 `plugin:event|listen` 调用次数。 */ listenInvokeCounts: Map; /** 还没落地的注册 eval 数量(模拟 listen 之后那次独立 eval)。 */ pendingRegistrationEvals(): number; /** 让注册 eval 落地:之后 `emit` 才能命中新登记的事件 ID。 */ flushRegistrationEvals(): void; /** 仍在 `__TAURI_INTERNALS__.callbacks` 里的 handler 数量。 */ jsCallbackCount(): number; /** 直接投递事件,语义与 `event_initialization_script` 一致(条目缺失则跳过)。 */ emit(event: string, payload: unknown): void; install(): void; restore(): void; }; type FakeOptions = { /** 非 event 插件的命令交给它(通常是套件自己的 invoke 替身)。 */ invoke?: ( command: string, args?: Record, ) => Promise; }; const INTERNALS_KEY = '__TAURI_INTERNALS__'; const EVENT_INTERNALS_KEY = '__TAURI_EVENT_PLUGIN_INTERNALS__'; const GLOBAL_KEY = '__TAURI__'; type MutableWindow = Window & Record; export function createTauriEventFake( options: FakeOptions = {}, ): TauriEventFake { const listeners: FakeListener[] = []; const racyUnregisterViolations: string[] = []; const unlistenInvokeCounts = new Map(); const listenInvokeCounts = new Map(); const jsCallbacks = new Map void>(); const registrations: FakeListener[] = []; let nextEventId = 1; let nextHandlerId = 1; let racyUnregisterCalls = 0; let bridgeListenCalls = 0; let installedGlobals: Array<{ key: string; had: boolean; value: unknown }> = []; const registry: Record> = {}; const fake: TauriEventFake = { listeners, racyUnregisterViolations, get racyUnregisterCalls() { return racyUnregisterCalls; }, get bridgeListenCalls() { return bridgeListenCalls; }, unlistenInvokeCounts, listenInvokeCounts, pendingRegistrationEvals: () => registrations.length, flushRegistrationEvals() { const pending = registrations.splice(0, registrations.length); for (const listener of pending) { registry[listener.event] ??= {}; registry[listener.event][listener.eventId] = { handlerId: listener.handlerId, }; } }, jsCallbackCount: () => jsCallbacks.size, emit(event, payload) { for (const listener of listeners) { if (listener.event !== event) continue; const entry = registry[event]?.[listener.eventId]; if (!entry) continue; jsCallbacks.get(entry.handlerId)?.({ event, id: listener.eventId, payload, }); } }, install() { const target = window as unknown as MutableWindow; const internals = { invoke: async (command: string, args?: Record) => { if (command === 'plugin:event|listen') { const event = String(args?.event ?? ''); const eventId = nextEventId++; listenInvokeCounts.set( event, (listenInvokeCounts.get(event) ?? 0) + 1, ); const listener: FakeListener = { event, eventId, handlerId: Number(args?.handler ?? 0), target: args?.target, }; listeners.push(listener); // 注册表条目由 listen 之后一次独立 eval 写入,这里只排队。 registrations.push(listener); return eventId; } if (command === 'plugin:event|unlisten') { const event = String(args?.event ?? ''); const eventId = Number(args?.eventId ?? 0); unlistenInvokeCounts.set( event, (unlistenInvokeCounts.get(event) ?? 0) + 1, ); const index = listeners.findIndex( (item) => item.event === event && item.eventId === eventId, ); if (index >= 0) listeners.splice(index, 1); return null; } if (options.invoke) return options.invoke(command, args); return null; }, transformCallback: (callback: (eventData: unknown) => void) => { const handlerId = nextHandlerId++; jsCallbacks.set(handlerId, callback); return handlerId; }, unregisterCallback: (handlerId: number) => { jsCallbacks.delete(handlerId); }, }; const racyUnregisterListener = (event: string, eventId: number) => { racyUnregisterCalls += 1; const eventListeners = registry[event]; if (!eventListeners) return; const entry = eventListeners[eventId]; if (!entry) { racyUnregisterViolations.push(`${event}#${eventId}`); // tauri 2.11 unlisten_js_script 的真实抛错点。 throw new TypeError( "undefined is not an object (evaluating 'listeners[eventId].handlerId')", ); } jsCallbacks.delete(entry.handlerId); }; // 全局桥(withGlobalTauri):按库内实现返回会读注册表的注销函数。 const bridgeListen = async ( event: string, handler: (eventData: unknown) => void, ) => { bridgeListenCalls += 1; const handlerId = internals.transformCallback(handler); const eventId = Number( (await internals.invoke('plugin:event|listen', { event, target: { kind: 'Any' }, handler: handlerId, })) as number, ); return () => { racyUnregisterListener(event, eventId); void internals.invoke('plugin:event|unlisten', { event, eventId }); }; }; const globals: Array<[string, unknown]> = [ [INTERNALS_KEY, internals], [EVENT_INTERNALS_KEY, { unregisterListener: racyUnregisterListener }], [ GLOBAL_KEY, { core: { invoke: internals.invoke }, event: { listen: (event: string, handler: (eventData: unknown) => void) => bridgeListen(event, handler), }, }, ], ]; installedGlobals = globals.map(([key]) => ({ key, had: key in target, value: target[key], })); for (const [key, value] of globals) target[key] = value; }, restore() { const target = window as unknown as MutableWindow; for (const entry of installedGlobals) { if (entry.had) target[entry.key] = entry.value; else delete target[entry.key]; } installedGlobals = []; }, }; return fake; }