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
7 changes: 6 additions & 1 deletion electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ interface Window {
message?: string;
error?: string;
}>;
setRecordingState: (recording: boolean, recordingId?: number) => Promise<void>;
setRecordingState: (
state: "recording" | "paused" | "stopped",
recordingId?: number,
elapsedMs?: number,
) => Promise<void>;
discardCursorTelemetry: (recordingId: number) => Promise<void>;
getCursorTelemetry: (videoPath?: string) => Promise<{
success: boolean;
Expand All @@ -72,6 +76,7 @@ interface Window {
error?: string;
}>;
onStopRecordingFromTray: (callback: () => void) => () => void;
onTogglePauseRecordingFromTray: (callback: () => void) => () => void;
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>;
saveExportedVideo: (
videoData: ArrayBuffer,
Expand Down
59 changes: 27 additions & 32 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ export function registerIpcHandlers(
getMainWindow: () => BrowserWindow | null,
getSourceSelectorWindow: () => BrowserWindow | null,
getCountdownOverlayWindow: () => BrowserWindow | null,
onRecordingStateChange?: (recording: boolean, sourceName: string) => void,
onRecordingStateChange?: (state: "recording" | "paused" | "stopped", sourceName: string) => void,
switchToHud?: () => void,
) {
const supportsWindowOpacity = process.platform !== "linux";
Expand Down Expand Up @@ -700,28 +700,33 @@ export function registerIpcHandlers(
}
});

ipcMain.handle("set-recording-state", (_, recording: boolean, recordingId?: number) => {
if (recording) {
stopCursorCapture();
// The renderer is the source of truth for the recording id (it
// uses the same id as the saved fileName). Fall back to a
// timestamp only if the renderer didn't supply one, so the
// buffer always has a stable key per session.
const id = typeof recordingId === "number" ? recordingId : Date.now();
cursorTelemetryBuffer.startSession(id);
cursorCaptureStartTimeMs = Date.now();
sampleCursorPoint();
cursorCaptureInterval = setInterval(sampleCursorPoint, CURSOR_SAMPLE_INTERVAL_MS);
} else {
stopCursorCapture();
cursorTelemetryBuffer.endSession();
}
ipcMain.handle(
"set-recording-state",
(_, state: "recording" | "paused" | "stopped", recordingId?: number, elapsedMs?: number) => {
if (state === "recording") {
stopCursorCapture();
// The renderer is the source of truth for the recording id (it
// uses the same id as the saved fileName). Fall back to a
// timestamp only if the renderer didn't supply one, so the
// buffer always has a stable key per session.
const id = typeof recordingId === "number" ? recordingId : Date.now();
cursorTelemetryBuffer.startSession(id);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve telemetry session when resuming recording

Avoid calling startSession() on every transition to "recording": this path now runs both at initial start and after pause, and startSession() clears the active sample buffer. In a pause→resume flow, all cursor telemetry captured before the pause is dropped, so the final overlay only contains post-resume movement. This breaks cursor/video sync for any recording that is paused at least once.

Useful? React with 👍 / 👎.

cursorCaptureStartTimeMs = Date.now() - (elapsedMs || 0);
sampleCursorPoint();
cursorCaptureInterval = setInterval(sampleCursorPoint, CURSOR_SAMPLE_INTERVAL_MS);
} else if (state === "paused") {
stopCursorCapture();
} else {
stopCursorCapture();
cursorTelemetryBuffer.endSession();
}

const source = selectedSource || { name: "Screen" };
if (onRecordingStateChange) {
onRecordingStateChange(recording, source.name);
}
});
const source = selectedSource || { name: "Screen" };
if (onRecordingStateChange) {
onRecordingStateChange(state, source.name);
}
},
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

ipcMain.handle("discard-cursor-telemetry", (_, recordingId: number) => {
cursorTelemetryBuffer.discardBatch(recordingId);
Expand Down Expand Up @@ -1128,14 +1133,4 @@ export function registerIpcHandlers(
return null;
}
});

ipcMain.handle("save-shortcuts", async (_, shortcuts: unknown) => {
try {
await fs.writeFile(SHORTCUTS_FILE, JSON.stringify(shortcuts, null, 2), "utf-8");
return { success: true };
} catch (error) {
console.error("Failed to save shortcuts:", error);
return { success: false, error: String(error) };
}
});
}
127 changes: 99 additions & 28 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
app,
BrowserWindow,
dialog,
globalShortcut,
ipcMain,
Menu,
nativeImage,
Expand Down Expand Up @@ -217,35 +218,59 @@ function getTrayIcon(filename: string, size: number) {
});
}

function updateTrayMenu(recording: boolean = false) {
function updateTrayMenu(state: "recording" | "paused" | "stopped" = "stopped") {
if (!tray) return;
const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon;
const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "OpenScreen";
const menuTemplate = recording
? [
{
label: mainT("common", "actions.stopRecording") || "Stop Recording",
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("stop-recording-from-tray");
}
},

const isRecording = state === "recording";
const isPaused = state === "paused";
// TODO: Create an actual paused icon later, or use default/recording for now. Let's just use default or an indicator?
// Using default tray icon for paused visually differentiates it from the red recording dot
const trayIcon = isRecording ? recordingTrayIcon : defaultTrayIcon;

let trayToolTip = "OpenScreen";
if (isRecording) trayToolTip = `Recording: ${selectedSourceName}`;
if (isPaused) trayToolTip = `Paused: ${selectedSourceName}`;

let menuTemplate: Parameters<typeof Menu.buildFromTemplate>[0] = [];

if (isRecording || isPaused) {
menuTemplate = [
{
label: isPaused
? mainT("common", "actions.resumeRecording") || "Resume Recording"
: mainT("common", "actions.pauseRecording") || "Pause Recording",
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("toggle-pause-recording-from-tray");
}
},
]
: [
{
label: mainT("common", "actions.open") || "Open",
click: () => {
showMainWindow();
},
},
{
label: mainT("common", "actions.stopRecording") || "Stop Recording",
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("stop-recording-from-tray");
}
},
{
label: mainT("common", "actions.quit") || "Quit",
click: () => {
app.quit();
},
},
];
} else {
menuTemplate = [
{
label: mainT("common", "actions.open") || "Open",
click: () => {
showMainWindow();
},
},
{
label: mainT("common", "actions.quit") || "Quit",
click: () => {
app.quit();
},
];
},
];
}

tray.setImage(trayIcon);
tray.setToolTip(trayToolTip);
tray.setContextMenu(Menu.buildFromTemplate(menuTemplate));
Expand Down Expand Up @@ -410,18 +435,64 @@ app.whenReady().then(async () => {
showMainWindow();
}

async function updateGlobalShortcuts() {
globalShortcut.unregisterAll();
let config: any = {};
try {
const data = await fs.readFile(path.join(app.getPath("userData"), "shortcuts.json"), "utf-8");
config = JSON.parse(data);
} catch {}

const binding = config.togglePauseRecording || { key: "p", ctrl: true, alt: true };
const parts = [];
if (binding.ctrl) parts.push("CommandOrControl");
if (binding.alt) parts.push("Alt");
if (binding.shift) parts.push("Shift");
const key = binding.key && binding.key.length === 1 ? binding.key.toUpperCase() : binding.key;
if (key) {
parts.push(key);
const accelerator = parts.join("+");
try {
globalShortcut.register(accelerator, () => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Check global shortcut registration result

globalShortcut.register() returns false when an accelerator is unavailable (it does not throw), but this code ignores the return value and only handles exceptions. If the chosen binding is already taken by another app/OS, the UI will appear to save successfully while pause/resume never triggers globally, and because unregisterAll() runs first this can also leave users with no working global pause shortcut.

Useful? React with 👍 / 👎.

if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("toggle-pause-recording-from-tray");
}
});
} catch (e) {
console.error("Failed to register global shortcut:", accelerator, e);
}
}
}

await updateGlobalShortcuts();

ipcMain.handle("save-shortcuts", async (_, shortcuts: unknown) => {
try {
await fs.writeFile(
path.join(app.getPath("userData"), "shortcuts.json"),
JSON.stringify(shortcuts, null, 2),
"utf-8",
);
await updateGlobalShortcuts();
return { success: true };
} catch (error) {
console.error("Failed to save shortcuts:", error);
return { success: false, error: String(error) };
}
});

registerIpcHandlers(
createEditorWindowWrapper,
createSourceSelectorWindowWrapper,
createCountdownOverlayWindowWrapper,
() => mainWindow,
() => sourceSelectorWindow,
() => countdownOverlayWindow,
(recording: boolean, sourceName: string) => {
(state: "recording" | "paused" | "stopped", sourceName: string) => {
selectedSourceName = sourceName;
if (!tray) createTray();
updateTrayMenu(recording);
if (!recording) {
updateTrayMenu(state);
if (state === "stopped") {
showMainWindow();
}
},
Expand Down
13 changes: 11 additions & 2 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
getRecordedVideoPath: () => {
return ipcRenderer.invoke("get-recorded-video-path");
},
setRecordingState: (recording: boolean, recordingId?: number) => {
return ipcRenderer.invoke("set-recording-state", recording, recordingId);
setRecordingState: (
state: "recording" | "paused" | "stopped",
recordingId?: number,
elapsedMs?: number,
) => {
return ipcRenderer.invoke("set-recording-state", state, recordingId, elapsedMs);
},
getCursorTelemetry: (videoPath?: string) => {
return ipcRenderer.invoke("get-cursor-telemetry", videoPath);
Expand All @@ -65,6 +69,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.on("stop-recording-from-tray", listener);
return () => ipcRenderer.removeListener("stop-recording-from-tray", listener);
},
onTogglePauseRecordingFromTray: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("toggle-pause-recording-from-tray", listener);
return () => ipcRenderer.removeListener("toggle-pause-recording-from-tray", listener);
},
openExternalUrl: (url: string) => {
return ipcRenderer.invoke("open-external-url", url);
},
Expand Down
28 changes: 23 additions & 5 deletions src/hooks/useScreenRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
setElapsedSeconds(0);
accumulatedDurationMs.current = 0;
segmentStartedAt.current = null;
window.electronAPI?.setRecordingState(false);
window.electronAPI?.setRecordingState("stopped");

void (async () => {
try {
Expand Down Expand Up @@ -409,17 +409,25 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
});

useEffect(() => {
let cleanup: (() => void) | undefined;
let stopCleanup: (() => void) | undefined;
let pauseCleanup: (() => void) | undefined;

if (window.electronAPI?.onStopRecordingFromTray) {
cleanup = window.electronAPI.onStopRecordingFromTray(() => {
stopCleanup = window.electronAPI.onStopRecordingFromTray(() => {
stopRecording.current();
});
}

if (window.electronAPI?.onTogglePauseRecordingFromTray) {
pauseCleanup = window.electronAPI.onTogglePauseRecordingFromTray(() => {
togglePaused();
});
}

return () => {
const activeRunId = countdownRunId.current;
if (cleanup) cleanup();
if (stopCleanup) stopCleanup();
if (pauseCleanup) pauseCleanup();
countdownRunId.current += 1;
void safeHideCountdownOverlay(activeRunId);
allowAutoFinalize.current = false;
Expand Down Expand Up @@ -773,7 +781,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
setRecording(true);
setPaused(false);
setElapsedSeconds(0);
window.electronAPI?.setRecordingState(true, recordingId.current);
window.electronAPI?.setRecordingState("recording", recordingId.current, 0);

const activeScreenRecorder = screenRecorder.current;
const activeWebcamRecorder = webcamRecorder.current;
Expand Down Expand Up @@ -830,6 +838,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
segmentStartedAt.current = Date.now();
setPaused(false);
window.electronAPI?.setRecordingState(
"recording",
recordingId.current,
accumulatedDurationMs.current,
);
} catch (error) {
console.error("Failed to resume recording:", error);
}
Expand All @@ -849,6 +862,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
activeWebcamRecorder.pause();
}
setPaused(true);
window.electronAPI?.setRecordingState(
"paused",
recordingId.current,
accumulatedDurationMs.current,
);
} catch (error) {
console.error("Failed to pause recording:", error);
}
Expand Down
19 changes: 19 additions & 0 deletions src/lib/cursorTelemetryBuffer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,25 @@ describe("createCursorTelemetryBuffer", () => {
expect(batch?.samples.map((s) => s.timeMs)).toEqual([1]);
});

it("preserves active session samples when resuming with the same recordingId", () => {
const buf = createCursorTelemetryBuffer({ maxActiveSamples: 10 });

buf.startSession(1);
buf.push(sample(1));
buf.push(sample(2));

// Resuming the same session (e.g. after a pause) should not clear the buffer
buf.startSession(1);
buf.push(sample(3));
buf.endSession();

expect(buf.pendingCount).toBe(1);
const batch = buf.takeNextBatch();
expect(batch?.recordingId).toBe(1);
expect(batch?.samples).toHaveLength(3);
expect(batch?.samples.map((s) => s.timeMs)).toEqual([1, 2, 3]);
});

it("discardBatch(id) drops only the batch produced by that recording id", () => {
const buf = createCursorTelemetryBuffer({ maxActiveSamples: 10 });

Expand Down
3 changes: 3 additions & 0 deletions src/lib/cursorTelemetryBuffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ export function createCursorTelemetryBuffer(

return {
startSession(recordingId) {
if (activeRecordingId === recordingId) {
return;
}
Comment on lines +151 to +153
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

startSession behavior changed, but contract docs still say the old thing

Line 151 makes same-id calls a no-op (nice), but the startSession docs still describe repeated calls as clearing active samples. lowkey risky for future callers relying on comments over code.

nit: cleaner doc patch
- * Begin a new recording session under the given `recordingId`. Clears
- * any in-progress active samples (without touching already-completed
- * pending batches). Safe to call repeatedly — e.g. a rapid Stop →
- * Record sequence — and the most recent id wins.
+ * Begin a new recording session under the given `recordingId`.
+ * If called with the same `recordingId` as the current active session,
+ * this is a no-op (used by pause/resume flows). If called with a different
+ * id, any in-progress active samples are cleared (without touching
+ * already-completed pending batches).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/cursorTelemetryBuffer.ts` around lines 151 - 153, The implementation
of startSession now treats repeated calls with the same recordingId as a no-op
via the activeRecordingId check, but the startSession documentation still states
that repeated calls clear active samples; update the contract to match behavior
or change the code to match docs. Specifically, either adjust the comment/docs
for startSession to state that if recordingId === activeRecordingId the call is
a no-op and does not clear samples, or modify the startSession logic (remove or
adjust the activeRecordingId === recordingId early return in the startSession
function) so repeated calls will clear active samples as documented; reference
startSession, activeRecordingId and recordingId when making the change so the
behavior and docs remain consistent.

active = [];
activeRecordingId = recordingId;
},
Expand Down
Loading
Loading