diff --git a/apps/desktop/src/lib/electron-app/factories/app/setup.ts b/apps/desktop/src/lib/electron-app/factories/app/setup.ts index 5ac810ec67f..65e833ea801 100644 --- a/apps/desktop/src/lib/electron-app/factories/app/setup.ts +++ b/apps/desktop/src/lib/electron-app/factories/app/setup.ts @@ -71,6 +71,11 @@ export async function makeAppSetup( PLATFORM.IS_LINUX && app.disableHardwareAcceleration(); +// macOS Sequoia+: occluded window throttling can corrupt GPU compositor layers +if (PLATFORM.IS_MAC) { + app.commandLine.appendSwitch("disable-backgrounding-occluded-windows"); +} + PLATFORM.IS_WINDOWS && app.setAppUserModelId( env.NODE_ENV === "development" ? process.execPath : makeAppId(), diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index a5f2fb7aaaf..b993b1edd71 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -2,11 +2,11 @@ import { join } from "node:path"; import { workspaces, worktrees } from "@superset/local-db"; import { eq } from "drizzle-orm"; import type { BrowserWindow } from "electron"; -import { Notification } from "electron"; +import { app, Notification, nativeTheme } from "electron"; import { createWindow } from "lib/electron-app/factories/windows/create"; import { createAppRouter } from "lib/trpc/routers"; import { localDb } from "main/lib/local-db"; -import { NOTIFICATION_EVENTS, PORTS } from "shared/constants"; +import { NOTIFICATION_EVENTS, PLATFORM, PORTS } from "shared/constants"; import { createIPCHandler } from "trpc-electron/main"; import { productName } from "~/package.json"; import { appState } from "../lib/app-state"; @@ -61,6 +61,28 @@ let currentWindow: BrowserWindow | null = null; // Getter for routers to access current window without stale references const getWindow = () => currentWindow; +// invalidate() alone may not rebuild corrupted GPU layers — a tiny resize +// forces Chromium to reconstruct the compositor layer tree. +const forceRepaint = (win: BrowserWindow) => { + if (win.isDestroyed()) return; + win.webContents.invalidate(); + if (win.isMaximized() || win.isFullScreen()) return; + const [width, height] = win.getSize(); + win.setSize(width + 1, height); + setTimeout(() => { + if (!win.isDestroyed()) win.setSize(width, height); + }, 32); +}; + +// GPU process restarts don't repaint existing compositor layers automatically. +app.on("child-process-gone", (_event, details) => { + if (details.type === "GPU") { + console.warn("[main-window] GPU process gone:", details.reason); + const win = getWindow(); + if (win) forceRepaint(win); + } +}); + export async function MainWindow() { const savedWindowState = loadWindowState(); const initialBounds = getInitialWindowBounds(savedWindowState); @@ -75,6 +97,7 @@ export async function MainWindow() { minWidth: 400, minHeight: 400, show: false, + backgroundColor: nativeTheme.shouldUseDarkColors ? "#252525" : "#ffffff", center: initialBounds.center, movable: true, resizable: true, @@ -97,6 +120,11 @@ export async function MainWindow() { currentWindow = window; + // macOS Sequoia+: background throttling can corrupt GPU compositor layers + if (PLATFORM.IS_MAC) { + window.webContents.setBackgroundThrottling(false); + } + if (ipcHandler) { ipcHandler.attachWindow(window); } else { @@ -204,6 +232,16 @@ export async function MainWindow() { }, ); + // macOS Sequoia+: occluded/minimized windows can lose compositor layers + if (PLATFORM.IS_MAC) { + window.on("restore", () => { + window.webContents.invalidate(); + }); + window.on("show", () => { + window.webContents.invalidate(); + }); + } + window.webContents.on("did-finish-load", async () => { console.log("[main-window] Renderer loaded successfully"); // Restore maximized state if it was saved diff --git a/apps/desktop/src/renderer/components/BootErrorBoundary/BootErrorBoundary.tsx b/apps/desktop/src/renderer/components/BootErrorBoundary/BootErrorBoundary.tsx new file mode 100644 index 00000000000..15c347bdba4 --- /dev/null +++ b/apps/desktop/src/renderer/components/BootErrorBoundary/BootErrorBoundary.tsx @@ -0,0 +1,76 @@ +import type { ReactNode } from "react"; +import { Component } from "react"; + +export interface BootErrorBoundaryProps { + children: ReactNode; + onError?: (error: Error) => void; +} + +interface BootErrorBoundaryState { + hasError: boolean; + error?: Error; +} + +export class BootErrorBoundary extends Component< + BootErrorBoundaryProps, + BootErrorBoundaryState +> { + state: BootErrorBoundaryState = { hasError: false }; + + static getDerivedStateFromError(error: Error): BootErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error): void { + console.error("[renderer] Boot error boundary caught:", error); + this.props.onError?.(error); + } + + render() { + if (!this.state.hasError) { + return this.props.children; + } + + return ( +
+
+

+ Superset failed to start +

+

+ The renderer crashed during startup. Please check logs for details. +

+ + +
+
+ ); + } +} diff --git a/apps/desktop/src/renderer/components/BootErrorBoundary/index.ts b/apps/desktop/src/renderer/components/BootErrorBoundary/index.ts new file mode 100644 index 00000000000..6e8ab7d5dae --- /dev/null +++ b/apps/desktop/src/renderer/components/BootErrorBoundary/index.ts @@ -0,0 +1 @@ +export { BootErrorBoundary } from "./BootErrorBoundary"; diff --git a/apps/desktop/src/renderer/index.tsx b/apps/desktop/src/renderer/index.tsx index 0354ef032dc..7bbcf4bf404 100644 --- a/apps/desktop/src/renderer/index.tsx +++ b/apps/desktop/src/renderer/index.tsx @@ -4,6 +4,14 @@ initSentry(); import { createRouter, RouterProvider } from "@tanstack/react-router"; import ReactDom from "react-dom/client"; +import { BootErrorBoundary } from "./components/BootErrorBoundary"; +import { + cleanupBootErrorHandling, + initBootErrorHandling, + isBootErrorReported, + markBootMounted, + reportBootError, +} from "./lib/boot-errors"; import { persistentHistory } from "./lib/persistent-hash-history"; import { posthog } from "./lib/posthog"; import { electronQueryClient } from "./providers/ElectronTRPCProvider"; @@ -11,6 +19,9 @@ import { routeTree } from "./routeTree.gen"; import "./globals.css"; +const rootElement = document.querySelector("app"); +initBootErrorHandling(rootElement); + const router = createRouter({ routeTree, history: persistentHistory, @@ -30,12 +41,19 @@ const handleDeepLink = (path: string) => { console.log("[deep-link] Navigating to:", path); router.navigate({ to: path }); }; -window.ipcRenderer.on("deep-link-navigate", handleDeepLink); +if (window.ipcRenderer?.on && window.ipcRenderer?.off) { + window.ipcRenderer.on("deep-link-navigate", handleDeepLink); +} else { + reportBootError( + "Renderer preload not available (window.ipcRenderer missing)", + ); +} if (import.meta.hot) { import.meta.hot.dispose(() => { unsubscribe(); - window.ipcRenderer.off("deep-link-navigate", handleDeepLink); + window.ipcRenderer?.off?.("deep-link-navigate", handleDeepLink); + cleanupBootErrorHandling(); }); } @@ -45,6 +63,15 @@ declare module "@tanstack/react-router" { } } -ReactDom.createRoot(document.querySelector("app") as HTMLElement).render( - , -); +if (!rootElement) { + reportBootError("Missing root element"); +} else if (!isBootErrorReported()) { + ReactDom.createRoot(rootElement).render( + reportBootError("Render failed", error)} + > + + , + ); + markBootMounted(); +} diff --git a/apps/desktop/src/renderer/lib/boot-errors.ts b/apps/desktop/src/renderer/lib/boot-errors.ts new file mode 100644 index 00000000000..5a62dd0fb05 --- /dev/null +++ b/apps/desktop/src/renderer/lib/boot-errors.ts @@ -0,0 +1,99 @@ +let bootErrorReported = false; +let hasMounted = false; +let rootElement: Element | null = null; +let listenersAttached = false; + +const renderBootError = (message: string, error?: unknown) => { + if (bootErrorReported) return; + bootErrorReported = true; + + const container = rootElement ?? document.body; + const wrapper = document.createElement("div"); + wrapper.style.display = "flex"; + wrapper.style.height = "100vh"; + wrapper.style.alignItems = "center"; + wrapper.style.justifyContent = "center"; + wrapper.style.background = "#0f0f0f"; + wrapper.style.color = "#e5e5e5"; + wrapper.style.fontFamily = "system-ui, sans-serif"; + wrapper.style.padding = "24px"; + wrapper.style.textAlign = "center"; + + const inner = document.createElement("div"); + inner.style.maxWidth = "520px"; + + const title = document.createElement("div"); + title.textContent = "Superset failed to start"; + title.style.fontSize = "18px"; + title.style.marginBottom = "8px"; + + const detail = document.createElement("div"); + detail.textContent = message; + detail.style.fontSize = "14px"; + detail.style.opacity = "0.8"; + + inner.appendChild(title); + inner.appendChild(detail); + + if (error) { + let errorText = "Unknown error"; + if (error instanceof Error) { + errorText = error.message; + } else if (typeof error === "string") { + errorText = error; + } else { + try { + errorText = JSON.stringify(error); + } catch { + errorText = String(error); + } + } + const pre = document.createElement("pre"); + pre.textContent = errorText; + pre.style.marginTop = "12px"; + pre.style.fontSize = "12px"; + pre.style.opacity = "0.7"; + pre.style.whiteSpace = "pre-wrap"; + inner.appendChild(pre); + } + + wrapper.appendChild(inner); + container.replaceChildren(wrapper); +}; + +export const reportBootError = (message: string, error?: unknown) => { + console.error("[renderer] Boot error:", message, error); + if (hasMounted) return; + renderBootError(message, error); +}; + +const handleGlobalError = (event: ErrorEvent) => { + if (hasMounted) return; + reportBootError(event.message || "Unhandled error", event.error); +}; + +const handleUnhandledRejection = (event: PromiseRejectionEvent) => { + if (hasMounted) return; + reportBootError("Unhandled promise rejection", event.reason); +}; + +export const initBootErrorHandling = (root: Element | null) => { + rootElement = root; + if (listenersAttached) return; + listenersAttached = true; + window.addEventListener("error", handleGlobalError); + window.addEventListener("unhandledrejection", handleUnhandledRejection); +}; + +export const cleanupBootErrorHandling = () => { + if (!listenersAttached) return; + listenersAttached = false; + window.removeEventListener("error", handleGlobalError); + window.removeEventListener("unhandledrejection", handleUnhandledRejection); +}; + +export const markBootMounted = () => { + hasMounted = true; +}; + +export const isBootErrorReported = () => bootErrorReported; diff --git a/apps/desktop/src/renderer/providers/PostHogProvider/PostHogProvider.tsx b/apps/desktop/src/renderer/providers/PostHogProvider/PostHogProvider.tsx index ca9e7528368..985421d5e7c 100644 --- a/apps/desktop/src/renderer/providers/PostHogProvider/PostHogProvider.tsx +++ b/apps/desktop/src/renderer/providers/PostHogProvider/PostHogProvider.tsx @@ -11,12 +11,16 @@ export function PostHogProvider({ children }: PostHogProviderProps) { const [isInitialized, setIsInitialized] = useState(false); useEffect(() => { - initPostHog(); - posthog.capture("desktop_opened"); - setIsInitialized(true); + try { + initPostHog(); + posthog.capture("desktop_opened"); + } catch (error) { + console.error("[posthog] Failed to initialize:", error); + } finally { + setIsInitialized(true); + } }, []); - // Don't render children until PostHog is initialized if (!isInitialized) { return null; }