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.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-04 06:51:41 +08:00
1 джерело 4d224f80c9
коміт c5749b0c17
5 files changed
+154 -8

No files matched your search

+48
Переглянути файл
@@ -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<typeof import("@/platform/ipc")>();
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();
});
});
+15 -8
Переглянути файл
@@ -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());
}
+2
Переглянути файл
@@ -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-<parent>`) loads this page too.
const settingsParent = settingsWindowParent();
+35
Переглянути файл
@@ -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");
});
});
+54
Переглянути файл
@@ -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);
};
}