diff --git a/src/features/settings/__tests__/settingsWindowOpen.test.ts b/src/features/settings/__tests__/settingsWindowOpen.test.ts new file mode 100644 index 000000000..f1ed12a2e --- /dev/null +++ b/src/features/settings/__tests__/settingsWindowOpen.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; + +const logFrontend = vi.hoisted(() => vi.fn(() => Promise.resolve())); +vi.mock("@tauri-apps/api/webviewWindow", () => ({ + getCurrentWebviewWindow: () => ({ + label: "settings-main", + listen: () => Promise.resolve(() => {}), + close: () => Promise.resolve(), + setTitle: () => Promise.resolve(), + }), +})); +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + const ok = (data: unknown) => Promise.resolve({ status: "ok", data }); + return { + ...real, + isTauri: () => true, + commands: { + ...real.commands, + logFrontend, + remoteCurrent: () => ok(null), + projectCurrent: () => ok(null), + settingsGetGlobal: () => ok({}), + windowTakeLaunch: () => Promise.resolve(null), + }, + }; +}); +vi.mock("../dialog/LazySettingsDialog", () => ({ + LazySettingsDialog: () => null, + loadBuiltinPages: () => Promise.reject(new Error("page failed")), + unloadBuiltinPages: () => {}, +})); + +const { activateSettingsWindow } = await import(".."); +const { useSettingsDialog } = await import("../model/staging"); + +describe("the native Settings window", () => { + it("opens even when a page fails to register, and logs why", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const dispose = activateSettingsWindow("main"); + await vi.waitFor(() => expect(useSettingsDialog.getState().open).toBe(true)); + expect(logFrontend).toHaveBeenCalledWith( + "error", + expect.stringContaining("cannot register settings pages: Error: page failed"), + ); + dispose(); + }); +}); diff --git a/src/features/settings/index.ts b/src/features/settings/index.ts index 8391dad44..802a17da2 100644 --- a/src/features/settings/index.ts +++ b/src/features/settings/index.ts @@ -16,6 +16,7 @@ import { registerActions, type Action } from "@/platform/actions"; import { call, commands, isTauri } from "@/platform/ipc"; import { isPopupOpen, lazyComponent } from "@/ui"; import { loadSettings, useProject } from "@/platform/project"; +import { logError } from "@/platform/errorLog"; import { restoreRemote } from "@/platform/remote"; import { provideSettingsSearchIndex, registerOverlay } from "@/workbench/contributions"; import { windowBackgroundHex } from "@/workbench/theme"; @@ -206,14 +207,20 @@ export function activateSettingsWindow(parent: string): () => void { }), ]; void (async () => { - await loadBuiltinPages(); - await restoreRemote(); - const project = await commands.projectCurrent().catch(() => null); - if (project) useProject.setState({ project, settings: project.settings }); - else await loadSettings().catch((e) => console.warn("cannot load settings", e)); - // `listenLaunch` may have taken the launch first and applied it already. - const launch = await takeLaunch(); - openSettings(launch?.page, { newProjects: launch?.newProjects ?? useSettingsDialog.getState().newProjects }); + let launch: SettingsLaunch | null = null; + try { + // A page that fails to register is reported; the window still opens with the others. + await loadBuiltinPages().catch((e) => logError("cannot register settings pages", e)); + await restoreRemote(); + const project = await commands.projectCurrent().catch(() => null); + if (project) useProject.setState({ project, settings: project.settings }); + else await loadSettings().catch((e) => console.warn("cannot load settings", e)); + // `listenLaunch` may have taken the launch first and applied it already. + launch = await takeLaunch(); + } finally { + // Without this the window would stay an empty title bar. + openSettings(launch?.page, { newProjects: launch?.newProjects ?? useSettingsDialog.getState().newProjects }); + } })(); return () => disposers.reverse().forEach((d) => d()); } diff --git a/src/main.tsx b/src/main.tsx index 657b4a5d2..31825a44c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,6 +5,7 @@ import { Workbench } from "./workbench/Workbench"; import { activateSecondaryWindow, activateWorkbench } from "./workbench/activate"; import { activateFeatures, activateSettingsWindowFeatures, SettingsWindow, settingsWindowParent } from "./features"; import { installRepeatedDoubleClick } from "./platform/doubleClick"; +import { installErrorLogging } from "./platform/errorLog"; import { installKeyboardDispatcher } from "./platform/keymap"; import { isTauri } from "./platform/ipc"; import { hideSplash } from "./platform/splash"; @@ -18,6 +19,7 @@ if (import.meta.env.DEV && isTauri()) { } installRepeatedDoubleClick(); +installErrorLogging(); // The native Settings window (`settings-`) loads this page too. const settingsParent = settingsWindowParent(); diff --git a/src/platform/errorLog.test.ts b/src/platform/errorLog.test.ts new file mode 100644 index 000000000..db05462f4 --- /dev/null +++ b/src/platform/errorLog.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const logFrontend = vi.hoisted(() => vi.fn(() => Promise.resolve())); +vi.mock("@tauri-apps/api/webviewWindow", () => ({ getCurrentWebviewWindow: () => ({ label: "project-2" }) })); +vi.mock("./ipc", () => ({ isTauri: () => true, commands: { logFrontend } })); + +import { describeError, installErrorLogging } from "./errorLog"; + +afterEach(() => logFrontend.mockClear()); + +describe("frontend errors in the log", () => { + it("writes uncaught errors and unhandled rejections once each, with the window", () => { + const off = installErrorLogging(); + const boom = new Error("boom"); + window.dispatchEvent(new ErrorEvent("error", { error: boom, message: "boom" })); + window.dispatchEvent(new ErrorEvent("error", { error: boom, message: "boom" })); + const rejection = new Event("unhandledrejection") as PromiseRejectionEvent; + Object.assign(rejection, { reason: "no project" }); + window.dispatchEvent(rejection); + off(); + const late = new Event("unhandledrejection") as PromiseRejectionEvent; + Object.assign(late, { reason: "after" }); + window.dispatchEvent(late); + expect(logFrontend.mock.calls.map((c) => c as unknown[])).toEqual([ + ["error", expect.stringMatching(/^\[project-2\] uncaught error: Error: boom\n\s+at /)], + ["error", "[project-2] unhandled rejection: no project"], + ]); + }); + + it("describes errors, strings and other values", () => { + expect(describeError("x")).toBe("x"); + expect(describeError({ kind: "notFound" })).toBe('{"kind":"notFound"}'); + expect(describeError(undefined)).toBe("undefined"); + }); +}); diff --git a/src/platform/errorLog.ts b/src/platform/errorLog.ts new file mode 100644 index 000000000..241dc0484 --- /dev/null +++ b/src/platform/errorLog.ts @@ -0,0 +1,54 @@ +/** + * Uncaught errors and unhandled promise rejections of this window go to the log file + * (target `frontend`), so the log attached to a report shows what failed in the UI. + */ +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; +import { commands, isTauri } from "./ipc"; + +/** Per window session: a loop of the same failure must not flood the log. */ +const MAX_REPORTS = 50; + +export function describeError(reason: unknown): string { + if (reason instanceof Error) + return reason.stack?.includes(reason.message) ? reason.stack : `${reason}\n${reason.stack ?? ""}`; + if (typeof reason === "string") return reason; + try { + return JSON.stringify(reason) ?? String(reason); + } catch { + return String(reason); + } +} + +/** A caught error worth keeping: shown in the console and written to the log file. */ +export function logError(context: string, reason: unknown): void { + console.error(context, reason); + if (isTauri()) void commands.logFrontend("error", `${context}: ${describeError(reason).trim()}`).catch(() => {}); +} + +/** Installs the handlers; returns their disposer. Outside the desktop app it does nothing. */ +export function installErrorLogging(): () => void { + if (!isTauri()) return () => {}; + let label = "?"; + try { + label = getCurrentWebviewWindow().label; + } catch { + /* no window: keep "?" */ + } + let sent = 0; + let last = ""; + const report = (kind: string, reason: unknown) => { + const message = `[${label}] ${kind}: ${describeError(reason).trim()}`; + if (message === last || sent >= MAX_REPORTS) return; + last = message; + sent++; + void commands.logFrontend("error", message).catch(() => {}); + }; + const onError = (e: ErrorEvent) => report("uncaught error", e.error ?? e.message); + const onRejection = (e: PromiseRejectionEvent) => report("unhandled rejection", e.reason); + window.addEventListener("error", onError); + window.addEventListener("unhandledrejection", onRejection); + return () => { + window.removeEventListener("error", onError); + window.removeEventListener("unhandledrejection", onRejection); + }; +}