mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-22 15:09:22 +02:00
Compare commits
6 Commits
agent/lamb
...
feat/clien
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2241b30a18 | ||
|
|
e7dabd0cba | ||
|
|
075765c460 | ||
|
|
443c6b7d8c | ||
|
|
dd0886480e | ||
|
|
e1e73db59a |
90
apps/desktop/src/main/freeze-breadcrumb.test.ts
Normal file
90
apps/desktop/src/main/freeze-breadcrumb.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
writeFreezeBreadcrumb,
|
||||
readAndClearFreezeBreadcrumb,
|
||||
clearFreezeBreadcrumb,
|
||||
type FreezeBreadcrumb,
|
||||
} from "./freeze-breadcrumb";
|
||||
|
||||
// Each test gets its own temp dir so the on-disk breadcrumb is isolated.
|
||||
const dirs: string[] = [];
|
||||
function tempFile(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "freeze-breadcrumb-"));
|
||||
dirs.push(dir);
|
||||
return join(dir, "last-client-failure.json");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const sample: FreezeBreadcrumb = {
|
||||
kind: "unresponsive",
|
||||
context: { desktopRoute: { path: "/acme/issues" } },
|
||||
ts: 1_700_000_000_000,
|
||||
version: "0.3.1",
|
||||
};
|
||||
|
||||
describe("freeze breadcrumb round-trip", () => {
|
||||
it("writes then reads back the breadcrumb", () => {
|
||||
const file = tempFile();
|
||||
writeFreezeBreadcrumb(file, sample);
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toEqual(sample);
|
||||
});
|
||||
|
||||
it("read clears the file so a failure reports exactly once", () => {
|
||||
const file = tempFile();
|
||||
writeFreezeBreadcrumb(file, sample);
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toEqual(sample);
|
||||
expect(existsSync(file)).toBe(false);
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
|
||||
it("clearFreezeBreadcrumb removes a pending breadcrumb (hang recovered)", () => {
|
||||
const file = tempFile();
|
||||
writeFreezeBreadcrumb(file, sample);
|
||||
clearFreezeBreadcrumb(file);
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// The breadcrumb crosses a process boundary (main writes, renderer flushes via
|
||||
// IPC) and lives across app versions — a future write shape or a corrupt file
|
||||
// must never throw into boot. CLAUDE.md "API Response Compatibility".
|
||||
describe("freeze breadcrumb defends against malformed input", () => {
|
||||
it("returns null when no file exists", () => {
|
||||
expect(readAndClearFreezeBreadcrumb(tempFile())).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on corrupt JSON", () => {
|
||||
const file = tempFile();
|
||||
writeFileSync(file, "{ not valid json", "utf8");
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when `kind` is missing", () => {
|
||||
const file = tempFile();
|
||||
writeFileSync(file, JSON.stringify({ ts: 1, version: "x" }), "utf8");
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when `kind` is the wrong type", () => {
|
||||
const file = tempFile();
|
||||
writeFileSync(file, JSON.stringify({ kind: 42, context: {} }), "utf8");
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on a JSON null payload", () => {
|
||||
const file = tempFile();
|
||||
writeFileSync(file, "null", "utf8");
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
|
||||
it("clearing a non-existent file is a no-op, never throws", () => {
|
||||
expect(() => clearFreezeBreadcrumb(tempFile())).not.toThrow();
|
||||
});
|
||||
});
|
||||
76
apps/desktop/src/main/freeze-breadcrumb.ts
Normal file
76
apps/desktop/src/main/freeze-breadcrumb.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { writeFileSync, readFileSync, rmSync } from "node:fs";
|
||||
import type { FreezeBreadcrumb } from "../shared/freeze-breadcrumb";
|
||||
|
||||
// When the renderer truly hangs or its process dies, it can't send telemetry
|
||||
// itself — the thread is blocked or gone. The main process (always alive) is
|
||||
// the only watcher that can react, but during the hang it can't reach the
|
||||
// renderer's posthog-js either. So it writes a breadcrumb to disk; the next
|
||||
// time a renderer boots, it reads + clears the file and reports the event.
|
||||
// This survives even a force-quit, which is the whole point.
|
||||
|
||||
export type { FreezeBreadcrumb };
|
||||
|
||||
/**
|
||||
* Best-effort write. A breadcrumb we can't persist is lost, never fatal.
|
||||
*
|
||||
* Known limitation: this is a single slot — last write wins. Multiple failures
|
||||
* within one session collapse to the last one, so per-session failure counts
|
||||
* are undercounted. Acceptable for now: telemetry aggregates presence and
|
||||
* frequency across users, not exhaustive per-session sequences. Upgrade to an
|
||||
* append/ring buffer if per-session failure chains become a question.
|
||||
*/
|
||||
export function writeFreezeBreadcrumb(filePath: string, breadcrumb: FreezeBreadcrumb): void {
|
||||
try {
|
||||
writeFileSync(filePath, JSON.stringify(breadcrumb), "utf8");
|
||||
} catch {
|
||||
// Disk full / permissions — drop silently.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a persisted breadcrumb. Called when the renderer recovers from a hang
|
||||
* (a `responsive` event after `unresponsive`): the breadcrumb was written
|
||||
* pre-emptively while the thread was stuck, but since it came back, the
|
||||
* in-thread long-task watchdog already reports it — keeping the breadcrumb
|
||||
* would double-count it AND mislabel a recovered window as `recovered: false`.
|
||||
* Best-effort; a stale breadcrumb only costs one duplicate report.
|
||||
*/
|
||||
export function clearFreezeBreadcrumb(filePath: string): void {
|
||||
try {
|
||||
rmSync(filePath, { force: true });
|
||||
} catch {
|
||||
// Nothing to clear / permissions — ignore.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the breadcrumb and delete it in the same call, so a failure is reported
|
||||
* exactly once. Returns null when there's no breadcrumb (the normal case) or
|
||||
* when the file is unreadable / corrupt.
|
||||
*/
|
||||
export function readAndClearFreezeBreadcrumb(filePath: string): FreezeBreadcrumb | null {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(filePath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
rmSync(filePath, { force: true });
|
||||
} catch {
|
||||
// If we can't delete it we'd re-report next launch; acceptable over throwing.
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
typeof (parsed as FreezeBreadcrumb).kind === "string"
|
||||
) {
|
||||
return parsed as FreezeBreadcrumb;
|
||||
}
|
||||
} catch {
|
||||
// Corrupt JSON — drop.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -23,6 +23,11 @@ import {
|
||||
installRendererRecoveryHandlers,
|
||||
type RendererRecoveryWindow,
|
||||
} from "./renderer-recovery";
|
||||
import {
|
||||
writeFreezeBreadcrumb,
|
||||
readAndClearFreezeBreadcrumb,
|
||||
clearFreezeBreadcrumb,
|
||||
} from "./freeze-breadcrumb";
|
||||
|
||||
// Bundled icon used for dock/taskbar branding. macOS/Windows production
|
||||
// builds let the OS pick up the icon from the .app bundle / .exe resources,
|
||||
@@ -66,6 +71,13 @@ if (process.platform !== "win32") {
|
||||
|
||||
const PROTOCOL = "multica";
|
||||
|
||||
// Where the main process parks a freeze/crash breadcrumb until the next
|
||||
// renderer boot flushes it to telemetry. Lives in userData so it survives a
|
||||
// force-quit. Resolved lazily — app.getPath is only valid after `ready`.
|
||||
function freezeBreadcrumbPath(): string {
|
||||
return join(app.getPath("userData"), "last-client-failure.json");
|
||||
}
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let latestRendererRouteContext: RendererRouteContext | null = null;
|
||||
let runtimeConfigResult: RuntimeConfigResult = {
|
||||
@@ -275,6 +287,21 @@ function createWindow(): void {
|
||||
? { desktopRoute: latestRendererRouteContext }
|
||||
: {}),
|
||||
}),
|
||||
// Only persist in production: a true hang/crash can't report itself, so we
|
||||
// write a breadcrumb and the next renderer boot flushes it to PostHog. Dev
|
||||
// is excluded to keep field telemetry clean.
|
||||
persistBreadcrumb: is.dev
|
||||
? undefined
|
||||
: (payload) =>
|
||||
writeFreezeBreadcrumb(freezeBreadcrumbPath(), {
|
||||
kind: payload.kind,
|
||||
context: payload.context,
|
||||
ts: Date.now(),
|
||||
version: getAppVersion(),
|
||||
}),
|
||||
clearBreadcrumb: is.dev
|
||||
? undefined
|
||||
: () => clearFreezeBreadcrumb(freezeBreadcrumbPath()),
|
||||
});
|
||||
|
||||
installContextMenu(window.webContents);
|
||||
@@ -413,6 +440,14 @@ if (!gotTheLock) {
|
||||
event.returnValue = { version: getAppVersion(), os };
|
||||
});
|
||||
|
||||
// Sync IPC: read + clear any freeze/crash breadcrumb left by a previous
|
||||
// session. The renderer flushes it to telemetry on boot (it couldn't be
|
||||
// reported when it happened — the renderer was hung or gone). Read-and-
|
||||
// clear so a failure reports exactly once.
|
||||
ipcMain.on("freeze:get-last", (event) => {
|
||||
event.returnValue = readAndClearFreezeBreadcrumb(freezeBreadcrumbPath());
|
||||
});
|
||||
|
||||
// Sync IPC: preload exposes the validated runtime config before renderer
|
||||
// boot. If desktop.json exists but is invalid, renderer receives the
|
||||
// blocking error and must not silently fall back to the cloud defaults.
|
||||
|
||||
@@ -175,3 +175,97 @@ describe("installRendererRecoveryHandlers", () => {
|
||||
expect(detail).toContain("Diagnostic details:\nkind: unresponsive\ncontext: {}");
|
||||
});
|
||||
});
|
||||
|
||||
describe("freeze/crash breadcrumb state machine", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
function install(fixture: ReturnType<typeof makeWindow>) {
|
||||
const persistBreadcrumb = vi.fn();
|
||||
const clearBreadcrumb = vi.fn();
|
||||
installRendererRecoveryHandlers(fixture.window, {
|
||||
isDev: false,
|
||||
showReloadPrompt: vi.fn(async () => "dismiss" as const),
|
||||
persistBreadcrumb,
|
||||
clearBreadcrumb,
|
||||
unresponsivePromptDelayMs: 100,
|
||||
});
|
||||
return { persistBreadcrumb, clearBreadcrumb };
|
||||
}
|
||||
|
||||
it("a sustained hang writes exactly one unresponsive breadcrumb", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
|
||||
expect(persistBreadcrumb).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "unresponsive" }),
|
||||
);
|
||||
expect(clearBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recovering after a written breadcrumb clears it (no double-count, no false recovered:false)", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
|
||||
|
||||
fixture.windowHandlers.get("responsive")?.();
|
||||
expect(clearBreadcrumb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("recovering before the delay never writes a breadcrumb, so nothing to clear", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
fixture.windowHandlers.get("responsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(persistBreadcrumb).not.toHaveBeenCalled();
|
||||
expect(clearBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a hang that never recovers (force-quit) keeps its breadcrumb for next-boot reporting", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// No "responsive" ever fires — the breadcrumb must survive uncleared.
|
||||
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
|
||||
expect(clearBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a recoverable crash writes a breadcrumb and never clears it (a dead process never recovers)", () => {
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "crashed" });
|
||||
|
||||
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
|
||||
expect(persistBreadcrumb).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "render-process-gone" }),
|
||||
);
|
||||
expect(clearBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a clean (non-crash) renderer exit writes no breadcrumb", () => {
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "clean-exit" });
|
||||
|
||||
expect(persistBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,21 @@ type RendererRecoveryOptions = {
|
||||
isDev: boolean;
|
||||
showReloadPrompt: (payload: ReloadPromptPayload) => Promise<ReloadPromptResult>;
|
||||
getDiagnosticContext?: () => Record<string, unknown>;
|
||||
/**
|
||||
* Persist a freeze/crash breadcrumb to disk. The renderer can't report a
|
||||
* true hang or process death itself (blocked / gone), so the main process
|
||||
* writes it here and the next renderer boot flushes it to telemetry. Omit
|
||||
* in dev to keep field telemetry clean.
|
||||
*/
|
||||
persistBreadcrumb?: (payload: ReloadPromptPayload) => void;
|
||||
/**
|
||||
* Delete a previously-persisted unresponsive breadcrumb. Called when the
|
||||
* renderer recovers (`responsive` after `unresponsive`): the window came
|
||||
* back, so the in-thread watchdog reports the freeze and the breadcrumb
|
||||
* would only double-count it. Crash breadcrumbs are never cleared — a dead
|
||||
* process never recovers.
|
||||
*/
|
||||
clearBreadcrumb?: () => void;
|
||||
log?: (tag: string, ...args: unknown[]) => void;
|
||||
unresponsivePromptDelayMs?: number;
|
||||
};
|
||||
@@ -28,11 +43,16 @@ export function installRendererRecoveryHandlers(
|
||||
isDev,
|
||||
showReloadPrompt,
|
||||
getDiagnosticContext,
|
||||
persistBreadcrumb,
|
||||
clearBreadcrumb,
|
||||
log = defaultDevLog,
|
||||
unresponsivePromptDelayMs = 1500,
|
||||
}: RendererRecoveryOptions,
|
||||
) {
|
||||
let unresponsivePromptTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// True once a breadcrumb has been written for the current hang. A later
|
||||
// `responsive` clears it; only a hang that never returns survives to report.
|
||||
let unresponsiveBreadcrumbWritten = false;
|
||||
const mergeDiagnosticContext = (context: Record<string, unknown>) => ({
|
||||
...readDiagnosticContext(getDiagnosticContext),
|
||||
...context,
|
||||
@@ -49,12 +69,18 @@ export function installRendererRecoveryHandlers(
|
||||
window.webContents.on("render-process-gone", (_event, details) => {
|
||||
if (isDev) log("process-gone", JSON.stringify(details));
|
||||
if (!isRecoverableRendererExit(details)) return;
|
||||
maybePromptReload({
|
||||
const payload: ReloadPromptPayload = {
|
||||
kind: "render-process-gone",
|
||||
context: mergeDiagnosticContext({ details }),
|
||||
});
|
||||
};
|
||||
persistBreadcrumb?.(payload);
|
||||
maybePromptReload(payload);
|
||||
});
|
||||
|
||||
// preload-error intentionally does NOT persist a breadcrumb: it's a startup
|
||||
// failure of the preload script itself, and the breadcrumb-flush path depends
|
||||
// on that same preload exposing `getLastFreeze` — if preload is broken, the
|
||||
// next boot couldn't read it back anyway. We only prompt for reload here.
|
||||
window.webContents.on("preload-error", (_event, preloadPath, error) => {
|
||||
if (isDev) log("preload-error", `path=${preloadPath} err=${formatError(error)}`);
|
||||
maybePromptReload({
|
||||
@@ -67,17 +93,27 @@ export function installRendererRecoveryHandlers(
|
||||
if (isDev || unresponsivePromptTimer) return;
|
||||
unresponsivePromptTimer = setTimeout(() => {
|
||||
unresponsivePromptTimer = null;
|
||||
maybePromptReload({
|
||||
const payload: ReloadPromptPayload = {
|
||||
kind: "unresponsive",
|
||||
context: mergeDiagnosticContext({}),
|
||||
});
|
||||
};
|
||||
persistBreadcrumb?.(payload);
|
||||
unresponsiveBreadcrumbWritten = true;
|
||||
maybePromptReload(payload);
|
||||
}, unresponsivePromptDelayMs);
|
||||
});
|
||||
|
||||
window.on("responsive", () => {
|
||||
if (!unresponsivePromptTimer) return;
|
||||
clearTimeout(unresponsivePromptTimer);
|
||||
unresponsivePromptTimer = null;
|
||||
if (unresponsivePromptTimer) {
|
||||
clearTimeout(unresponsivePromptTimer);
|
||||
unresponsivePromptTimer = null;
|
||||
}
|
||||
// The window came back: drop any breadcrumb written during this hang so it
|
||||
// isn't re-reported (and mislabeled `recovered: false`) on next boot.
|
||||
if (unresponsiveBreadcrumbWritten) {
|
||||
clearBreadcrumb?.();
|
||||
unresponsiveBreadcrumbWritten = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
4
apps/desktop/src/preload/index.d.ts
vendored
4
apps/desktop/src/preload/index.d.ts
vendored
@@ -2,6 +2,7 @@ import { ElectronAPI } from "@electron-toolkit/preload";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
import type { NavigationGesture } from "../shared/navigation-gestures";
|
||||
import type { RendererRouteContextInput } from "../shared/renderer-route-context";
|
||||
import type { FreezeBreadcrumb } from "../shared/freeze-breadcrumb";
|
||||
|
||||
interface DesktopAPI {
|
||||
/** App version + normalized OS, captured synchronously at preload time. */
|
||||
@@ -15,6 +16,9 @@ interface DesktopAPI {
|
||||
onSystemLocaleChanged: (callback: (locale: string) => void) => () => void;
|
||||
/** Validated runtime endpoint config, or a blocking config error. */
|
||||
runtimeConfig: RuntimeConfigResult;
|
||||
/** Read + clear any freeze/crash breadcrumb from a previous session, so the
|
||||
* renderer can flush it to telemetry on boot. Null when nothing's pending. */
|
||||
getLastFreeze: () => FreezeBreadcrumb | null;
|
||||
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
|
||||
onAuthToken: (callback: (token: string) => void) => () => void;
|
||||
/** Listen for invitation IDs delivered via deep link. Returns an unsubscribe function. */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import { electronAPI } from "@electron-toolkit/preload";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
import type { FreezeBreadcrumb } from "../shared/freeze-breadcrumb";
|
||||
import {
|
||||
RENDERER_ROUTE_CONTEXT_CHANNEL,
|
||||
type RendererRouteContextInput,
|
||||
@@ -78,6 +79,16 @@ const desktopAPI = {
|
||||
},
|
||||
/** Validated runtime endpoint config, or a blocking config error. */
|
||||
runtimeConfig,
|
||||
/** Read + clear any freeze/crash breadcrumb left by a previous session, so
|
||||
* the renderer can flush it to telemetry on boot. Returns null when there's
|
||||
* nothing pending (the normal case). */
|
||||
getLastFreeze: (): FreezeBreadcrumb | null => {
|
||||
try {
|
||||
return ipcRenderer.sendSync("freeze:get-last") as FreezeBreadcrumb | null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/** Listen for auth token delivered via deep link */
|
||||
onAuthToken: (callback: (token: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, token: string) =>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useTabStore } from "./stores/tab-store";
|
||||
import { useWindowOverlayStore } from "./stores/window-overlay-store";
|
||||
import { useDaemonIPCBridge } from "./platform/daemon-ipc-bridge";
|
||||
import { createDesktopLocaleAdapter } from "./platform/i18n-adapter";
|
||||
import { captureEvent } from "@multica/core/analytics";
|
||||
import { RESOURCES } from "@multica/views/locales";
|
||||
|
||||
// BCP-47 region tags for the <html lang> attribute, mirroring
|
||||
@@ -332,6 +333,26 @@ export default function App() {
|
||||
const runtimeConfigResult = window.desktopAPI.runtimeConfig;
|
||||
useCmdWCloseTab();
|
||||
|
||||
// Flush a freeze/crash breadcrumb the main process parked from a previous
|
||||
// session. A true hang or process death can't report itself when it happens
|
||||
// (the renderer is blocked or gone), so the main process persists it and we
|
||||
// emit it here on the next boot. The in-thread, recoverable freeze tier is
|
||||
// handled separately by the shared watchdog in CoreProvider.
|
||||
useEffect(() => {
|
||||
const last = window.desktopAPI.getLastFreeze();
|
||||
if (!last) return;
|
||||
const crashed = last.kind === "render-process-gone";
|
||||
captureEvent(crashed ? "client_crash" : "client_unresponsive", {
|
||||
// Spread context FIRST so our explicit fields below always win — a
|
||||
// future context key (e.g. its own `source`) must not silently override.
|
||||
...last.context,
|
||||
source: crashed ? "render-process-gone" : "main-unresponsive",
|
||||
recovered: false,
|
||||
breadcrumb_ts: last.ts,
|
||||
crashed_version: last.version,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Stable identity reference so downstream effects (WS reconnect) don't
|
||||
// tear down on every parent render.
|
||||
const identity = useMemo(
|
||||
|
||||
16
apps/desktop/src/shared/freeze-breadcrumb.ts
Normal file
16
apps/desktop/src/shared/freeze-breadcrumb.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* A freeze/crash breadcrumb persisted by the main process and flushed to
|
||||
* telemetry by the next renderer boot. Shared across main, preload, and
|
||||
* renderer because all three touch it. See main/freeze-breadcrumb.ts for the
|
||||
* read/write logic and the rationale.
|
||||
*/
|
||||
export interface FreezeBreadcrumb {
|
||||
/** "unresponsive" (hang) or "render-process-gone" (crash). */
|
||||
kind: string;
|
||||
/** Diagnostic context captured at failure time (route, window url, …). */
|
||||
context: Record<string, unknown>;
|
||||
/** Epoch ms when the failure was recorded. */
|
||||
ts: number;
|
||||
/** App version at failure time. */
|
||||
version: string;
|
||||
}
|
||||
58
apps/web/app/global-error.tsx
Normal file
58
apps/web/app/global-error.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { captureException } from "@multica/core/analytics";
|
||||
|
||||
/**
|
||||
* Route-level error boundary for the web app. Next.js renders this (replacing
|
||||
* the root layout) when an error escapes everything below it — the full-page
|
||||
* white-screen case. React catches these before they reach window.onerror, so
|
||||
* posthog-js's automatic exception capture never sees them; we report them
|
||||
* explicitly here. Section-level failures are handled in place by
|
||||
* `@multica/ui` ErrorBoundary and don't reach this far.
|
||||
*/
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
captureException(error, { source: "global-error", digest: error.digest });
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html>
|
||||
<body
|
||||
style={{
|
||||
display: "flex",
|
||||
minHeight: "100vh",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 420, textAlign: "center" }}>
|
||||
<h1 style={{ fontSize: 18, fontWeight: 600 }}>Something went wrong</h1>
|
||||
<p style={{ marginTop: 8, color: "#666" }}>
|
||||
The page hit an unexpected error. Try reloading.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: "8px 16px",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #ccc",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ vi.mock("posthog-js", () => {
|
||||
reset: vi.fn(),
|
||||
identify: vi.fn(),
|
||||
capture: vi.fn(),
|
||||
captureException: vi.fn(),
|
||||
};
|
||||
return { default: mock };
|
||||
});
|
||||
@@ -22,10 +23,12 @@ async function loadModule() {
|
||||
init: ReturnType<typeof vi.fn>;
|
||||
register: ReturnType<typeof vi.fn>;
|
||||
reset: ReturnType<typeof vi.fn>;
|
||||
captureException: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
posthog.init.mockClear();
|
||||
posthog.register.mockClear();
|
||||
posthog.reset.mockClear();
|
||||
posthog.captureException.mockClear();
|
||||
return { analytics, posthog };
|
||||
}
|
||||
|
||||
@@ -183,3 +186,33 @@ describe("capturePageview", () => {
|
||||
expect(capture).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("captureException", () => {
|
||||
it("buffers a pre-init exception and flushes it on init", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
const err = new Error("boom");
|
||||
|
||||
// Before init: buffered, nothing sent yet.
|
||||
analytics.captureException(err, { source: "global-error" });
|
||||
expect(posthog.captureException).not.toHaveBeenCalled();
|
||||
|
||||
// Init flushes the buffer in order.
|
||||
analytics.initAnalytics({ key: "k", host: "" });
|
||||
expect(posthog.captureException).toHaveBeenCalledTimes(1);
|
||||
expect(posthog.captureException).toHaveBeenCalledWith(
|
||||
err,
|
||||
expect.objectContaining({ source: "global-error" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends immediately once initialized", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.initAnalytics({ key: "k", host: "" });
|
||||
posthog.captureException.mockClear();
|
||||
|
||||
const err = new Error("later");
|
||||
analytics.captureException(err);
|
||||
expect(posthog.captureException).toHaveBeenCalledTimes(1);
|
||||
expect(posthog.captureException).toHaveBeenCalledWith(err, expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// backend returns an empty key and this module stays inert.
|
||||
|
||||
import posthog from "posthog-js";
|
||||
import { redactExceptionProperties } from "./redact-exception";
|
||||
|
||||
export const EVENT_SCHEMA_VERSION = 2;
|
||||
|
||||
@@ -56,7 +57,8 @@ let lastCapturedPath: string | null = null;
|
||||
// buffer stays small (~one step-transition worth).
|
||||
type PendingOp =
|
||||
| { kind: "event"; name: string; props?: Record<string, unknown> }
|
||||
| { kind: "set"; props: Record<string, unknown> };
|
||||
| { kind: "set"; props: Record<string, unknown> }
|
||||
| { kind: "exception"; error: unknown; props?: Record<string, unknown> };
|
||||
const pendingOps: PendingOp[] = [];
|
||||
// Cached super-properties so resetAnalytics() can re-register them after
|
||||
// posthog.reset() wipes the persisted set. Without this, logout / account
|
||||
@@ -142,7 +144,25 @@ export function initAnalytics(config: AnalyticsConfig | null | undefined): boole
|
||||
autocapture: false,
|
||||
capture_heatmaps: false,
|
||||
capture_dead_clicks: false,
|
||||
capture_exceptions: false,
|
||||
// Exception autocapture IS on: posthog-js attaches window.onerror +
|
||||
// unhandledrejection handlers and sends `$exception` events with the
|
||||
// error's stack. Unlike the click/heatmap autocapture above, this is
|
||||
// explicit failure signal (not behavioral noise) and is the one PostHog
|
||||
// surface that natively handles thrown JS errors — see the failure-tier
|
||||
// split in packages/core/diagnostics. (Production builds are minified;
|
||||
// upload source maps to PostHog to de-minify the stacks.)
|
||||
//
|
||||
// Error messages can interpolate user input (a validation error with the
|
||||
// typed value, a URL with a token), so `before_send` scrubs the message
|
||||
// and `$exception_list[].value` before the event leaves the client. Stack
|
||||
// frames (code locations) are kept. See redact-exception.ts.
|
||||
capture_exceptions: true,
|
||||
before_send: (event) => {
|
||||
if (event && event.event === "$exception") {
|
||||
redactExceptionProperties(event.properties);
|
||||
}
|
||||
return event;
|
||||
},
|
||||
disable_session_recording: true,
|
||||
disable_surveys: true,
|
||||
});
|
||||
@@ -184,6 +204,8 @@ export function initAnalytics(config: AnalyticsConfig | null | undefined): boole
|
||||
const op = pendingOps.shift()!;
|
||||
if (op.kind === "event") {
|
||||
posthog.capture(op.name, withClientEventProperties(op.props));
|
||||
} else if (op.kind === "exception") {
|
||||
posthog.captureException(op.error, withClientEventProperties(op.props));
|
||||
} else {
|
||||
capturePersonSet(op.props);
|
||||
}
|
||||
@@ -250,6 +272,31 @@ export function captureEvent(
|
||||
posthog.capture(name, withClientEventProperties(props));
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a caught exception that never reached `window.onerror` — a React
|
||||
* render-phase error swallowed by an error boundary. Global uncaught errors
|
||||
* and unhandled rejections are already captured automatically by posthog-js
|
||||
* (`capture_exceptions: true`); this wrapper is for the boundary case those
|
||||
* handlers can't see.
|
||||
*
|
||||
* Currently called by the web route-level `global-error`. Section-level
|
||||
* `@multica/ui` ErrorBoundary can opt in by passing `onError={captureException}`
|
||||
* at its call sites; it is not wired app-wide (those failures already degrade
|
||||
* gracefully with fallback UI).
|
||||
*
|
||||
* Calls before initAnalytics() buffer in order, same as captureEvent.
|
||||
*/
|
||||
export function captureException(
|
||||
error: unknown,
|
||||
props?: Record<string, unknown>,
|
||||
): void {
|
||||
if (!initialized) {
|
||||
pendingOps.push({ kind: "exception", error, props });
|
||||
return;
|
||||
}
|
||||
posthog.captureException(error, withClientEventProperties(props));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (overwrite) person properties on the currently identified user.
|
||||
* Mirrors the backend's `Event.Set` path — keep these aligned so the
|
||||
|
||||
68
packages/core/analytics/redact-exception.test.ts
Normal file
68
packages/core/analytics/redact-exception.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { redactText, redactExceptionProperties } from "./redact-exception";
|
||||
|
||||
describe("redactText", () => {
|
||||
it("redacts email addresses", () => {
|
||||
expect(redactText("Invalid email: alice@example.com")).toBe(
|
||||
"Invalid email: [redacted]",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips URL query strings that may carry tokens, keeping host + path", () => {
|
||||
expect(
|
||||
redactText("fetch failed https://api.multica.ai/issues?token=abc123secret"),
|
||||
).toBe("fetch failed https://api.multica.ai/issues?[redacted]");
|
||||
});
|
||||
|
||||
it("redacts long opaque tokens (JWT / API key / uuid)", () => {
|
||||
expect(redactText("auth header eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9")).toBe(
|
||||
"auth header [redacted]",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the non-sensitive part of a message intact", () => {
|
||||
expect(redactText("Cannot read property 'x' of undefined")).toBe(
|
||||
"Cannot read property 'x' of undefined",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes through non-strings unchanged", () => {
|
||||
expect(redactText(undefined)).toBeUndefined();
|
||||
expect(redactText(42)).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactExceptionProperties", () => {
|
||||
it("scrubs the message and each $exception_list value, leaving frames untouched", () => {
|
||||
const props = {
|
||||
$exception_message: "Bad email bob@corp.com",
|
||||
$exception_list: [
|
||||
{
|
||||
type: "TypeError",
|
||||
value: "Token leaked: ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
stacktrace: { frames: [{ filename: "app.tsx", lineno: 5, function: "render" }] },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
redactExceptionProperties(props);
|
||||
|
||||
const entry = props.$exception_list[0]!;
|
||||
expect(props.$exception_message).toBe("Bad email [redacted]");
|
||||
expect(entry.value).toBe("Token leaked: [redacted]");
|
||||
// Frames are code locations, not user data — left intact.
|
||||
expect(entry.stacktrace.frames[0]).toEqual({
|
||||
filename: "app.tsx",
|
||||
lineno: 5,
|
||||
function: "render",
|
||||
});
|
||||
expect(entry.type).toBe("TypeError");
|
||||
});
|
||||
|
||||
it("is safe on undefined / malformed properties", () => {
|
||||
expect(redactExceptionProperties(undefined)).toBeUndefined();
|
||||
expect(() =>
|
||||
redactExceptionProperties({ $exception_list: "not-an-array" as unknown as [] }),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
61
packages/core/analytics/redact-exception.ts
Normal file
61
packages/core/analytics/redact-exception.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// PII scrubbing for `$exception` events before they leave the client.
|
||||
//
|
||||
// Exception autocapture (`capture_exceptions: true`) sends the error message
|
||||
// and stack. Stack frames are code locations (file / line / function) and are
|
||||
// safe, but a message often interpolates user input — a validation error with
|
||||
// the typed value, a parse error with the raw text, a network error with a URL
|
||||
// that may carry a token. We keep the diagnostic shape (type + frames + the
|
||||
// non-sensitive part of the message) and redact the patterns that carry user
|
||||
// data. Wired as posthog-js `before_send`; see initAnalytics.
|
||||
|
||||
const REDACTED = "[redacted]";
|
||||
|
||||
// Order matters: strip query strings before the generic long-token rule, so a
|
||||
// URL's host isn't itself shredded.
|
||||
const PATTERNS: Array<[RegExp, string]> = [
|
||||
// Emails.
|
||||
[/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi, REDACTED],
|
||||
// URL query/fragment (may carry tokens / PII) — keep scheme+host+path.
|
||||
[/((?:https?|file|multica):\/\/[^\s?#]*)[?#]\S*/gi, `$1?${REDACTED}`],
|
||||
// Long opaque tokens: JWTs, API keys, UUIDs, session ids (24+ chars).
|
||||
[/\b[A-Za-z0-9_-]{24,}\b/g, REDACTED],
|
||||
];
|
||||
|
||||
/** Redact PII-ish substrings from a free-text string. */
|
||||
export function redactText(input: unknown): unknown {
|
||||
if (typeof input !== "string" || input.length === 0) return input;
|
||||
let out = input;
|
||||
for (const [pattern, replacement] of PATTERNS) {
|
||||
out = out.replace(pattern, replacement);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact the user-facing strings on a `$exception` event's properties in
|
||||
* place: the top-level message and every entry's `value` in `$exception_list`.
|
||||
* Types and stack frames are left untouched (code locations, not user data).
|
||||
* Returns the same properties object for chaining.
|
||||
*/
|
||||
export function redactExceptionProperties(
|
||||
properties: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!properties || typeof properties !== "object") return properties;
|
||||
|
||||
if ("$exception_message" in properties) {
|
||||
properties.$exception_message = redactText(properties.$exception_message);
|
||||
}
|
||||
|
||||
const list = properties.$exception_list;
|
||||
if (Array.isArray(list)) {
|
||||
for (const entry of list) {
|
||||
if (entry && typeof entry === "object" && "value" in entry) {
|
||||
(entry as { value: unknown }).value = redactText(
|
||||
(entry as { value: unknown }).value,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return properties;
|
||||
}
|
||||
99
packages/core/diagnostics/freeze-watchdog.test.ts
Normal file
99
packages/core/diagnostics/freeze-watchdog.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../analytics", () => ({ captureEvent: vi.fn() }));
|
||||
|
||||
// A controllable PerformanceObserver stand-in: records the callback so a test
|
||||
// can fire synthetic long-task entries, and counts constructions so we can
|
||||
// assert idempotent install.
|
||||
let lastCallback: ((list: { getEntries: () => Array<{ duration: number }> }) => void) | null;
|
||||
let constructed: number;
|
||||
let observeCalls: number;
|
||||
|
||||
class FakePerformanceObserver {
|
||||
constructor(cb: (list: { getEntries: () => Array<{ duration: number }> }) => void) {
|
||||
constructed += 1;
|
||||
lastCallback = cb;
|
||||
}
|
||||
observe() {
|
||||
observeCalls += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function fireLongTask(duration: number) {
|
||||
lastCallback?.({ getEntries: () => [{ duration }] });
|
||||
}
|
||||
|
||||
async function load() {
|
||||
vi.resetModules();
|
||||
const mod = await import("./freeze-watchdog");
|
||||
const { captureEvent } = await import("../analytics");
|
||||
return {
|
||||
installFreezeWatchdog: mod.installFreezeWatchdog,
|
||||
captureEvent: captureEvent as unknown as ReturnType<typeof vi.fn>,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
lastCallback = null;
|
||||
constructed = 0;
|
||||
observeCalls = 0;
|
||||
vi.stubGlobal("window", {});
|
||||
vi.stubGlobal("location", { pathname: "/acme/issues" });
|
||||
vi.stubGlobal("PerformanceObserver", FakePerformanceObserver);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("installFreezeWatchdog", () => {
|
||||
it("reports a long task at or above the 2s threshold with duration + path", async () => {
|
||||
const { installFreezeWatchdog, captureEvent } = await load();
|
||||
installFreezeWatchdog();
|
||||
|
||||
fireLongTask(2300);
|
||||
|
||||
expect(captureEvent).toHaveBeenCalledTimes(1);
|
||||
expect(captureEvent).toHaveBeenCalledWith("client_unresponsive", {
|
||||
source: "longtask",
|
||||
duration_ms: 2300,
|
||||
path: "/acme/issues",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores blocks below the threshold (normal render cost)", async () => {
|
||||
const { installFreezeWatchdog, captureEvent } = await load();
|
||||
installFreezeWatchdog();
|
||||
|
||||
fireLongTask(600);
|
||||
fireLongTask(1999);
|
||||
|
||||
expect(captureEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is idempotent — a second install does not add a second observer", async () => {
|
||||
const { installFreezeWatchdog } = await load();
|
||||
installFreezeWatchdog();
|
||||
installFreezeWatchdog();
|
||||
|
||||
expect(constructed).toBe(1);
|
||||
expect(observeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("is a no-op on the server (no window)", async () => {
|
||||
vi.stubGlobal("window", undefined);
|
||||
const { installFreezeWatchdog, captureEvent } = await load();
|
||||
|
||||
expect(() => installFreezeWatchdog()).not.toThrow();
|
||||
expect(constructed).toBe(0);
|
||||
expect(captureEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is a no-op when PerformanceObserver is unavailable", async () => {
|
||||
vi.stubGlobal("PerformanceObserver", undefined);
|
||||
const { installFreezeWatchdog } = await load();
|
||||
|
||||
expect(() => installFreezeWatchdog()).not.toThrow();
|
||||
});
|
||||
});
|
||||
57
packages/core/diagnostics/freeze-watchdog.ts
Normal file
57
packages/core/diagnostics/freeze-watchdog.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// Client freeze watchdog — shared by web and desktop.
|
||||
//
|
||||
// Installs a long-task observer in the main thread. A "long task" is any
|
||||
// stretch where the thread didn't return to the event loop; the browser
|
||||
// already tracks them and delivers each entry once the thread unblocks, so
|
||||
// even a multi-second freeze reports its duration after the fact. We only
|
||||
// emit for blocks at or above FREEZE_THRESHOLD_MS to keep this to genuine
|
||||
// "almost froze" events, not the normal 50–600ms render cost.
|
||||
//
|
||||
// This is the in-thread, recoverable tier: it catches freezes the thread
|
||||
// survives. A true non-recoverable hang (the thread never unblocks) can only
|
||||
// be caught from outside — on desktop that is the main process `unresponsive`
|
||||
// handler (see apps/desktop renderer-recovery). Web has no free external
|
||||
// watcher, so this observer is its only freeze signal for now.
|
||||
//
|
||||
// The emitted `client_unresponsive` event carries `client_type` automatically
|
||||
// (an analytics super-property), so desktop vs web is queryable without any
|
||||
// platform branch here.
|
||||
|
||||
import { captureEvent } from "../analytics";
|
||||
|
||||
// 2s is well above the normal switch/render cost (measured 50–600ms) and just
|
||||
// under Electron's renderer-hang threshold, so an event here means "the user
|
||||
// felt a real stall" without flooding on routine heavy renders.
|
||||
const FREEZE_THRESHOLD_MS = 2000;
|
||||
|
||||
let installed = false;
|
||||
|
||||
/**
|
||||
* Install the long-task observer. Safe to call multiple times (idempotent) and
|
||||
* safe on the server (no-op when `window` / `PerformanceObserver` is absent).
|
||||
* Call once from a client-only effect.
|
||||
*/
|
||||
export function installFreezeWatchdog(): void {
|
||||
if (installed) return;
|
||||
if (typeof window === "undefined") return;
|
||||
if (typeof PerformanceObserver === "undefined") return;
|
||||
installed = true;
|
||||
|
||||
try {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.duration < FREEZE_THRESHOLD_MS) continue;
|
||||
captureEvent("client_unresponsive", {
|
||||
source: "longtask",
|
||||
duration_ms: Math.round(entry.duration),
|
||||
path: typeof location !== "undefined" ? location.pathname : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
// No `buffered: true` — we only want freezes from now on. Replaying tasks
|
||||
// buffered before install would mislabel slow startup as a runtime freeze.
|
||||
observer.observe({ type: "longtask" });
|
||||
} catch {
|
||||
// longtask entry type unsupported on this engine — nothing else to do.
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { ApiClient } from "../api/client";
|
||||
import { installFreezeWatchdog } from "../diagnostics/freeze-watchdog";
|
||||
import { setApiInstance, setSchemaLogger } from "../api";
|
||||
import { createAuthStore, registerAuthStore } from "../auth";
|
||||
import { createChatStore, registerChatStore } from "../chat";
|
||||
@@ -80,6 +81,12 @@ export function CoreProvider({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useMemo(() => initCore(apiBaseUrl, storage, onLogin, onLogout, cookieAuth, identity), []);
|
||||
|
||||
// Client-only freeze watchdog — shared by web and desktop. No-op on the
|
||||
// server and idempotent, so mounting it here covers both apps in one place.
|
||||
useEffect(() => {
|
||||
installFreezeWatchdog();
|
||||
}, []);
|
||||
|
||||
// I18nProvider wraps everything else: server and client must use the same
|
||||
// (locale, resources) to avoid hydration mismatch. Language switching goes
|
||||
// through window.location.reload(), never client-side changeLanguage.
|
||||
|
||||
Reference in New Issue
Block a user