Skip to content
Closed
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
27 changes: 22 additions & 5 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ type RendererMarketplaceReviewStatus =
type RendererMarketplaceSearchResult =
import("./extensions/extensionTypes").MarketplaceSearchResult;
type RendererRecordingSessionData = import("./ipc/types").RecordingSessionData;
type RendererLaunchShortcutAction = import("./ipc/shortcutTypes").LaunchShortcutAction;

interface RendererFfmpegAudioMuxMetrics {
tempVideoWriteMs?: number;
Expand Down Expand Up @@ -558,7 +559,7 @@ interface Window {
startDelayMsByPath?: Record<string, number>;
error?: string;
}>;
setRecordingState: (recording: boolean) => Promise<void>;
setRecordingState: (recording: boolean, paused?: boolean) => Promise<void>;
getCursorTelemetry: (videoPath?: string) => Promise<{
success: boolean;
samples: CursorTelemetryPoint[];
Expand All @@ -579,10 +580,13 @@ interface Window {
cursors: Record<string, SystemCursorAsset>;
error?: string;
}>;
onStopRecordingFromTray: (callback: () => void) => () => void;
onRecordingStateChanged: (
callback: (state: { recording: boolean; sourceName: string }) => void,
) => () => void;
onStopRecordingFromTray: (callback: () => void) => () => void;
onTrayRecordingCommand: (
callback: (command: "start" | "pause" | "resume" | "stop") => void,
) => () => void;
onRecordingStateChanged: (
callback: (state: { recording: boolean; paused: boolean; sourceName: string }) => void,
) => () => void;
onRecordingSessionChanged: (
callback: (session: RendererRecordingSessionData | null) => void,
) => () => void;
Expand Down Expand Up @@ -829,6 +833,19 @@ interface Window {
}>;
getShortcuts: () => Promise<Record<string, unknown> | null>;
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>;
registerLaunchGlobalShortcuts: (config: unknown) => Promise<{
success: boolean;
unsupported?: boolean;
failedRegistrations?: Array<{
action: RendererLaunchShortcutAction;
accelerator: string;
}>;
error?: string;
}>;
unregisterLaunchGlobalShortcuts: () => Promise<{ success: boolean; error?: string }>;
onLaunchShortcutTriggered: (
callback: (action: RendererLaunchShortcutAction) => void,
) => () => void;
getAppSetting: (key: string) => unknown;
setAppSetting: (key: string, value: unknown) => boolean;
setHasUnsavedChanges: (hasChanges: boolean) => void;
Expand Down
2 changes: 1 addition & 1 deletion electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export function registerIpcHandlers(
createSourceSelectorWindow: () => BrowserWindow,
_getMainWindow: () => BrowserWindow | null,
getSourceSelectorWindow: () => BrowserWindow | null,
onRecordingStateChange?: (recording: boolean, sourceName: string) => void,
onRecordingStateChange?: (recording: boolean, paused: boolean, sourceName: string) => void,
) {
registerSourceHandlers({
createEditorWindow,
Expand Down
1 change: 1 addition & 0 deletions electron/ipc/recording/mac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export function attachNativeCaptureLifecycle(process: ChildProcessWithoutNullStr
if (!window.isDestroyed()) {
window.webContents.send("recording-state-changed", {
recording: false,
paused: false,
sourceName,
});
}
Expand Down
1 change: 1 addition & 0 deletions electron/ipc/recording/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ export function attachWindowsCaptureLifecycle(proc: ChildProcessWithoutNullStrea
if (!window.isDestroyed()) {
window.webContents.send("recording-state-changed", {
recording: false,
paused: false,
sourceName,
});
}
Expand Down
7 changes: 4 additions & 3 deletions electron/ipc/register/recording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ async function resolveExistingPath(...candidates: Array<string | null | undefine
}

export function registerRecordingHandlers(
onRecordingStateChange?: (recording: boolean, sourceName: string) => void,
onRecordingStateChange?: (recording: boolean, paused: boolean, sourceName: string) => void,
) {
ipcMain.handle(
"start-native-screen-recording",
Expand Down Expand Up @@ -1810,7 +1810,7 @@ export function registerRecordingHandlers(
}
});

ipcMain.handle("set-recording-state", (_, recording: boolean) => {
ipcMain.handle("set-recording-state", (_, recording: boolean, paused = false) => {
if (recording) {
stopCursorCapture();
stopInteractionCapture();
Expand Down Expand Up @@ -1844,13 +1844,14 @@ export function registerRecordingHandlers(
if (!window.isDestroyed()) {
window.webContents.send("recording-state-changed", {
recording,
paused,
sourceName: source.name,
});
}
});

if (onRecordingStateChange) {
onRecordingStateChange(recording, source.name);
onRecordingStateChange(recording, paused, source.name);
}
});

Expand Down
135 changes: 133 additions & 2 deletions electron/ipc/register/settings.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
import { readFileSync, writeFileSync } from "node:fs";
import fs from "node:fs/promises";
import { app, ipcMain } from "electron";
import { app, globalShortcut, ipcMain } from "electron";
import { hideCursor } from "../../cursorHider";
import { closeCountdownWindow, createCountdownWindow, getCountdownWindow } from "../../windows";
import {
closeCountdownWindow,
createCountdownWindow,
getCountdownWindow,
getHudOverlayWindow,
} from "../../windows";
import {
APP_SETTINGS_FILE,
COUNTDOWN_SETTINGS_FILE,
RECORDINGS_SETTINGS_FILE,
SHORTCUTS_FILE,
} from "../constants";
import {
isLaunchShortcutAction,
type LaunchShortcutAction,
type ShortcutBinding,
} from "../shortcutTypes";
import {
countdownCancelled,
countdownInProgress,
Expand Down Expand Up @@ -64,7 +74,70 @@ function hasAppSetting(store: Record<string, unknown>, key: string): boolean {
return Reflect.getOwnPropertyDescriptor(store, key) !== undefined;
}

let launchShortcutRegisteredAccelerators: string[] = [];
let launchShortcutWillQuitCleanupRegistered = false;

const ELECTRON_KEY_MAP: Record<string, string> = {
arrowup: "Up",
arrowdown: "Down",
arrowleft: "Left",
arrowright: "Right",
escape: "Escape",
backspace: "Backspace",
delete: "Delete",
enter: "Enter",
tab: "Tab",
};

function toElectronAccelerator(binding: ShortcutBinding): string | null {
const rawKey = binding.key;
if (rawKey === " ") {
const parts: string[] = [];
if (binding.ctrl) parts.push("CommandOrControl");
if (binding.shift) parts.push("Shift");
if (binding.alt) parts.push("Alt");
parts.push("Space");
return parts.join("+");
}

const key = rawKey?.trim().toLowerCase();
if (!key) {
return null;
}

const mappedKey =
ELECTRON_KEY_MAP[key] ??
(key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1));
const parts: string[] = [];
if (binding.ctrl) parts.push("CommandOrControl");
if (binding.shift) parts.push("Shift");
if (binding.alt) parts.push("Alt");
parts.push(mappedKey);
return parts.join("+");
}

function unregisterLaunchGlobalShortcuts() {
for (const accelerator of launchShortcutRegisteredAccelerators) {
globalShortcut.unregister(accelerator);
}
launchShortcutRegisteredAccelerators = [];
}

function notifyLaunchShortcutTriggered(action: LaunchShortcutAction) {
const hud = getHudOverlayWindow();
if (!hud || hud.isDestroyed()) {
return;
}

hud.webContents.send("launch-shortcut-triggered", action);
}

export function registerSettingsHandlers() {
if (!launchShortcutWillQuitCleanupRegistered) {
launchShortcutWillQuitCleanupRegistered = true;
app.on("will-quit", unregisterLaunchGlobalShortcuts);
}

ipcMain.handle("app:getVersion", () => {
return app.getVersion();
});
Expand Down Expand Up @@ -139,6 +212,64 @@ export function registerSettingsHandlers() {
}
});

ipcMain.handle("register-launch-global-shortcuts", async (_, config: unknown) => {
try {
unregisterLaunchGlobalShortcuts();

if (process.platform !== "darwin") {
return { success: true, unsupported: true };
}

if (!config || typeof config !== "object") {
return { success: true };
}

const failedRegistrations: Array<{
action: LaunchShortcutAction;
accelerator: string;
}> = [];

for (const [action, binding] of Object.entries(
config as Record<string, ShortcutBinding>,
)) {
if (!isLaunchShortcutAction(action)) {
console.warn("Ignoring unknown launch shortcut action in config:", action);
continue;
}

const accelerator = toElectronAccelerator(binding);
if (!accelerator) {
continue;
}

const registered = globalShortcut.register(accelerator, () => {
Comment on lines +223 to +245
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate each shortcut binding before converting it.

Line 240 calls toElectronAccelerator(binding) without first proving that binding is an object with a string key. Because Lines 205-213 persist arbitrary JSON, one corrupt entry can throw on binding.key, hit the catch at Line 259, and prevent every remaining valid launch shortcut from registering.

Suggested hardening
 			for (const [action, binding] of Object.entries(
 				config as Record<string, ShortcutBinding>,
 			)) {
 				if (!isLaunchShortcutAction(action)) {
 					console.warn("Ignoring unknown launch shortcut action in config:", action);
 					continue;
 				}
+
+				if (
+					!binding ||
+					typeof binding !== "object" ||
+					typeof (binding as { key?: unknown }).key !== "string"
+				) {
+					console.warn("Ignoring malformed launch shortcut binding:", { action, binding });
+					continue;
+				}
 
 				const accelerator = toElectronAccelerator(binding);
 				if (!accelerator) {
 					continue;
 				}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ipc/register/settings.ts` around lines 223 - 245, The loop over
config entries calls toElectronAccelerator(binding) without validating that
binding is an object with the expected shape, so a malformed persisted entry can
throw and abort registering all shortcuts; before calling toElectronAccelerator
(inside the for...of that uses isLaunchShortcutAction), check that binding is an
object and binding.key is a non-empty string (and any other expected fields),
log/console.warn and continue for invalid entries, and/or wrap the per-binding
conversion/registration (the call to toElectronAccelerator and
globalShortcut.register) in its own try/catch so one bad entry doesn't prevent
others from registering.

notifyLaunchShortcutTriggered(action);
});

if (registered) {
launchShortcutRegisteredAccelerators.push(accelerator);
} else {
const failedRegistration = { action, accelerator };
failedRegistrations.push(failedRegistration);
console.warn("Failed to register launch global shortcut:", failedRegistration);
}
}

return { success: true, failedRegistrations };
} catch (error) {
return { success: false, error: String(error) };
}
});

ipcMain.handle("unregister-launch-global-shortcuts", async () => {
try {
unregisterLaunchGlobalShortcuts();
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
});

// ---------------------------------------------------------------------------
// Countdown timer before recording
// ---------------------------------------------------------------------------
Expand Down
20 changes: 20 additions & 0 deletions electron/ipc/shortcutTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export const LAUNCH_SHORTCUT_ACTIONS = [
"startRecording",
"stopRecording",
"pauseRecording",
"resumeRecording",
"muteMicrophone",
] as const;

export type LaunchShortcutAction = (typeof LAUNCH_SHORTCUT_ACTIONS)[number];

export function isLaunchShortcutAction(action: string): action is LaunchShortcutAction {
return (LAUNCH_SHORTCUT_ACTIONS as readonly string[]).includes(action);
}

export type ShortcutBinding = {
key: string;
ctrl?: boolean;
shift?: boolean;
alt?: boolean;
};
Loading