diff --git a/src/App.tsx b/src/App.tsx index 4d74a319..8f9231fe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; +import { subscribe } from "@/lib/tauriEvent"; import { SearchIcon } from "lucide-react"; import { call } from "@/control"; import { useRequests } from "./useRequests"; @@ -396,9 +396,9 @@ function Shell({ first }: { first: boolean }) { void invoke("import_link_closed") .catch(() => {}) .then(take); - const un = listen("import-link", () => void take()); + const un = subscribe("import-link", () => void take()); return () => { - void un.then((f) => f()); + un(); }; }, []); const closeImport = useCallback(() => { @@ -435,7 +435,7 @@ function Shell({ first }: { first: boolean }) { void invoke("take_pending_view") .then(go) .catch(() => {}); - const un = listen("open-view", (e) => { + const un = subscribe("open-view", (e) => { go(e.payload); void invoke("take_pending_view").catch(() => {}); }); @@ -443,7 +443,7 @@ function Shell({ first }: { first: boolean }) { const local = (e: Event) => go((e as CustomEvent).detail); window.addEventListener("tw-open-view", local); return () => { - void un.then((f) => f()); + un(); window.removeEventListener("tw-open-view", local); }; }, []); @@ -526,7 +526,7 @@ function Shell({ first }: { first: boolean }) { */ useEffect(() => { let alive = true; - const un = listen("core-state", (e) => { + const un = subscribe("core-state", (e) => { if (!alive) return; setCore(e.payload); // 又起来了:之前读状态失败的次数和原因作废,这一回重新数 @@ -535,7 +535,7 @@ function Shell({ first }: { first: boolean }) { setLinkError(null); } }); - void un + void un.ready .then(() => invoke("core_state")) .then((c) => { if (alive) setCore(c); @@ -545,7 +545,7 @@ function Shell({ first }: { first: boolean }) { }); return () => { alive = false; - void un.then((f) => f()); + un(); }; }, []); diff --git a/src/Notices.tsx b/src/Notices.tsx index 7afa2e17..20de0ef6 100644 --- a/src/Notices.tsx +++ b/src/Notices.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { BellIcon, XIcon } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; +import { subscribe } from "@/lib/tauriEvent"; import { Button } from "@/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/ui/popover"; import { when } from "@/format"; @@ -62,8 +62,8 @@ export function Notices({ useEffect(() => { load(); - const un = listen("notices-changed", (e) => setList(e.payload)); - return () => void un.then((f) => f()); + const un = subscribe("notices-changed", (e) => setList(e.payload)); + return un; }, [load]); useEffect(() => { @@ -74,8 +74,8 @@ export function Notices({ invoke("notice_mode") .then(setMode) .catch(() => setMode("system")); - const un = listen("notice-mode-changed", (e) => setMode(e.payload)); - return () => void un.then((f) => f()); + const un = subscribe("notice-mode-changed", (e) => setMode(e.payload)); + return un; }, []); function markRead(key: string) { diff --git a/src/RequestDrawer.tsx b/src/RequestDrawer.tsx index 130d4c12..b6729a86 100644 --- a/src/RequestDrawer.tsx +++ b/src/RequestDrawer.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import { listen } from "@tauri-apps/api/event"; +import { subscribe } from "@/lib/tauriEvent"; import { call } from "@/control"; import { useText } from "@/i18n"; import { coreText } from "@/i18n/core.i18n"; @@ -154,7 +154,7 @@ function Detail({ id, onClose }: { id: number; onClose: () => void }) { useEffect(() => { if (!running) return; let alive = true; - const un = listen("core-event", (e) => { + const un = subscribe("core-event", (e) => { const ev = e.payload; const mine = (ev.kind === "request_headers" || @@ -167,7 +167,7 @@ function Detail({ id, onClose }: { id: number; onClose: () => void }) { }); return () => { alive = false; - void un.then((f) => f()); + un(); }; }, [running, id, load]); diff --git a/src/UpdateWindow.tsx b/src/UpdateWindow.tsx index abcb2e92..4e7185a7 100644 --- a/src/UpdateWindow.tsx +++ b/src/UpdateWindow.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; +import { subscribe } from "@/lib/tauriEvent"; import { getCurrentWindow } from "@tauri-apps/api/window"; import appIcon from "../src-tauri/icons/128x128.png"; import { Banner } from "@/ui/banner"; @@ -59,22 +59,22 @@ export default function UpdateWindow() { // 窗口开着的时候又查到了更新的一版 —— 换成新的那一版 useEffect(() => { - const un = listen("update-found", load); + const un = subscribe("update-found", load); return () => { - void un.then((f) => f()); + un(); }; }, [load]); useEffect(() => { - const a = listen("update-step", (e) => setStep(e.payload)); + const a = subscribe("update-step", (e) => setStep(e.payload)); // 事件报的是**这一块多大**,不是已下载总量,要自己累加 - const b = listen<[number, number | null]>("update-progress", (e) => { + const b = subscribe<[number, number | null]>("update-progress", (e) => { const [chunk, total] = e.payload; setProgress(([done, t]) => [done + chunk, total ?? t]); }); return () => { - void a.then((f) => f()); - void b.then((f) => f()); + a(); + b(); }; }, []); diff --git a/src/connection/useConnection.ts b/src/connection/useConnection.ts index 577ad5ae..b0a8c096 100644 --- a/src/connection/useConnection.ts +++ b/src/connection/useConnection.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { listen } from "@tauri-apps/api/event"; +import { subscribe } from "@/lib/tauriEvent"; import { connApi, type ConnView } from "./api"; /** @@ -11,10 +11,10 @@ export function useConnection(): ConnView | null { const [view, setView] = useState(null); useEffect(() => { let alive = true; - const un = listen("connection", (e) => { + const un = subscribe("connection", (e) => { if (alive) setView(e.payload); }); - void un + void un.ready .then(() => connApi.view()) .then((v) => { if (alive) setView(v); @@ -24,7 +24,7 @@ export function useConnection(): ConnView | null { }); return () => { alive = false; - void un.then((f) => f()); + un(); }; }, []); return view; diff --git a/src/lib/tauriEvent.test.ts b/src/lib/tauriEvent.test.ts new file mode 100644 index 00000000..0dd802b2 --- /dev/null +++ b/src/lib/tauriEvent.test.ts @@ -0,0 +1,352 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest"; +import type { Event } from "@tauri-apps/api/event"; +import { RETRY_MS, subscribe, useTauriEvent } from "./tauriEvent"; + +/** + * 假的 `listen`:每订阅一次登记一条,注册什么时候完成、退订会不会失败都由测试来定。 + * `emit` 发给 Tauri 那边**还登记着**的监听 —— 退订失败的那一条照样收得到,和真应用 + * 里那条没退掉的监听一样。 + */ +interface Reg { + event: string; + options: unknown; + handler: (e: Event) => void; + unlisten: Mock<() => Promise>; + /** 注册完成:`listen` 兑现,交出退订函数 */ + register: () => void; + /** 注册失败(不在应用里) */ + fail: (e: unknown) => void; + /** Tauri 那边已经退掉了 */ + gone: boolean; +} + +const tauri = vi.hoisted(() => ({ regs: [] as Reg[] })); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn( + (event: string, handler: (e: Event) => void, options?: unknown) => + new Promise((resolve, reject) => { + const reg: Reg = { + event, + options, + handler, + unlisten: vi.fn(async () => { + reg.gone = true; + }), + register: () => resolve(() => reg.unlisten()), + fail: reject, + gone: false, + }; + tauri.regs.push(reg); + }), + ), +})); + +/** + * 只够 `useTauriEvent` 用的一个 React:`useRef`、`useEffect` 按调用顺序占格子,`mount`、 + * `rerender` 跑一遍组件再提交 effect。**StrictMode 的开发模式**照 React 自己的做法:第一次 + * 挂上时 effect 跑完、清理、再跑一遍。仓库里没有 DOM 测试环境,所以照着这个顺序重放。 + */ +const react = vi.hoisted(() => { + interface Slot { + ref?: { current: unknown }; + effect?: () => void | (() => void); + deps?: readonly unknown[]; + cleanup?: void | (() => void); + dirty?: boolean; + } + const slots: Slot[] = []; + let at = 0; + return { + slots, + start: () => { + at = 0; + }, + useRef: (init: unknown) => { + const s = (slots[at++] ??= {}); + s.ref ??= { current: init }; + return s.ref; + }, + useEffect: (effect: () => void | (() => void), deps?: readonly unknown[]) => { + const s = (slots[at++] ??= {}); + s.effect = effect; + if (!s.deps || !deps || deps.some((d, i) => !Object.is(d, s.deps?.[i]))) { + s.deps = deps; + s.dirty = true; + } + }, + }; +}); + +vi.mock("react", async (load) => ({ + ...(await load()), + useRef: react.useRef, + useEffect: react.useEffect, +})); + +function mount

(component: (props: P) => void, props: P, { strict = false } = {}) { + const cleanup = () => { + for (const s of react.slots) if (typeof s.cleanup === "function") s.cleanup(); + }; + const commit = () => { + for (const s of react.slots) { + if (!s.dirty) continue; + if (typeof s.cleanup === "function") s.cleanup(); + s.cleanup = s.effect?.(); + s.dirty = false; + } + }; + react.start(); + component(props); + commit(); + if (strict) { + // StrictMode:挂上、卸掉、再挂上,三步之间没有任何异步 + cleanup(); + for (const s of react.slots) s.cleanup = s.effect?.(); + } + return { + rerender(next: P) { + react.start(); + component(next); + commit(); + }, + unmount: cleanup, + }; +} + +/** 发一个事件给 Tauri 那边还登记着的监听 */ +function emit(event: string, payload: unknown) { + for (const r of tauri.regs) if (r.event === event && !r.gone) r.handler({ event, id: 0, payload }); +} + +/** + * 跑完所有排着的微任务,再过一轮事件循环。Node 在这之间检查没人接的 rejection, + * `rejections` 收得到的话就是在这之前漏的 + */ +const settle = () => new Promise((r) => setImmediate(r)); + +const rejections: unknown[] = []; +const onRejection = (e: unknown) => rejections.push(e); + +beforeEach(() => { + tauri.regs.length = 0; + react.slots.length = 0; + rejections.length = 0; + process.on("unhandledRejection", onRejection); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); +}); + +afterEach(async () => { + await settle(); + process.off("unhandledRejection", onRejection); + vi.useRealTimers(); + // 每一条都不许漏出没人接的 rejection + expect(rejections).toEqual([]); +}); + +/** Tauri 的 `_unlisten` 在登记脚本还没到页面时抛的那个错 */ +const notYet = () => new TypeError("undefined is not an object (evaluating 'listeners[eventId].handlerId')"); + +describe("subscribe", () => { + it("注册完成后退订:退一次,之后的事件不再进回调", async () => { + const seen: unknown[] = []; + const off = subscribe("core-state", (e) => seen.push(e.payload)); + const [reg] = tauri.regs; + reg!.register(); + await settle(); + emit("core-state", 1); + off(); + await settle(); + emit("core-state", 2); + expect(seen).toEqual([1]); + expect(reg!.unlisten).toHaveBeenCalledTimes(1); + }); + + it("注册完成前就退订:完成的那一刻退,只退一次", async () => { + const seen: unknown[] = []; + const off = subscribe("core-event", (e) => seen.push(e.payload)); + off(); + const [reg] = tauri.regs; + expect(reg!.unlisten).not.toHaveBeenCalled(); + reg!.register(); + await settle(); + expect(reg!.unlisten).toHaveBeenCalledTimes(1); + emit("core-event", "late"); + expect(seen).toEqual([]); + }); + + it("退订两次只退一次,注册前后都一样", async () => { + const early = subscribe("a", () => {}); + early(); + early(); + tauri.regs[0]!.register(); + await settle(); + early(); + + const late = subscribe("b", () => {}); + tauri.regs[1]!.register(); + await settle(); + late(); + late(); + await settle(); + late(); + + expect(tauri.regs[0]!.unlisten).toHaveBeenCalledTimes(1); + expect(tauri.regs[1]!.unlisten).toHaveBeenCalledTimes(1); + }); + + /** + * 真应用里的那个空档:注册完成时登记脚本还没到页面,退订抛错(Tauri 的退订函数是 + * async 的,抛错就是一个 rejection)。**不往外漏**,隔一会儿再退一次就退掉了; + * 中间 Tauri 照旧发来的事件不进回调 + */ + it("退订失败不往外抛,隔一会儿再退一次", async () => { + const seen: unknown[] = []; + const off = subscribe("core-event", (e) => seen.push(e.payload)); + off(); + const [reg] = tauri.regs; + reg!.unlisten.mockRejectedValueOnce(notYet()); + reg!.register(); + await settle(); + expect(reg!.unlisten).toHaveBeenCalledTimes(1); + emit("core-event", "still registered"); + expect(seen).toEqual([]); + + vi.advanceTimersByTime(RETRY_MS); + await settle(); + expect(reg!.unlisten).toHaveBeenCalledTimes(2); + expect(reg!.gone).toBe(true); + vi.advanceTimersByTime(RETRY_MS * 10); + await settle(); + expect(reg!.unlisten).toHaveBeenCalledTimes(2); + }); + + it("退订函数同步抛错也一样接住", async () => { + const off = subscribe("core-event", () => {}); + const [reg] = tauri.regs; + reg!.unlisten.mockImplementationOnce(() => { + throw notYet(); + }); + reg!.register(); + await settle(); + off(); + await settle(); + vi.advanceTimersByTime(RETRY_MS); + await settle(); + expect(reg!.unlisten).toHaveBeenCalledTimes(2); + expect(reg!.gone).toBe(true); + }); + + it("两次都退不掉就算了:不再重试,也不往外抛", async () => { + const debug = vi.spyOn(console, "debug").mockImplementation(() => {}); + const seen: unknown[] = []; + const off = subscribe("core-event", (e) => seen.push(e.payload)); + const [reg] = tauri.regs; + reg!.unlisten.mockRejectedValue(notYet()); + reg!.register(); + await settle(); + off(); + await settle(); + vi.advanceTimersByTime(RETRY_MS * 10); + await settle(); + expect(reg!.unlisten).toHaveBeenCalledTimes(2); + // 监听还登记着,回调照样失效 + emit("core-event", "after"); + expect(seen).toEqual([]); + expect(debug).toHaveBeenCalledTimes(1); + debug.mockRestore(); + }); + + /** 「先挂上监听再读一次现状」等的是它 */ + it("ready 在注册完成时兑现,退订过也一样", async () => { + const off = subscribe("connection", () => {}); + let ready = false; + void off.ready.then(() => { + ready = true; + }); + off(); + await settle(); + expect(ready).toBe(false); + tauri.regs[0]!.register(); + await settle(); + expect(ready).toBe(true); + }); + + /** 不在应用里(浏览器里看 `pnpm dev`、截图页没 mock 的事件):没人等也不漏 rejection */ + it("注册失败时 ready 拒绝;没人等它也不漏,退订什么都不做", async () => { + const quiet = subscribe("language-changed", () => {}); + tauri.regs[0]!.fail(new Error("not in Tauri")); + await settle(); + quiet(); + + const waited = subscribe("core-state", () => {}); + tauri.regs[1]!.fail(new Error("not in Tauri")); + await expect(waited.ready).rejects.toThrow("not in Tauri"); + waited(); + await settle(); + }); + + /** 窗口范围的事件(`getCurrentWindow().listen` 那一种):`target` 原样交给 `listen` */ + it("options 原样交给 listen", () => { + const target = { kind: "Window", label: "main" } as const; + subscribe("tauri://focus", () => {}, { target }); + expect(tauri.regs[0]!.options).toEqual({ target }); + }); +}); + +describe("useTauriEvent", () => { + function Probe({ event, onEvent }: { event: string; onEvent: (p: unknown) => void }) { + useTauriEvent(event, (e) => onEvent(e.payload)); + } + + /** + * 开发模式下 StrictMode 把挂上的 effect 跑两遍,中间清理一次,**三步都在第一个 + * `listen` 兑现之前**。以前这正是成串报错的地方:第一份在兑现的那一刻被退订 + */ + it("StrictMode 双跑:第一份兑现时退一次,只剩一份在听", async () => { + const seen: unknown[] = []; + mount(Probe, { event: "notices-changed", onEvent: (p) => seen.push(p) }, { strict: true }); + expect(tauri.regs).toHaveLength(2); + const [first, second] = tauri.regs; + first!.unlisten.mockRejectedValueOnce(notYet()); + first!.register(); + second!.register(); + await settle(); + emit("notices-changed", "x"); + expect(seen).toEqual(["x"]); + vi.advanceTimersByTime(RETRY_MS); + await settle(); + expect(first!.unlisten).toHaveBeenCalledTimes(2); + expect(first!.gone).toBe(true); + expect(second!.unlisten).not.toHaveBeenCalled(); + }); + + it("回调换了不重新订阅,事件交给最新的回调", async () => { + const a: unknown[] = []; + const b: unknown[] = []; + const view = mount(Probe, { event: "update-step", onEvent: (p) => a.push(p) }); + tauri.regs[0]!.register(); + await settle(); + view.rerender({ event: "update-step", onEvent: (p) => b.push(p) }); + emit("update-step", 1); + expect(tauri.regs).toHaveLength(1); + expect(a).toEqual([]); + expect(b).toEqual([1]); + }); + + it("事件名换了:退掉旧的、订新的;卸掉时退掉", async () => { + const view = mount(Probe, { event: "a", onEvent: () => {} }); + tauri.regs[0]!.register(); + await settle(); + view.rerender({ event: "b", onEvent: () => {} }); + tauri.regs[1]!.register(); + await settle(); + expect(tauri.regs.map((r) => [r.event, r.unlisten.mock.calls.length])).toEqual([ + ["a", 1], + ["b", 0], + ]); + view.unmount(); + await settle(); + expect(tauri.regs[1]!.unlisten).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/tauriEvent.ts b/src/lib/tauriEvent.ts new file mode 100644 index 00000000..3228700b --- /dev/null +++ b/src/lib/tauriEvent.ts @@ -0,0 +1,92 @@ +import { useEffect, useRef } from "react"; +import { listen, type EventCallback, type EventName, type Options, type UnlistenFn } from "@tauri-apps/api/event"; + +/** + * 听 Rust 那边发来的事件。**界面里的事件订阅一律走这里**,不直接调 `listen`、`once` + * 或窗口上的 `listen`/`onXxx`(`source.test.ts` 盯着)。窗口范围的事件用 `options.target`。 + * + * **直接用 `listen` 退订会抛错,还可能根本没退掉。**`listen` 兑现时,Tauri 只是把「在 + * 页面里登记这个监听」的那段脚本排进了应用主线程的队列,应答却是从异步运行时直接发回 + * 来的 —— 两者谁先到页面说不准。真应用里换页时实测:一千来次订阅里有几十次应答先到, + * 脚本多半晚几毫秒到几十毫秒才跟上。这个空档里退订,Tauri 的 `_unlisten` 去读 + * `listeners[eventId].handlerId`,那一项还没有: + * + * · 退订函数是 async 的,抛出来的错误成了一个没人接的 rejection,换页时成串地出; + * · 它抛在通知 Rust 之前,Rust 那边的登记原样留着。脚本随后到达、监听照常装上,之后 + * 每来一个事件,都去调那个已经卸掉的组件的回调。 + * + * 退订偏偏最容易撞进这个空档:effect 的清理先于 `listen` 兑现时(StrictMode 的双跑、 + * 刚挂上就卸掉的页面),`un.then((f) => f())` 正好在兑现的那一刻退。 + * + * 所以这里: + * + * · 退订函数是同步的,什么时候调都行,调几次都只退一次。注册还没完成就记下,完成的 + * 那一刻退; + * · 一退订,回调当场失效,不等 Tauri 那边真的退掉; + * · 退订失败不往外抛,隔一会儿再退一次,那时脚本早到了;再不行就算了 —— 回调反正已经 + * 失效。再退一次不会退掉别的:上面那种失败抛在动任何东西之前,别的失败(通知 Rust 的 + * 那一下没送到)重来一遍也只是按同一个号再删一次。 + */ +export function subscribe(event: EventName, handler: EventCallback, options?: Options): Unsubscribe { + let live = true; + let unlisten: UnlistenFn | null = null; + const ready = listen( + event, + (e) => { + if (live) handler(e); + }, + options, + ).then((f) => { + unlisten = f; + if (!live) release(f); + }); + // 只退订、没人等 `ready` 的地方,「不在应用里」的那次拒绝不该成为没人接的 rejection + ready.catch(() => {}); + const off = () => { + if (!live) return; + live = false; + if (unlisten) release(unlisten); + }; + return Object.assign(off, { ready }); +} + +/** + * 退订。**同步、随时可调、调几次都只退一次**,可以直接当 effect 的清理函数返回。 + */ +export type Unsubscribe = (() => void) & { + /** + * 注册完成时兑现,注册失败(不在应用里)时拒绝,跟着 `listen` 返回的那个 promise 落定。 + * 「先挂上监听再读一次现状」的地方等它:它兑现时 Rust 那边已经记下了这个监听,之后发出 + * 的事件都会发给它。 + */ + readonly ready: Promise; +}; + +/** + * 组件挂着的时候听一个事件。回调总是用最新的那一个(页面里通常是个每次渲染都新的闭包), + * 订阅只跟着事件名重建 —— 重建一次就可能漏掉那一瞬间的事件。 + */ +export function useTauriEvent(event: EventName, handler: EventCallback): void { + const latest = useRef(handler); + latest.current = handler; + useEffect(() => subscribe(event, (e) => latest.current(e)), [event]); +} + +/** 第一次没退掉之后隔多久再退。登记脚本多半只晚几毫秒到几十毫秒,这里留足余量 */ +export const RETRY_MS = 250; + +function release(f: UnlistenFn, again = true): void { + attempt(f).catch((e: unknown) => { + if (again) setTimeout(() => release(f, false), RETRY_MS); + else if (import.meta.env.DEV) console.debug("[tauri-event] unlisten failed", e); + }); +} + +/** 调一次退订函数,同步抛出和异步拒绝一样收成一个 promise */ +function attempt(f: UnlistenFn): Promise { + try { + return Promise.resolve(f()); + } catch (e) { + return Promise.reject(e); + } +} diff --git a/src/main.tsx b/src/main.tsx index 9d6235fd..7940ef16 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,12 +1,12 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { getCurrentWindow } from "@tauri-apps/api/window"; -import { listen } from "@tauri-apps/api/event"; +import { subscribe } from "./lib/tauriEvent"; import { setLang, type Lang } from "./i18n"; import "./index.css"; // 设置里换了语言:每个开着的窗口当场换。不在应用里时没有事件可听 -void listen("language-changed", (e) => setLang(e.payload)).catch(() => {}); +subscribe("language-changed", (e) => setLang(e.payload)); // 更新窗口和主窗口是同一份页面,按窗口的名字决定画哪一个。给一个小窗 // 单独起一个入口,就要多维护一份构建配置和一份样式入口。 diff --git a/src/overview/useLive.ts b/src/overview/useLive.ts index fb4490b9..579ee1ac 100644 --- a/src/overview/useLive.ts +++ b/src/overview/useLive.ts @@ -1,6 +1,6 @@ import { useEffect, useReducer, useRef } from "react"; import { call } from "@/control"; -import { listen } from "@tauri-apps/api/event"; +import { subscribe } from "@/lib/tauriEvent"; import { textOf } from "@/i18n"; import type { CoreEvent, HistoryRow } from "@/types"; import { liveText } from "./useLive.i18n"; @@ -207,7 +207,7 @@ export function useInFlight(): number { flying.current.delete(id); since?.ended.add(id); }; - const un = listen("core-event", (e) => { + const un = subscribe("core-event", (e) => { const ev = e.payload; if (ev.kind === "request_started") start(ev.id); else if ( @@ -229,7 +229,7 @@ export function useInFlight(): number { try { // **等订阅真的挂上再问。**`listen` 是异步注册的:先问的话,快照和 // 订阅之间结束的请求,它的结局谁都没收到,就一直挂在「进行中」 - await un; + await un.ready; const open = await call("InFlight", null); // 等的这会儿 core 停了、或者又开始了一次对账:这份作废 if (!alive || since !== mark) return; @@ -249,7 +249,7 @@ export function useInFlight(): number { **core 不在跑的时候,谁都不在「进行中」。**它一停,正在跑的请求就断 了,而它们再也不会有结局;回来的时候(`running:`)重新对一遍。 */ - const unState = listen("core-state", (e) => { + const unState = subscribe("core-state", (e) => { if (e.payload.startsWith("running")) { void resync(); } else { @@ -260,8 +260,8 @@ export function useInFlight(): number { }); return () => { alive = false; - void un.then((f) => f()); - void unState.then((f) => f()); + un(); + unState(); }; }, [soon]); @@ -324,7 +324,7 @@ export function useLiveWindow(active: boolean, windowMs: number) { }); arrived.current += 1; }; - const un = listen("core-event", (e) => { + const un = subscribe("core-event", (e) => { const ev = e.payload; if (ev.kind === "request_finished" || ev.kind === "request_cancelled") { // 取消的也画进曲线:**上游已经为它计了费**,那些 token 真实发生过 @@ -362,7 +362,7 @@ export function useLiveWindow(active: boolean, windowMs: number) { const seed = async () => { try { // 挂上了再补 —— `listen` 是异步注册的 - await un; + await un.ready; /* **库里的时刻是 core 的时钟,图的横轴是这边的时钟。**连的是另一台机器上 的 core 时,两边不一定对得上:差出一分钟,补回来的这一段就整段错开一分钟, @@ -407,7 +407,7 @@ export function useLiveWindow(active: boolean, windowMs: number) { }, LIVE_FRAME_MS); return () => { alive = false; - void un.then((f) => f()); + un(); clearInterval(h); }; }, [active, windowMs, frame, soon]); diff --git a/src/routing/useFlights.ts b/src/routing/useFlights.ts index 30cafc1f..02e55c21 100644 --- a/src/routing/useFlights.ts +++ b/src/routing/useFlights.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { listen } from "@tauri-apps/api/event"; +import { subscribe } from "@/lib/tauriEvent"; import { call } from "@/control"; import type { CoreEvent } from "@/types"; import { applyFlightEvent, type Flight } from "./flights"; @@ -43,7 +43,7 @@ export function useFlights(): ReadonlyMap { timers.add(h); }; - const un = listen("core-event", (e) => { + const un = subscribe("core-event", (e) => { const ev = e.payload; if (ev.kind === "events_dropped") { // 结局可能正在丢掉的那几条里:清掉,按快照重来 @@ -62,7 +62,7 @@ export function useFlights(): ReadonlyMap { seen = mark; try { // 等订阅真的挂上再问:`listen` 是异步注册的 - await un; + await un.ready; const open = await call("InFlight", null); if (!alive || seen !== mark) return; // 每个请求到目前为止的事件按原来的顺序重放:已经路由了的,一打开就画到上游 @@ -78,7 +78,7 @@ export function useFlights(): ReadonlyMap { } } void resync(); - const unState = listen("core-state", (e) => { + const unState = subscribe("core-state", (e) => { if (e.payload.startsWith("running")) { void resync(); return; @@ -90,8 +90,8 @@ export function useFlights(): ReadonlyMap { return () => { alive = false; - void un.then((f) => f()); - void unState.then((f) => f()); + un(); + unState(); if (batch) clearTimeout(batch); for (const h of timers) clearTimeout(h); }; diff --git a/src/settings/AboutSection.tsx b/src/settings/AboutSection.tsx index 2f28b8da..c939c371 100644 --- a/src/settings/AboutSection.tsx +++ b/src/settings/AboutSection.tsx @@ -10,6 +10,7 @@ import { StatusLabel } from "@/ui/status-dot"; import { Switch } from "@/ui/switch"; import { Tip } from "@/ui/tip"; import { useResource } from "@/lib/resource"; +import { useTauriEvent } from "@/lib/tauriEvent"; import { cn } from "@/lib/utils"; import { useText } from "@/i18n"; import { errorText } from "@/i18n/core.i18n"; @@ -17,7 +18,7 @@ import { remoteText } from "@/connection/remote.i18n"; import type { RemoteCore } from "@/connection/api"; import type { Found, UpdateView } from "@/updateFlow"; import { APP_KEYS, settingsApi } from "./api"; -import { FLASH, Loaded, RowError, SettingsCard, SettingsGroup, SettingsRow, anchorId, useAppEvent, useWrite } from "./kit"; +import { FLASH, Loaded, RowError, SettingsCard, SettingsGroup, SettingsRow, anchorId, useWrite } from "./kit"; import { settingsText } from "./SettingsPage.i18n"; /** 「检查更新」按下去之后的结果。`idle`:这次打开设置页之后还没查过 */ @@ -55,7 +56,7 @@ export function AboutSection({ remote, linked }: { remote: RemoteCore | null; li }; // 后台那轮自动检查查到了:这里也跟着显示 - useAppEvent("update-found", withOffer); + useTauriEvent("update-found", (e) => withOffer(e.payload)); async function look() { setCheck({ kind: "checking" }); diff --git a/src/settings/GeneralSection.tsx b/src/settings/GeneralSection.tsx index 441a3abe..3b272118 100644 --- a/src/settings/GeneralSection.tsx +++ b/src/settings/GeneralSection.tsx @@ -8,6 +8,7 @@ import { notify } from "@/ui/notify"; import { guideText } from "@/guide/guide.i18n"; import { resetHints, useAnyDismissed } from "@/guide/hints"; import { useResource } from "@/lib/resource"; +import { useTauriEvent } from "@/lib/tauriEvent"; import { LANG_NAMES, setLang, useText, type Lang } from "@/i18n"; import { isMac } from "@/platform"; import { @@ -20,7 +21,7 @@ import { type ThemeView, } from "./api"; import { generalText } from "./GeneralSection.i18n"; -import { Loaded, SettingsCard, SettingsGroup, SettingsRow, useAppEvent, useWrite } from "./kit"; +import { Loaded, SettingsCard, SettingsGroup, SettingsRow, useWrite } from "./kit"; /** * 设置 → 通用。**改的是这个应用自己,点一下就换**,不走配置文件的「保存」:这几项 @@ -62,7 +63,7 @@ function LanguageRow() { return v; }); // 别的窗口(或者系统语言)换了:跟着重读 - useAppEvent("language-changed", () => void r.reload()); + useTauriEvent("language-changed", () => void r.reload()); return ( ("menubar-style-changed", (s) => void mutate(s)); + useTauriEvent("menubar-style-changed", (e) => void mutate(e.payload)); return ( ("notice-mode-changed", (m) => void mutate(m)); + useTauriEvent("notice-mode-changed", (e) => void mutate(e.payload)); return ( (r: Resource, write: (next: T) => Promise): [bo ); return [pending, set]; } - -/** - * 听一个 Rust 那边发的应用事件(`language-changed`、`notice-mode-changed`…)。它们 - * 不是 core 的事件,`useResource` 的 `events` 管不到。不在应用里时什么都不做。 - */ -export function useAppEvent(name: string, cb: (payload: T) => void) { - const f = useRef(cb); - f.current = cb; - useEffect(() => { - let off: (() => void) | null = null; - let dead = false; - listen(name, (e) => f.current(e.payload)) - .then((un) => { - if (dead) un(); - else off = un; - }) - .catch(() => {}); - return () => { - dead = true; - off?.(); - }; - }, [name]); -} diff --git a/src/source.test.ts b/src/source.test.ts index 3d0bc805..536df7a4 100644 --- a/src/source.test.ts +++ b/src/source.test.ts @@ -21,6 +21,25 @@ function hasControlChar(text: string): boolean { return false; } +/** 唯一可以直接订阅 Tauri 事件的文件 */ +const TAURI_EVENT = join("src", "lib", "tauriEvent.ts"); + +/** + * 一段源码里绕过 `@/lib/tauriEvent` 订阅 Tauri 事件的地方:从 `@tauri-apps/api/event` + * 拿值(只拿类型的 `import type` 不算)、窗口或 webview 上的 `.listen(`/`.once(`, + * 以及窗口上那几个 `onXxx(` 事件接口 —— 它们返回的是同一种退订函数 + */ +function tauriEventMisuse(code: string): string[] { + const out: string[] = []; + const values = code.replace(/import\s+type\s[^;]*?from\s*["']@tauri-apps\/api\/event["']/g, ""); + if (/["']@tauri-apps\/api\/event["']/.test(values)) out.push("@tauri-apps/api/event"); + if (/\.(listen|once)\s*(<[^>()]*>)?\s*\(/.test(code)) out.push(".listen( / .once("); + if (/\bon(Resized|Moved|CloseRequested|FocusChanged|ScaleChanged|ThemeChanged|DragDropEvent)\s*\(/.test(code)) { + out.push("window onXxx("); + } + return out; +} + describe("源码文件", () => { /** * **下拉框里的占位值曾经是一个字面的 NUL 字节。**编译和运行都没问题, @@ -31,4 +50,35 @@ describe("源码文件", () => { const bad = sources("src").filter((f) => hasControlChar(readFileSync(f, "utf8"))); expect(bad).toEqual([]); }); + + /** + * **Tauri 的事件一律经 `@/lib/tauriEvent` 订阅。**直接用 `listen` 退订,会撞上 + * Tauri 还没在页面里登记好监听的那个空档:抛出没人接的 rejection(换页时一次 + * 几十个),监听也没退掉,卸掉的组件照样收事件。为什么、怎么避开见那个文件。 + * 测试文件不管:它们要 mock 这个模块。 + */ + it("Tauri 事件只经 @/lib/tauriEvent 订阅", () => { + const bad = sources("src") + .filter((f) => /[.]tsx?$/.test(f) && !/[.]test[.]tsx?$/.test(f) && f !== TAURI_EVENT) + .flatMap((f) => tauriEventMisuse(readFileSync(f, "utf8")).map((why) => `${f}: ${why}`)); + expect(bad).toEqual([]); + }); + + /** 上面那条靠的几个正则真的认得出来,不是因为认不出才通过 */ + it("认得出绕过 @/lib/tauriEvent 的写法", () => { + expect(tauriEventMisuse('import { listen } from "@tauri-apps/api/event";')).toEqual(["@tauri-apps/api/event"]); + expect(tauriEventMisuse("import { once as o } from '@tauri-apps/api/event'")).toEqual(["@tauri-apps/api/event"]); + expect(tauriEventMisuse('const { listen } = await import("@tauri-apps/api/event");')).toEqual([ + "@tauri-apps/api/event", + ]); + expect(tauriEventMisuse('getCurrentWindow().listen("tauri://focus", f)')).toEqual([".listen( / .once("]); + expect(tauriEventMisuse("await win.once('x', f)")).toEqual([".listen( / .once("]); + expect(tauriEventMisuse("void getCurrentWindow().onFocusChanged(({ payload }) => f(payload))")).toEqual([ + "window onXxx(", + ]); + // 只拿类型、经 helper 订阅、配置里的 `listen.port` 都不算 + expect(tauriEventMisuse('import type { Event } from "@tauri-apps/api/event";')).toEqual([]); + expect(tauriEventMisuse('const un = subscribe("core-state", (e) => f(e.payload));')).toEqual([]); + expect(tauriEventMisuse("const port = ov.listen.port;")).toEqual([]); + }); }); diff --git a/src/ui/README.md b/src/ui/README.md index db447571..8f0575eb 100644 --- a/src/ui/README.md +++ b/src/ui/README.md @@ -241,6 +241,11 @@ r.mutate(next | (prev) => next, { revalidate? }) // optimistic update; returns the last change is never answered with data from before it. - `events`: core / local event kinds that make the data stale (throttled 2.5s, like `useCoreEvent`). Prefer this over polling — never add `setInterval`. +- Any other event pushed from Rust (`language-changed`, `update-found`, …): + `useTauriEvent(name, handler)`, or `subscribe()` inside an effect that needs the + unsubscribe or `.ready` — both from `@/lib/tauriEvent`. Never `listen` from + `@tauri-apps/api/event` directly: unlistening right after it resolves throws and can + leave the listener registered (`src/source.test.ts` fails on it). - `deps`: values that change the answer (config version, a filter). The old data stays on screen while the new one loads. - `invalidate("keys")` / `invalidate("upstream:")` (prefix) from anywhere after a diff --git a/src/upstreams/ChatgptLoginDialog.tsx b/src/upstreams/ChatgptLoginDialog.tsx index f09d2911..9a059875 100644 --- a/src/upstreams/ChatgptLoginDialog.tsx +++ b/src/upstreams/ChatgptLoginDialog.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { CopyIcon, ExternalLinkIcon, SmartphoneIcon } from "lucide-react"; -import { listen } from "@tauri-apps/api/event"; +import { subscribe } from "@/lib/tauriEvent"; import { Banner } from "@/ui/banner"; import { Button } from "@/ui/button"; import { Checkbox } from "@/ui/checkbox"; @@ -114,7 +114,7 @@ export function ChatgptLoginDialog({ // 结果由 core 发事件,不必一直问;问一遍是为了事件漏掉时也能收尾 useEffect(() => { if (!waiting) return; - const un = listen("core-event", (e) => { + const un = subscribe("core-event", (e) => { const ev = e.payload; if (ev.kind !== "login_finished" || ev.login !== waiting) return; // 事件只报结果,不带登上的是哪个账号:那一项只在这次登录的状态里。立刻问一次, @@ -137,7 +137,7 @@ export function ChatgptLoginDialog({ }); }, POLL_MS); return () => { - void un.then((f) => f()); + un(); clearInterval(timer); }; // settle 每次渲染都是新的,但订阅只该跟着这次登录重建 diff --git a/src/upstreams/ZaiLoginDialog.tsx b/src/upstreams/ZaiLoginDialog.tsx index 4f723329..1f9a4d10 100644 --- a/src/upstreams/ZaiLoginDialog.tsx +++ b/src/upstreams/ZaiLoginDialog.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { useSystemProxyLabel } from "@/connection/Remote"; import { ExternalLinkIcon } from "lucide-react"; -import { listen } from "@tauri-apps/api/event"; +import { subscribe } from "@/lib/tauriEvent"; import { Banner } from "@/ui/banner"; import { Button } from "@/ui/button"; import { Checkbox } from "@/ui/checkbox"; @@ -95,7 +95,7 @@ export function ZaiLoginDialog({ // 结果由 core 发事件,不必一直问;问一遍是为了事件漏掉时也能收尾 useEffect(() => { if (!waiting) return; - const un = listen("core-event", (e) => { + const un = subscribe("core-event", (e) => { const ev = e.payload; if (ev.kind !== "login_finished" || ev.login !== waiting) return; // 事件只报结果,不带登上的是哪个账号:那一项只在这次登录的状态里。立刻问一次, @@ -118,7 +118,7 @@ export function ZaiLoginDialog({ }); }, POLL_MS); return () => { - void un.then((f) => f()); + un(); clearInterval(timer); }; // settle 每次渲染都是新的,但订阅只该跟着这次登录重建 diff --git a/src/upstreams/data.ts b/src/upstreams/data.ts index d1430e19..23de8ed7 100644 --- a/src/upstreams/data.ts +++ b/src/upstreams/data.ts @@ -5,7 +5,7 @@ * 跟着事件流走的一份现状,不缓存。 */ import { useEffect, useMemo, useRef, useState } from "react"; -import { listen } from "@tauri-apps/api/event"; +import { subscribe } from "@/lib/tauriEvent"; import { bucketStart } from "@/format"; import { useResource, type Resource } from "@/lib/resource"; import type { CoreEvent, CostBucketGroup, ProviderView } from "@/types"; @@ -45,7 +45,7 @@ export function useUpstreamStats(): { stats: Resource; since: num const { mutate, reload } = stats; useEffect(() => { - const un = listen("core-event", (e) => { + const un = subscribe("core-event", (e) => { const ev = e.payload; if (ev.kind !== "quota_seen") return; mutate( @@ -59,7 +59,7 @@ export function useUpstreamStats(): { stats: Resource; since: num })), ); }); - return () => void un.then((f) => f()); + return un; }, [mutate]); useEffect(() => { @@ -217,7 +217,7 @@ export function useInFlight(): ReadonlyMap { if (oldest !== undefined) ended.current.delete(oldest); } }; - const unEvents = listen("core-event", (e) => { + const unEvents = subscribe("core-event", (e) => { const ev = e.payload; switch (ev.kind) { case "request_started": @@ -236,7 +236,7 @@ export function useInFlight(): ReadonlyMap { } bump(); }); - const unState = listen("core-state", (e) => { + const unState = subscribe("core-state", (e) => { live.current.clear(); bump(); if (e.payload.startsWith("running:")) seed(); @@ -244,8 +244,8 @@ export function useInFlight(): ReadonlyMap { seed(); return () => { alive = false; - void unEvents.then((f) => f()); - void unState.then((f) => f()); + unEvents(); + unState(); }; }, []); diff --git a/src/useCoreEvent.ts b/src/useCoreEvent.ts index 54d10054..799b177e 100644 --- a/src/useCoreEvent.ts +++ b/src/useCoreEvent.ts @@ -1,5 +1,5 @@ import { useEffect, useRef } from "react"; -import { listen } from "@tauri-apps/api/event"; +import { subscribe } from "@/lib/tauriEvent"; import { LOCAL_KINDS, type CoreEvent, type LocalEvent } from "./types"; /** @@ -46,14 +46,14 @@ export function useCoreEvent( }, throttleMs); }; const local = new Set(LOCAL_KINDS); - const unCore = listen("core-event", (e) => { + const unCore = subscribe("core-event", (e) => { // 这台机器上的事只认 `local-event` 那一路:旧版 core 自己也发同名的两种 if (!local.has(e.payload.kind)) soon(e.payload.kind); }); - const unLocal = listen("local-event", (e) => soon(e.payload.kind)); + const unLocal = subscribe("local-event", (e) => soon(e.payload.kind)); return () => { - void unCore.then((f) => f()); - void unLocal.then((f) => f()); + unCore(); + unLocal(); if (timer) clearTimeout(timer); }; }, [want, throttleMs]); diff --git a/src/useRequests.ts b/src/useRequests.ts index c1fc0374..efb6c04f 100644 --- a/src/useRequests.ts +++ b/src/useRequests.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { listen } from "@tauri-apps/api/event"; +import { subscribe } from "@/lib/tauriEvent"; import { call } from "@/control"; import { applyEvent, @@ -459,7 +459,7 @@ export function useRequests(ready: boolean) { 那一刻记,不等下一帧落地。 */ resetCoreClock(); - const un = listen("core-event", (e) => { + const un = subscribe("core-event", (e) => { const ev = e.payload; if (ev.kind === "request_started") noteCoreTime(ev.at_ms, Date.now()); if (since) { @@ -489,7 +489,7 @@ export function useRequests(ready: boolean) { try { // **等订阅真的挂上再问**:`listen` 是异步注册的,先问的话,快照和 // 订阅之间结束的请求,结局谁都没收到 - await un; + await un.ready; const sentAt = Date.now(); const open = await call("InFlight", null); const gotAt = Date.now(); @@ -509,7 +509,7 @@ export function useRequests(ready: boolean) { 再也等不到结局(见 `interruptInFlight`)。回来的时候(`running:`) 再对一次账,补上重连之前就开始了的。 */ - const unState = listen("core-state", (e) => { + const unState = subscribe("core-state", (e) => { if (e.payload.startsWith("running")) { void resync(); return; @@ -526,7 +526,7 @@ export function useRequests(ready: boolean) { 客户端配置里新出现的可疑内容。**这台机器上的文件监视说的,不是 core**: 连着哪个 core 都一样。MCP 页有新发现时直接落在「发现」上要用它。 */ - const unLocal = listen("local-event", (e) => { + const unLocal = subscribe("local-event", (e) => { const ev = e.payload; if (ev.kind === "scan_alert") setAlerts((prev) => [...ev.alerts, ...prev].slice(0, 50)); }); @@ -535,9 +535,9 @@ export function useRequests(ready: boolean) { // 但显式断掉能省下事件堆积。 return () => { alive = false; - un.then((f) => f()); - void unState.then((f) => f()); - void unLocal.then((f) => f()); + un(); + unState(); + unLocal(); if (frame.current !== null) cancelAnimationFrame(frame.current); if (settle.current) clearTimeout(settle.current); };