From c5749b0c17a1dfc37aa36974c516f7208b6fce8b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:51:41 +0800 Subject: [PATCH] fix(settings): open the Settings window's content even when a startup step fails; log frontend errors (#1110) The native Settings window showed only its title bar until its startup chain finished; a page that failed to register ended the chain silently. The steps are now guarded and openSettings runs in finally. Uncaught errors and unhandled rejections of every window (and caught errors via logError) are written to the log file through log_frontend, deduplicated and capped per window. --- .../__tests__/settingsWindowOpen.test.ts | 48 +++++++++++++++++ src/features/settings/index.ts | 23 +++++--- src/main.tsx | 2 + src/platform/errorLog.test.ts | 35 ++++++++++++ src/platform/errorLog.ts | 54 +++++++++++++++++++ 5 files changed, 154 insertions(+), 8 deletions(-) create mode 100644 src/features/settings/__tests__/settingsWindowOpen.test.ts create mode 100644 src/platform/errorLog.test.ts create mode 100644 src/platform/errorLog.ts 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); + }; +}