From 3ae3c1a154bac5fcc371f0601407e21c9ca16b6a Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 28 Jul 2026 17:02:36 +0200 Subject: [PATCH 1/3] perf: make platformUtils synchronous via preload --- electron/electron-env.d.ts | 2 +- electron/ipc/handlers.ts | 4 -- electron/preload.ts | 8 +-- src/components/launch/LaunchWindow.test.tsx | 2 +- src/contexts/ShortcutsContext.tsx | 9 ++-- src/hooks/useScreenRecorder.ts | 8 +-- src/lib/exporter/gifExporter.ts | 2 +- src/utils/platformUtils.ts | 59 ++++++--------------- 8 files changed, 32 insertions(+), 62 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 24b9a5bbb0..0cad8d4c12 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -317,7 +317,7 @@ interface Window { onMenuSaveProjectAs: (callback: () => void) => () => void; quitApp: () => void; setTitleBarOverlay: (color: string, symbolColor: string) => void; - getPlatform: () => Promise; + getPlatform: () => string; revealInFolder: ( filePath: string, ) => Promise<{ success: boolean; error?: string; message?: string }>; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index bbdda1cfb2..df0c88b95c 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -3283,10 +3283,6 @@ export function registerIpcHandlers( return { success: true }; } - ipcMain.handle("get-platform", () => { - return process.platform; - }); - // Keep the native Windows/Linux window-control overlay in the app's theme // colours. The renderer sends the resolved CSS values so the palette stays in // one place. No-op on macOS (traffic lights aren't tintable) and on any window diff --git a/electron/preload.ts b/electron/preload.ts index 381723d602..937607cbee 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -19,6 +19,10 @@ const ASSET_BASE_URL_ARG_PREFIX = "--asset-base-url="; const assetBaseUrlArg = process.argv.find((arg) => arg.startsWith(ASSET_BASE_URL_ARG_PREFIX)); const assetBaseUrl = assetBaseUrlArg ? assetBaseUrlArg.slice(ASSET_BASE_URL_ARG_PREFIX.length) : ""; +// Renderer side: process.platform is the same Node global as in the main process, +// so a synchronous read here saves the renderer's every-call IPC round-trip. +const PLATFORM = process.platform; + contextBridge.exposeInMainWorld("electronAPI", { assetBaseUrl, @@ -314,9 +318,7 @@ contextBridge.exposeInMainWorld("electronAPI", { setTitleBarOverlay: (color: string, symbolColor: string) => { ipcRenderer.send("set-titlebar-overlay", color, symbolColor); }, - getPlatform: () => { - return ipcRenderer.invoke("get-platform"); - }, + getPlatform: () => PLATFORM, revealInFolder: (filePath: string) => { return ipcRenderer.invoke("reveal-in-folder", filePath); }, diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index ce4f7e93ed..3e108ff17c 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -193,7 +193,7 @@ function stubElectronAPI(getSelectedSource: Window["electronAPI"]["getSelectedSo granted: true, status: "granted", })), - getPlatform: vi.fn(async () => "darwin"), + getPlatform: vi.fn(() => "darwin"), setHudOverlaySize: vi.fn(), setHudOverlayIgnoreMouseEvents: vi.fn(), beginHudOverlayDrag: vi.fn(), diff --git a/src/contexts/ShortcutsContext.tsx b/src/contexts/ShortcutsContext.tsx index aab5f45cf6..a1d895b407 100644 --- a/src/contexts/ShortcutsContext.tsx +++ b/src/contexts/ShortcutsContext.tsx @@ -30,15 +30,14 @@ export function useShortcuts(): ShortcutsContextValue { export function ShortcutsProvider({ children }: { children: ReactNode }) { const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS); + // `getIsMac()` is synchronous; the state still needs an effect so the value + // doesn't snapshot a wrong value during SSR/hydration (the module-level + // `process.platform` read happens once on first import). const [isMac, setIsMac] = useState(false); const [isConfigOpen, setIsConfigOpen] = useState(false); useEffect(() => { - getIsMac() - .then(setIsMac) - .catch(() => { - // Keep default non-mac fallback if detection fails. - }); + setIsMac(getIsMac()); window.electronAPI .getShortcuts?.() diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 185dc32499..578ad5e780 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -799,7 +799,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { countdownRunToken?: number, ) => { try { - const platform = await window.electronAPI.getPlatform(); + const platform = window.electronAPI.getPlatform(); if (platform !== "win32") { return false; } @@ -911,7 +911,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { countdownRunToken?: number, ) => { try { - const platform = await window.electronAPI.getPlatform(); + const platform = window.electronAPI.getPlatform(); if (platform !== "darwin") { return false; } @@ -1086,7 +1086,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } try { - const platform = await window.electronAPI.getPlatform(); + const platform = window.electronAPI.getPlatform(); if (platform === "darwin" && cursorCaptureMode === "editable-overlay") { // The main process shows a native dialog that deep-links to the // Accessibility settings pane when access is missing, so we just stop @@ -1176,7 +1176,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { // `getUserMedia` calls is the dominant source of the mic-vs-video lag at the // start of the recording (issue #57). const screenCapture = (async (): Promise => { - const platform = await window.electronAPI.getPlatform(); + const platform = window.electronAPI.getPlatform(); if (platform === "win32") { // getDisplayMedia + setDisplayMediaRequestHandler (main.ts) supplies the diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index dbfb02179a..60f57bc504 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -138,7 +138,7 @@ export class GifExporter { const onWarning = (message: string) => warnings.push(message); try { - const platform = await getPlatform(); + const platform = getPlatform(); this.cleanup(); this.cancelled = false; diff --git a/src/utils/platformUtils.ts b/src/utils/platformUtils.ts index e41145ee46..0c0e7f211b 100644 --- a/src/utils/platformUtils.ts +++ b/src/utils/platformUtils.ts @@ -1,60 +1,33 @@ -let cachedPlatform: string | null = null; - /** - * Gets the current platform from Electron + * Gets the current platform from Electron. `process.platform` is a synchronous + * Node global available in both the main and renderer processes, so we read it + * directly here instead of bouncing through an IPC round-trip. */ -export const getPlatform = async (): Promise => { - if (cachedPlatform) return cachedPlatform; - - try { - const platform = await window.electronAPI.getPlatform(); - cachedPlatform = platform; - return platform; - } catch (error) { - console.warn("Failed to get platform from Electron, falling back to navigator:", error); - // Fallback for dev/testing - let fallbackPlatform = "win32"; - if (typeof navigator !== "undefined") { - if (/Mac|iPhone|iPad|iPod/.test(navigator.platform)) { - fallbackPlatform = "darwin"; - } else if (/Linux/.test(navigator.platform)) { - fallbackPlatform = "linux"; - } - } - - cachedPlatform = fallbackPlatform; - return fallbackPlatform; - } -}; +export function getPlatform(): NodeJS.Platform { + return process.platform; +} /** - * Detects if the current platform is macOS + * Detects if the current platform is macOS. */ -export const isMac = async (): Promise => { - const platform = await getPlatform(); - return platform === "darwin"; -}; +export const isMac = (): boolean => getPlatform() === "darwin"; /** - * Gets the modifier key symbol based on the platform + * Gets the modifier key symbol based on the platform. */ -export const getModifierKey = async (): Promise => { - return (await isMac()) ? "⌘" : "Ctrl"; -}; +export const getModifierKey = (): "⌘" | "Ctrl" => (isMac() ? "⌘" : "Ctrl"); /** - * Gets the shift key symbol based on the platform + * Gets the shift key symbol based on the platform. */ -export const getShiftKey = async (): Promise => { - return (await isMac()) ? "⇧" : "Shift"; -}; +export const getShiftKey = (): "⇧" | "Shift" => (isMac() ? "⇧" : "Shift"); /** - * Formats a keyboard shortcut for display based on the platform + * Formats a keyboard shortcut for display based on the platform. * @param keys Array of key combinations (e.g., ['mod', 'D'] or ['shift', 'mod', 'Scroll']) */ -export const formatShortcut = async (keys: string[]): Promise => { - const isMacPlatform = await isMac(); +export function formatShortcut(keys: string[]): string { + const isMacPlatform = isMac(); return keys .map((key) => { if (key.toLowerCase() === "mod") return isMacPlatform ? "⌘" : "Ctrl"; @@ -62,4 +35,4 @@ export const formatShortcut = async (keys: string[]): Promise => { return key; }) .join(" + "); -}; +} From fdc600da03db4209d20c786a94f8d77029755f5b Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 28 Jul 2026 19:59:38 +0200 Subject: [PATCH 2/3] fix(platform): read platform from preload, not the renderer's absent process global --- src/contexts/ShortcutsContext.tsx | 6 ++-- src/utils/platformUtils.ts | 47 ++++++++++++------------------- 2 files changed, 21 insertions(+), 32 deletions(-) diff --git a/src/contexts/ShortcutsContext.tsx b/src/contexts/ShortcutsContext.tsx index a1d895b407..91bd7f8d3f 100644 --- a/src/contexts/ShortcutsContext.tsx +++ b/src/contexts/ShortcutsContext.tsx @@ -30,9 +30,9 @@ export function useShortcuts(): ShortcutsContextValue { export function ShortcutsProvider({ children }: { children: ReactNode }) { const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS); - // `getIsMac()` is synchronous; the state still needs an effect so the value - // doesn't snapshot a wrong value during SSR/hydration (the module-level - // `process.platform` read happens once on first import). + // `getIsMac()` is synchronous, but it reads `window.electronAPI`, so keep it + // in an effect rather than in the initial state — that keeps the first render + // free of any dependency on preload having been installed. const [isMac, setIsMac] = useState(false); const [isConfigOpen, setIsConfigOpen] = useState(false); diff --git a/src/utils/platformUtils.ts b/src/utils/platformUtils.ts index 0c0e7f211b..4d933e865a 100644 --- a/src/utils/platformUtils.ts +++ b/src/utils/platformUtils.ts @@ -1,38 +1,27 @@ /** - * Gets the current platform from Electron. `process.platform` is a synchronous - * Node global available in both the main and renderer processes, so we read it - * directly here instead of bouncing through an IPC round-trip. + * Gets the current platform. + * + * The renderer runs with `contextIsolation: true` / `nodeIntegration: false`, + * so the Node `process` global does not exist here — it lives only in the + * preload's isolated world. `electron/preload.ts` snapshots `process.platform` + * once and exposes it as a plain string, which is what we read. + * + * Browser mode (`src/native/browserShim.ts`, `?browser`) has no `electronAPI`, + * so fall back to sniffing `navigator` rather than throwing. */ export function getPlatform(): NodeJS.Platform { - return process.platform; + const fromPreload = window.electronAPI?.getPlatform?.(); + if (fromPreload) return fromPreload as NodeJS.Platform; + + if (typeof navigator !== "undefined") { + const ua = `${navigator.platform ?? ""} ${navigator.userAgent ?? ""}`; + if (/Mac|iPhone|iPad|iPod/.test(ua)) return "darwin"; + if (/Linux|Android/.test(ua)) return "linux"; + } + return "win32"; } /** * Detects if the current platform is macOS. */ export const isMac = (): boolean => getPlatform() === "darwin"; - -/** - * Gets the modifier key symbol based on the platform. - */ -export const getModifierKey = (): "⌘" | "Ctrl" => (isMac() ? "⌘" : "Ctrl"); - -/** - * Gets the shift key symbol based on the platform. - */ -export const getShiftKey = (): "⇧" | "Shift" => (isMac() ? "⇧" : "Shift"); - -/** - * Formats a keyboard shortcut for display based on the platform. - * @param keys Array of key combinations (e.g., ['mod', 'D'] or ['shift', 'mod', 'Scroll']) - */ -export function formatShortcut(keys: string[]): string { - const isMacPlatform = isMac(); - return keys - .map((key) => { - if (key.toLowerCase() === "mod") return isMacPlatform ? "⌘" : "Ctrl"; - if (key.toLowerCase() === "shift") return isMacPlatform ? "⇧" : "Shift"; - return key; - }) - .join(" + "); -} From f5b7f3f903745c33f5c6e93b56f8ce3ddefb4b56 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 28 Jul 2026 20:06:58 +0200 Subject: [PATCH 3/3] test(platform): guard the renderer/browser platform read --- src/utils/platformUtils.test.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/utils/platformUtils.test.ts diff --git a/src/utils/platformUtils.test.ts b/src/utils/platformUtils.test.ts new file mode 100644 index 0000000000..bdc7772e07 --- /dev/null +++ b/src/utils/platformUtils.test.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { getPlatform, isMac } from "./platformUtils"; + +// The renderer has no Node `process` global (contextIsolation: true), and +// browser mode has no `electronAPI` at all. jsdom provides `process`, so a +// regression here is invisible to every other gate — this file is the guard. + +const original = window.electronAPI; + +afterEach(() => { + window.electronAPI = original; +}); + +describe("getPlatform", () => { + it("reads the value the preload exposes", () => { + window.electronAPI = { getPlatform: () => "darwin" } as typeof window.electronAPI; + expect(getPlatform()).toBe("darwin"); + expect(isMac()).toBe(true); + }); + + it("falls back to navigator when electronAPI is absent (browser mode)", () => { + // @ts-expect-error — browser mode genuinely has no electronAPI. + window.electronAPI = undefined; + expect(() => getPlatform()).not.toThrow(); + expect(typeof getPlatform()).toBe("string"); + }); +});