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 64504872bc..207ef5e2c0 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -3282,10 +3282,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..91bd7f8d3f 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, 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); 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.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"); + }); +}); diff --git a/src/utils/platformUtils.ts b/src/utils/platformUtils.ts index e41145ee46..4d933e865a 100644 --- a/src/utils/platformUtils.ts +++ b/src/utils/platformUtils.ts @@ -1,65 +1,27 @@ -let cachedPlatform: string | null = null; - /** - * Gets the current platform from Electron + * 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 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"; - } - } +export function getPlatform(): NodeJS.Platform { + const fromPreload = window.electronAPI?.getPlatform?.(); + if (fromPreload) return fromPreload as NodeJS.Platform; - cachedPlatform = fallbackPlatform; - return fallbackPlatform; + 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"; } -}; - -/** - * Detects if the current platform is macOS - */ -export const isMac = async (): Promise => { - const platform = await getPlatform(); - return platform === "darwin"; -}; - -/** - * Gets the modifier key symbol based on the platform - */ -export const getModifierKey = async (): Promise => { - return (await isMac()) ? "⌘" : "Ctrl"; -}; - -/** - * Gets the shift key symbol based on the platform - */ -export const getShiftKey = async (): Promise => { - return (await isMac()) ? "⇧" : "Shift"; -}; + return "win32"; +} /** - * Formats a keyboard shortcut for display based on the platform - * @param keys Array of key combinations (e.g., ['mod', 'D'] or ['shift', 'mod', 'Scroll']) + * Detects if the current platform is macOS. */ -export const formatShortcut = async (keys: string[]): Promise => { - const isMacPlatform = await isMac(); - return keys - .map((key) => { - if (key.toLowerCase() === "mod") return isMacPlatform ? "⌘" : "Ctrl"; - if (key.toLowerCase() === "shift") return isMacPlatform ? "⇧" : "Shift"; - return key; - }) - .join(" + "); -}; +export const isMac = (): boolean => getPlatform() === "darwin";