Skip to content
Open
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
4 changes: 4 additions & 0 deletions electron-builder.json5
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
{
"from": "public/wallpapers",
"to": "assets/wallpapers"
},
{
"from": "public/builtin-extensions",
"to": "builtin-extensions"
}
],
"publish": [
Expand Down
5 changes: 5 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,11 @@ interface Window {
message?: string;
error?: string;
}>;
getKeystrokeTelemetry: (videoPath?: string) => Promise<{
success: boolean;
events: Array<{ timeMs: number; key: string; modifiers: string[] }>;
error?: string;
}>;
getSystemCursorAssets: () => Promise<{
success: boolean;
cursors: Record<string, SystemCursorAsset>;
Expand Down
1 change: 1 addition & 0 deletions electron/ipc/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ export const COMPANION_AUDIO_LAYOUTS = [
export const CURSOR_TELEMETRY_VERSION = 2;
export const CURSOR_SAMPLE_INTERVAL_MS = 33;
export const MAX_CURSOR_SAMPLES = 60 * 60 * 30; // 1 hour @ 30Hz
export const KEYSTROKE_TELEMETRY_VERSION = 1;
45 changes: 44 additions & 1 deletion electron/ipc/cursor/interaction.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import type { HookMouseEvent, UiohookLike, UiohookModuleNamespace, CursorInteractionType } from "../types";
import type { HookKeyboardEvent, HookMouseEvent, UiohookLike, UiohookModuleNamespace, CursorInteractionType } from "../types";
import {
isCursorCaptureActive,
interactionCaptureCleanup,
Expand All @@ -18,8 +18,35 @@ import {
getHookCursorScreenPoint,
isCursorCapturePaused,
pushCursorSample,
pushKeystroke,
} from "./telemetry";

// Maps uiohook-napi keycodes to Web KeyboardEvent.key values. Values match
// uiohook-napi's UiohookKey constants. Modifier keys are intentionally absent —
// modifiers come from the event's shiftKey/ctrlKey/altKey/metaKey booleans, and
// bare modifier presses map to null here so they aren't emitted on their own.
const KEYCODE_MAP: Record<number, string> = {
1: "Escape", 2: "1", 3: "2", 4: "3", 5: "4", 6: "5", 7: "6", 8: "7", 9: "8", 10: "9",
11: "0", 12: "-", 13: "=", 14: "Backspace", 15: "Tab",
16: "q", 17: "w", 18: "e", 19: "r", 20: "t", 21: "y", 22: "u", 23: "i", 24: "o", 25: "p",
26: "[", 27: "]", 28: "Enter",
30: "a", 31: "s", 32: "d", 33: "f", 34: "g", 35: "h", 36: "j", 37: "k", 38: "l",
39: ";", 40: "'", 41: "`", 43: "\\",
44: "z", 45: "x", 46: "c", 47: "v", 48: "b", 49: "n", 50: "m",
51: ",", 52: ".", 53: "/", 57: " ",
58: "CapsLock",
59: "F1", 60: "F2", 61: "F3", 62: "F4", 63: "F5", 64: "F6",
65: "F7", 66: "F8", 67: "F9", 68: "F10", 87: "F11", 88: "F12",
71: "7", 72: "8", 73: "9", 75: "4", 76: "5", 77: "6", 79: "1", 80: "2", 81: "3", 82: "0",
3655: "Home", 3657: "PageUp", 3663: "End", 3665: "PageDown", 3666: "Insert", 3667: "Delete",
57416: "ArrowUp", 57419: "ArrowLeft", 57421: "ArrowRight", 57424: "ArrowDown",
};

function keycodeToKey(keycode: number | undefined): string | null {
if (keycode === undefined) return null;
return KEYCODE_MAP[keycode] ?? null;
}

const nodeRequire = createRequire(import.meta.url);

export function normalizeHookMouseButton(rawButton: unknown): 1 | 2 | 3 {
Expand Down Expand Up @@ -271,8 +298,22 @@ export async function startInteractionCapture() {
setLinuxCursorScreenPoint({ x: point.x, y: point.y, updatedAt: Date.now() });
};

const onKeyDown = (event: HookKeyboardEvent) => {
if (!isCursorCaptureActive || isCursorCapturePaused()) return;
const key = keycodeToKey(event.keycode);
if (!key) return; // bare modifiers map to null and are skipped
const modifiers: string[] = [];
if (event.ctrlKey) modifiers.push("Control");
if (event.altKey) modifiers.push("Alt");
if (event.shiftKey) modifiers.push("Shift");
if (event.metaKey) modifiers.push("Meta");
const timeMs = getCursorCaptureElapsedMs();
pushKeystroke({ timeMs, key, modifiers });
};

hook.on("mousedown", onMouseDown);
hook.on("mouseup", onMouseUp);
hook.on("keydown", onKeyDown);
if (process.platform === "linux") {
hook.on("mousemove", onMouseMove);
}
Expand All @@ -282,12 +323,14 @@ export async function startInteractionCapture() {
if (typeof hook.off === "function") {
hook.off("mousedown", onMouseDown);
hook.off("mouseup", onMouseUp);
hook.off("keydown", onKeyDown);
if (process.platform === "linux") {
hook.off("mousemove", onMouseMove);
}
} else if (typeof hook.removeListener === "function") {
hook.removeListener("mousedown", onMouseDown);
hook.removeListener("mouseup", onMouseUp);
hook.removeListener("keydown", onKeyDown);
if (process.platform === "linux") {
hook.removeListener("mousemove", onMouseMove);
}
Expand Down
57 changes: 57 additions & 0 deletions electron/ipc/cursor/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,3 +326,60 @@ export function startCursorSampling() {
export { CURSOR_SAMPLE_INTERVAL_MS } from "../constants";
// Re-export for consumers that use it from this module
export { getTelemetryPathForVideo } from "../utils";

// ── Keystroke telemetry ───────────────────────────────────────────────────────

import { KEYSTROKE_TELEMETRY_VERSION } from "../constants";
import { activeKeystrokeEvents, setActiveKeystrokeEvents } from "../state";
import type { KeystrokeEvent } from "../types";
import { getKeystrokePathForVideo } from "../utils";

export function pushKeystroke(event: KeystrokeEvent): void {
setActiveKeystrokeEvents([...activeKeystrokeEvents, event]);
}

export function getActiveKeystrokes(): KeystrokeEvent[] {
return activeKeystrokeEvents;
}

export async function saveKeystrokeTelemetry(videoPath: string): Promise<void> {
const events = activeKeystrokeEvents;
const sidecarPath = getKeystrokePathForVideo(videoPath);

if (events.length === 0) {
await fs.rm(sidecarPath, { force: true });
return;
}

await fs.writeFile(
sidecarPath,
JSON.stringify({ version: KEYSTROKE_TELEMETRY_VERSION, events }, null, 2),
"utf-8",
);
}

export async function loadKeystrokeTelemetry(videoPath: string): Promise<KeystrokeEvent[]> {
const sidecarPath = getKeystrokePathForVideo(videoPath);

try {
const raw = await fs.readFile(sidecarPath, "utf-8");
const parsed = JSON.parse(raw);
if (!parsed || parsed.version !== KEYSTROKE_TELEMETRY_VERSION || !Array.isArray(parsed.events)) {
return [];
}
return parsed.events.filter(
(e: unknown): e is KeystrokeEvent =>
e !== null &&
typeof e === "object" &&
typeof (e as KeystrokeEvent).timeMs === "number" &&
typeof (e as KeystrokeEvent).key === "string" &&
Array.isArray((e as KeystrokeEvent).modifiers),
);
Comment on lines +370 to +377

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

Tighten modifier element validation in loader.

Line 376 only checks that modifiers is an array; non-string elements still pass and can propagate malformed telemetry across the IPC boundary.

Suggested fix
 		return parsed.events.filter(
 			(e: unknown): e is KeystrokeEvent =>
 				e !== null &&
 				typeof e === "object" &&
 				typeof (e as KeystrokeEvent).timeMs === "number" &&
 				typeof (e as KeystrokeEvent).key === "string" &&
-				Array.isArray((e as KeystrokeEvent).modifiers),
+				Array.isArray((e as KeystrokeEvent).modifiers) &&
+				(e as KeystrokeEvent).modifiers.every((m: unknown) => typeof m === "string"),
 		);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return parsed.events.filter(
(e: unknown): e is KeystrokeEvent =>
e !== null &&
typeof e === "object" &&
typeof (e as KeystrokeEvent).timeMs === "number" &&
typeof (e as KeystrokeEvent).key === "string" &&
Array.isArray((e as KeystrokeEvent).modifiers),
);
return parsed.events.filter(
(e: unknown): e is KeystrokeEvent =>
e !== null &&
typeof e === "object" &&
typeof (e as KeystrokeEvent).timeMs === "number" &&
typeof (e as KeystrokeEvent).key === "string" &&
Array.isArray((e as KeystrokeEvent).modifiers) &&
(e as KeystrokeEvent).modifiers.every((m: unknown) => typeof m === "string"),
);
🤖 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/cursor/telemetry.ts` around lines 370 - 377, The filter function
that validates KeystrokeEvent objects is only checking that the modifiers
property is an array, but not validating that each element within the array is a
string. This allows malformed telemetry with non-string modifier values to pass
through the IPC boundary. Add an additional validation check to the filter
condition that ensures every element in the modifiers array is of type string,
in addition to the existing Array.isArray check on line 376.

} catch {
return [];
}
}

export function resetKeystrokeTelemetry(): void {
setActiveKeystrokeEvents([]);
}
8 changes: 8 additions & 0 deletions electron/ipc/recording/mac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import fs from "node:fs/promises";
import { BrowserWindow } from "electron";
import {
persistPendingCursorTelemetry,
resetKeystrokeTelemetry,
saveKeystrokeTelemetry,
snapshotCursorTelemetryForPersistence,
} from "../cursor/telemetry";
import {
Expand Down Expand Up @@ -227,6 +229,12 @@ export async function finalizeStoredVideo(videoPath: string) {
} catch (error) {
console.warn("[mac-stop] Failed to persist cursor telemetry:", error);
}
try {
await saveKeystrokeTelemetry(videoPath);
} catch (error) {
console.warn("[mac-stop] Failed to persist keystroke telemetry:", error);
}
resetKeystrokeTelemetry();
if (isAutoRecordingPath(videoPath)) {
await pruneAutoRecordings([videoPath]);
}
Expand Down
23 changes: 23 additions & 0 deletions electron/ipc/register/recording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ import {
startCursorSampling,
stopCursorCapture,
writeCursorTelemetry,
loadKeystrokeTelemetry,
saveKeystrokeTelemetry,
resetKeystrokeTelemetry,
} from "../cursor/telemetry";
import { getFfmpegBinaryPath } from "../ffmpeg/binary";
import {
Expand Down Expand Up @@ -997,6 +1000,12 @@ export function registerRecordingHandlers(
} catch (error) {
console.warn("Failed to persist cursor telemetry during native stop:", error);
}
try {
await saveKeystrokeTelemetry(finalVideoPath);
} catch (error) {
console.warn("Failed to persist keystroke telemetry during native stop:", error);
}
resetKeystrokeTelemetry();
Comment on lines +1003 to +1008

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 | 🟠 Major | 🏗️ Heavy lift

Reset timing in Windows stop flow can delete the just-saved keystroke sidecar.

Line 1008 clears keystrokes before the later finalizeStoredVideo call in mux (Line 1473). That finalizer saves keystrokes again, and with an empty buffer it takes the delete-on-empty branch, removing ${videoPath}.keys.json. It also leaves terminal stop-failure paths without a guaranteed reset.

Suggested direction
 				try {
 					await saveKeystrokeTelemetry(finalVideoPath);
 				} catch (error) {
 					console.warn("Failed to persist keystroke telemetry during native stop:", error);
 				}
-				resetKeystrokeTelemetry();
+				// Defer keystroke reset until finalizeStoredVideo() after mux/finalization.

Also add a reset in terminal failure exits that do not reach finalizeStoredVideo() to prevent stale carryover.

🤖 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/recording.ts` around lines 1003 - 1008, The
resetKeystrokeTelemetry() call at the native stop flow is executed before
finalizeStoredVideo() is invoked later in the mux flow (around line 1473), which
causes the keystroke buffer to be cleared prematurely. When
finalizeStoredVideo() subsequently attempts to save keystrokes and encounters an
empty buffer, it triggers the delete-on-empty branch and removes the keystroke
sidecar file that was just saved. Move the resetKeystrokeTelemetry() call to
execute after finalizeStoredVideo() completes, and also add
resetKeystrokeTelemetry() calls to all terminal failure exit paths that do not
reach finalizeStoredVideo() to prevent stale keystroke data from persisting
across multiple recordings.


return { success: true, path: finalVideoPath };
} catch (error) {
Expand Down Expand Up @@ -1893,6 +1902,20 @@ export function registerRecordingHandlers(
}
});

ipcMain.handle("get-keystroke-telemetry", async (_, videoPath?: string) => {
const targetVideoPath = normalizeVideoSourcePath(videoPath ?? currentVideoPath);
if (!targetVideoPath) {
return { success: true, events: [] };
}
try {
const events = await loadKeystrokeTelemetry(targetVideoPath);
return { success: true, events };
} catch (error) {
console.error("Failed to load keystroke telemetry:", error);
return { success: false, events: [], error: String(error) };
}
});

ipcMain.handle(
"set-cursor-telemetry",
async (_, videoPath: string | undefined, samples: CursorTelemetryPoint[]) => {
Expand Down
8 changes: 8 additions & 0 deletions electron/ipc/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
CursorInteractionType,
CursorTelemetryPoint,
CursorVisualType,
KeystrokeEvent,
NativeCaptureDiagnostics,
RecordingSessionData,
SelectedSource,
Expand Down Expand Up @@ -291,3 +292,10 @@ export function setCachedNativeVideoEncoder(
export function setNativeHelperMigrationPromise(v: Promise<void> | null) {
nativeHelperMigrationPromise = v;
}

// ── Keystroke telemetry ───────────────────────────────────────────────────────
export let activeKeystrokeEvents: KeystrokeEvent[] = [];

export function setActiveKeystrokeEvents(v: KeystrokeEvent[]) {
activeKeystrokeEvents = v;
}
26 changes: 23 additions & 3 deletions electron/ipc/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,30 @@ export type HookMouseEvent = {

export type HookEventListener = (event: HookMouseEvent) => void;

export type HookKeyboardEvent = {
keycode?: number;
rawcode?: number;
type?: "keydown" | "keyup";
shiftKey?: boolean;
ctrlKey?: boolean;
altKey?: boolean;
metaKey?: boolean;
};

export type HookKeyboardEventName = "keydown" | "keyup";

export type HookKeyboardListener = (event: HookKeyboardEvent) => void;

export type KeystrokeEvent = {
timeMs: number;
key: string;
modifiers: string[];
};

export type UiohookLike = {
on: (eventName: HookEventName, listener: HookEventListener) => void;
off?: (eventName: HookEventName, listener: HookEventListener) => void;
removeListener?: (eventName: HookEventName, listener: HookEventListener) => void;
on: (eventName: HookEventName | HookKeyboardEventName, listener: HookEventListener | HookKeyboardListener) => void;
off?: (eventName: HookEventName | HookKeyboardEventName, listener: HookEventListener | HookKeyboardListener) => void;
removeListener?: (eventName: HookEventName | HookKeyboardEventName, listener: HookEventListener | HookKeyboardListener) => void;
start: () => void;
stop?: () => void;
};
Expand Down
4 changes: 4 additions & 0 deletions electron/ipc/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ export function getTelemetryPathForVideo(videoPath: string) {
return `${videoPath}.cursor.json`;
}

export function getKeystrokePathForVideo(videoPath: string) {
return `${videoPath}.keys.json`;
}

export function isAutoRecordingPath(filePath: string) {
return path.basename(filePath).startsWith(AUTO_RECORDING_PREFIX);
}
Expand Down
3 changes: 3 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
setCursorTelemetry: (videoPath: string | undefined, samples: CursorTelemetryPoint[]) => {
return ipcRenderer.invoke("set-cursor-telemetry", videoPath, samples);
},
getKeystrokeTelemetry: (videoPath?: string) => {
return ipcRenderer.invoke("get-keystroke-telemetry", videoPath);
},
getSystemCursorAssets: () => {
return ipcRenderer.invoke("get-system-cursor-assets");
},
Expand Down
Loading