diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index e4e6ba8ec..59ff64cb7 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -13,6 +13,11 @@ import { installNavigationGestures } from "./navigation-gestures"; import { getAppVersion } from "./app-version"; import { loadRuntimeConfig } from "./runtime-config-loader"; import type { RuntimeConfigResult } from "../shared/runtime-config"; +import { + RENDERER_ROUTE_CONTEXT_CHANNEL, + sanitizeRendererRouteContext, + type RendererRouteContext, +} from "../shared/renderer-route-context"; import { createElectronReloadPrompt, installRendererRecoveryHandlers, @@ -62,6 +67,7 @@ if (process.platform !== "win32") { const PROTOCOL = "multica"; let mainWindow: BrowserWindow | null = null; +let latestRendererRouteContext: RendererRouteContext | null = null; let runtimeConfigResult: RuntimeConfigResult = { ok: false, error: { message: "Runtime config has not loaded yet" }, @@ -166,9 +172,13 @@ function createWindow(): void { }, }); const window = mainWindow; + latestRendererRouteContext = null; window.on("closed", () => { - if (mainWindow === window) mainWindow = null; + if (mainWindow === window) { + mainWindow = null; + latestRendererRouteContext = null; + } }); // Strip Origin header from WebSocket upgrade requests so the server's @@ -259,6 +269,12 @@ function createWindow(): void { showReloadPrompt: createElectronReloadPrompt((options) => dialog.showMessageBox(window, options), ), + getDiagnosticContext: () => ({ + windowUrl: window.webContents.getURL(), + ...(latestRendererRouteContext + ? { desktopRoute: latestRendererRouteContext } + : {}), + }), }); installContextMenu(window.webContents); @@ -404,6 +420,13 @@ if (!gotTheLock) { event.returnValue = runtimeConfigResult; }); + ipcMain.on(RENDERER_ROUTE_CONTEXT_CHANNEL, (event, context: unknown) => { + if (!mainWindow || event.sender !== mainWindow.webContents) return; + const sanitized = sanitizeRendererRouteContext(context); + if (!sanitized) return; + latestRendererRouteContext = sanitized; + }); + // IPC: toggle immersive mode — hides the macOS traffic lights so full-screen // modals (e.g. create-workspace) can place UI in the top-left corner // without fighting the native window controls' hit-test. diff --git a/apps/desktop/src/main/renderer-recovery.test.ts b/apps/desktop/src/main/renderer-recovery.test.ts index 9704b163a..c216131ee 100644 --- a/apps/desktop/src/main/renderer-recovery.test.ts +++ b/apps/desktop/src/main/renderer-recovery.test.ts @@ -83,10 +83,50 @@ describe("installRendererRecoveryHandlers", () => { vi.useFakeTimers(); const fixture = makeWindow(); const showReloadPrompt = vi.fn(async () => "dismiss" as const); + const desktopRoute = { + surface: "tab", + path: "/acme/issues/MUL-3239", + workspaceSlug: "acme", + tabId: "tab-1", + reportedAt: "2026-06-15T00:00:00.000Z", + }; installRendererRecoveryHandlers(fixture.window, { isDev: false, showReloadPrompt, + getDiagnosticContext: () => ({ + windowUrl: + "file:///Applications/Multica.app/Contents/Resources/app.asar/index.html", + desktopRoute, + }), + unresponsivePromptDelayMs: 100, + }); + + fixture.windowHandlers.get("unresponsive")?.(); + await vi.advanceTimersByTimeAsync(100); + + expect(showReloadPrompt).toHaveBeenCalledWith({ + kind: "unresponsive", + context: { + windowUrl: + "file:///Applications/Multica.app/Contents/Resources/app.asar/index.html", + desktopRoute, + }, + }); + expect(fixture.reload).not.toHaveBeenCalled(); + }); + + it("keeps prompting when diagnostic context collection fails", async () => { + vi.useFakeTimers(); + const fixture = makeWindow(); + const showReloadPrompt = vi.fn(async () => "dismiss" as const); + + installRendererRecoveryHandlers(fixture.window, { + isDev: false, + showReloadPrompt, + getDiagnosticContext: () => { + throw new Error("diagnostics unavailable"); + }, unresponsivePromptDelayMs: 100, }); @@ -94,7 +134,6 @@ describe("installRendererRecoveryHandlers", () => { await vi.advanceTimersByTimeAsync(100); expect(showReloadPrompt).toHaveBeenCalledWith({ kind: "unresponsive", context: {} }); - expect(fixture.reload).not.toHaveBeenCalled(); }); it("keeps dev diagnostics non-prompting", async () => { diff --git a/apps/desktop/src/main/renderer-recovery.ts b/apps/desktop/src/main/renderer-recovery.ts index 4e26d052e..e7535d6d9 100644 --- a/apps/desktop/src/main/renderer-recovery.ts +++ b/apps/desktop/src/main/renderer-recovery.ts @@ -17,6 +17,7 @@ type ReloadPromptResult = "reload" | "dismiss"; type RendererRecoveryOptions = { isDev: boolean; showReloadPrompt: (payload: ReloadPromptPayload) => Promise; + getDiagnosticContext?: () => Record; log?: (tag: string, ...args: unknown[]) => void; unresponsivePromptDelayMs?: number; }; @@ -26,11 +27,16 @@ export function installRendererRecoveryHandlers( { isDev, showReloadPrompt, + getDiagnosticContext, log = defaultDevLog, unresponsivePromptDelayMs = 1500, }: RendererRecoveryOptions, ) { let unresponsivePromptTimer: ReturnType | null = null; + const mergeDiagnosticContext = (context: Record) => ({ + ...readDiagnosticContext(getDiagnosticContext), + ...context, + }); const maybePromptReload = (payload: ReloadPromptPayload) => { if (isDev) return; void showReloadPrompt(payload).then((result) => { @@ -43,14 +49,17 @@ export function installRendererRecoveryHandlers( window.webContents.on("render-process-gone", (_event, details) => { if (isDev) log("process-gone", JSON.stringify(details)); if (!isRecoverableRendererExit(details)) return; - maybePromptReload({ kind: "render-process-gone", context: { details } }); + maybePromptReload({ + kind: "render-process-gone", + context: mergeDiagnosticContext({ details }), + }); }); window.webContents.on("preload-error", (_event, preloadPath, error) => { if (isDev) log("preload-error", `path=${preloadPath} err=${formatError(error)}`); maybePromptReload({ kind: "preload-error", - context: { preloadPath, error: formatError(error) }, + context: mergeDiagnosticContext({ preloadPath, error: formatError(error) }), }); }); @@ -58,7 +67,10 @@ export function installRendererRecoveryHandlers( if (isDev || unresponsivePromptTimer) return; unresponsivePromptTimer = setTimeout(() => { unresponsivePromptTimer = null; - maybePromptReload({ kind: "unresponsive", context: {} }); + maybePromptReload({ + kind: "unresponsive", + context: mergeDiagnosticContext({}), + }); }, unresponsivePromptDelayMs); }); @@ -142,6 +154,17 @@ function defaultDevLog(tag: string, ...args: unknown[]) { process.stderr.write(`[renderer ${tag}] ${args.map(String).join(" ")}\n`); } +function readDiagnosticContext( + getDiagnosticContext: (() => Record) | undefined, +) { + if (!getDiagnosticContext) return {}; + try { + return getDiagnosticContext(); + } catch { + return {}; + } +} + function formatError(error: unknown) { return error instanceof Error ? (error.stack ?? error.message) : String(error); } diff --git a/apps/desktop/src/preload/index.d.ts b/apps/desktop/src/preload/index.d.ts index ae0e8365f..c7e8f9798 100644 --- a/apps/desktop/src/preload/index.d.ts +++ b/apps/desktop/src/preload/index.d.ts @@ -1,6 +1,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"; interface DesktopAPI { /** App version + normalized OS, captured synchronously at preload time. */ @@ -45,6 +46,8 @@ interface DesktopAPI { ) => () => void; /** Listen for native macOS back/forward swipe gestures. Returns an unsubscribe function. */ onNavigationGesture: (callback: (gesture: NavigationGesture) => void) => () => void; + /** Report the renderer's memory-router path for recovery diagnostics. */ + setRendererRouteContext: (context: RendererRouteContextInput) => void; /** Open the OS folder picker and return the chosen absolute path. * Used by the Project settings "Add local directory" flow. */ pickDirectory: ( diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index e32ee27f3..8c7af8114 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -1,6 +1,10 @@ import { contextBridge, ipcRenderer } from "electron"; import { electronAPI } from "@electron-toolkit/preload"; import type { RuntimeConfigResult } from "../shared/runtime-config"; +import { + RENDERER_ROUTE_CONTEXT_CHANNEL, + type RendererRouteContextInput, +} from "../shared/renderer-route-context"; import { isNavigationGesture, NAVIGATION_GESTURE_CHANNEL, @@ -156,6 +160,9 @@ const desktopAPI = { ipcRenderer.removeListener(NAVIGATION_GESTURE_CHANNEL, handler); }; }, + /** Report the renderer's memory-router path for recovery diagnostics. */ + setRendererRouteContext: (context: RendererRouteContextInput) => + ipcRenderer.send(RENDERER_ROUTE_CONTEXT_CHANNEL, context), /** Open the OS folder picker and return the chosen absolute path. */ pickDirectory: (defaultPath?: string) => ipcRenderer.invoke("local-directory:pick", defaultPath), diff --git a/apps/desktop/src/renderer/src/components/pageview-tracker.tsx b/apps/desktop/src/renderer/src/components/pageview-tracker.tsx index 17f1694a0..6afdcc4c1 100644 --- a/apps/desktop/src/renderer/src/components/pageview-tracker.tsx +++ b/apps/desktop/src/renderer/src/components/pageview-tracker.tsx @@ -7,6 +7,7 @@ import { useTabStore, } from "@/stores/tab-store"; import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overlay-store"; +import type { RendererRouteContextInput } from "../../../shared/renderer-route-context"; /** * Fires a PostHog $pageview whenever the user's visible surface changes, @@ -90,6 +91,16 @@ export function PageviewTracker() { const last = lastSurfaceRef.current; const next = { kind, key, path }; + const routeContext: RendererRouteContextInput = { + surface: kind, + path, + }; + if (kind === "tab") { + routeContext.workspaceSlug = activeWorkspaceSlug ?? undefined; + routeContext.tabId = activeTabId ?? undefined; + } + reportRendererRouteContext(routeContext); + if (kind === "tab" && key !== null) { const knownPath = observed.get(key); const isReactivation = @@ -112,6 +123,13 @@ export function PageviewTracker() { return null; } +function reportRendererRouteContext(context: RendererRouteContextInput) { + const desktopAPI = window.desktopAPI as + | { setRendererRouteContext?: (context: RendererRouteContextInput) => void } + | undefined; + desktopAPI?.setRendererRouteContext?.(context); +} + function overlayPath(overlay: WindowOverlay): string { switch (overlay.type) { case "new-workspace": diff --git a/apps/desktop/src/shared/renderer-route-context.ts b/apps/desktop/src/shared/renderer-route-context.ts new file mode 100644 index 000000000..47887fd98 --- /dev/null +++ b/apps/desktop/src/shared/renderer-route-context.ts @@ -0,0 +1,51 @@ +export const RENDERER_ROUTE_CONTEXT_CHANNEL = "renderer:route-context"; + +export type RendererRouteSurface = "login" | "overlay" | "tab"; + +export type RendererRouteContextInput = { + surface: RendererRouteSurface; + path: string; + workspaceSlug?: string; + tabId?: string; +}; + +export type RendererRouteContext = RendererRouteContextInput & { + reportedAt: string; +}; + +const MAX_ROUTE_CONTEXT_STRING_LENGTH = 512; + +export function sanitizeRendererRouteContext( + value: unknown, + reportedAt = new Date(), +): RendererRouteContext | null { + if (!value || typeof value !== "object") return null; + + const input = value as Record; + if (!isRendererRouteSurface(input.surface)) return null; + + const path = sanitizeString(input.path); + if (!path) return null; + + const workspaceSlug = sanitizeString(input.workspaceSlug); + const tabId = sanitizeString(input.tabId); + + return { + surface: input.surface, + path, + ...(workspaceSlug ? { workspaceSlug } : {}), + ...(tabId ? { tabId } : {}), + reportedAt: reportedAt.toISOString(), + }; +} + +function isRendererRouteSurface(value: unknown): value is RendererRouteSurface { + return value === "login" || value === "overlay" || value === "tab"; +} + +function sanitizeString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + return trimmed.slice(0, MAX_ROUTE_CONTEXT_STRING_LENGTH); +}