Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ interface Window {
onMenuSaveProjectAs: (callback: () => void) => () => void;
quitApp: () => void;
setTitleBarOverlay: (color: string, symbolColor: string) => void;
getPlatform: () => Promise<string>;
getPlatform: () => string;
revealInFolder: (
filePath: string,
) => Promise<{ success: boolean; error?: string; message?: string }>;
Expand Down
4 changes: 0 additions & 4 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down Expand Up @@ -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);
},
Expand Down
2 changes: 1 addition & 1 deletion src/components/launch/LaunchWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
9 changes: 4 additions & 5 deletions src/contexts/ShortcutsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,14 @@ export function useShortcuts(): ShortcutsContextValue {

export function ShortcutsProvider({ children }: { children: ReactNode }) {
const [shortcuts, setShortcuts] = useState<ShortcutsConfig>(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?.()
Expand Down
8 changes: 4 additions & 4 deletions src/hooks/useScreenRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<MediaStream> => {
const platform = await window.electronAPI.getPlatform();
const platform = window.electronAPI.getPlatform();

if (platform === "win32") {
// getDisplayMedia + setDisplayMediaRequestHandler (main.ts) supplies the
Expand Down
2 changes: 1 addition & 1 deletion src/lib/exporter/gifExporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
27 changes: 27 additions & 0 deletions src/utils/platformUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
78 changes: 20 additions & 58 deletions src/utils/platformUtils.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
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<boolean> => {
const platform = await getPlatform();
return platform === "darwin";
};

/**
* Gets the modifier key symbol based on the platform
*/
export const getModifierKey = async (): Promise<string> => {
return (await isMac()) ? "⌘" : "Ctrl";
};

/**
* Gets the shift key symbol based on the platform
*/
export const getShiftKey = async (): Promise<string> => {
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<string> => {
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";
Loading