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
20 changes: 19 additions & 1 deletion electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,10 @@ interface Window {
startDelayMsByPath?: Record<string, number>;
error?: string;
}>;
setRecordingState: (recording: boolean) => Promise<void>;
setRecordingState: (
recording: boolean,
options?: { showKeystrokes?: boolean },
) => Promise<void>;
getCursorTelemetry: (videoPath?: string) => Promise<{
success: boolean;
samples: CursorTelemetryPoint[];
Expand All @@ -581,6 +584,12 @@ interface Window {
message?: string;
error?: string;
}>;
getKeystrokeTelemetry: (videoPath?: string) => Promise<{
success: boolean;
samples: KeystrokeTelemetryPoint[];
message?: string;
error?: string;
}>;
getSystemCursorAssets: () => Promise<{
success: boolean;
cursors: Record<string, SystemCursorAsset>;
Expand Down Expand Up @@ -952,6 +961,15 @@ interface ProcessedDesktopSource {
windowTitle?: string;
}

interface KeystrokeTelemetryPoint {
timeMs: number;
key: string;
ctrl?: boolean;
alt?: boolean;
shift?: boolean;
meta?: boolean;
}

interface CursorTelemetryPoint {
timeMs: number;
cx: number;
Expand Down
3 changes: 3 additions & 0 deletions electron/ipc/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,6 @@ 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;
export const MAX_KEYSTROKE_SAMPLES = 60 * 60 * 20; // generous cap; keystrokes are sparse vs cursor samples
91 changes: 80 additions & 11 deletions electron/ipc/cursor/interaction.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import type { HookMouseEvent, UiohookLike, UiohookModuleNamespace, CursorInteractionType } from "../types";
import {
isCursorCaptureActive,
interactionCaptureCleanup,
setInteractionCaptureCleanup,
hasLoggedInteractionHookFailure,
setHasLoggedInteractionHookFailure,
interactionCaptureCleanup,
isCursorCaptureActive,
isKeystrokeCaptureActive,
lastLeftClick,
setHasLoggedInteractionHookFailure,
setInteractionCaptureCleanup,
setLastLeftClick,
setLinuxCursorScreenPoint,
} from "../state";
import type {
CursorInteractionType,
HookKeyboardEvent,
HookMouseEvent,
UiohookLike,
UiohookModuleNamespace,
} from "../types";
import {
getNormalizedCursorPoint,
getCursorCaptureElapsedMs,
getHookCursorScreenPoint,
getNormalizedCursorPoint,
isCursorCapturePaused,
pushCursorSample,
pushKeystrokeSample,
} from "./telemetry";

const nodeRequire = createRequire(import.meta.url);
Expand Down Expand Up @@ -172,6 +180,31 @@ function loadUiohookModule() {
}
}

/**
* Build a keycode → semantic-token map from uiohook-napi's UiohookKey table so
* the renderer never has to know raw platform keycodes. Tolerant of the table
* being absent — callers fall back to the numeric keycode string.
*/
function buildKeycodeTokenMap(): Map<number, string> {
const map = new Map<number, string>();
try {
const moduleExports = nodeRequire("uiohook-napi") as {
UiohookKey?: Record<string, number>;
};
const table = moduleExports?.UiohookKey;
if (table) {
for (const [name, code] of Object.entries(table)) {
if (typeof code === "number" && !map.has(code)) {
map.set(code, name);
}
}
}
} catch {
// Table unavailable — renderer falls back to the numeric keycode string.
}
return map;
}

export async function startInteractionCapture() {
if (!isCursorCaptureActive) {
return;
Expand Down Expand Up @@ -255,11 +288,7 @@ export async function startInteractionCapture() {
};

const onMouseMove = (event: HookMouseEvent) => {
if (
process.platform !== "linux" ||
!isCursorCaptureActive ||
isCursorCapturePaused()
) {
if (process.platform !== "linux" || !isCursorCaptureActive || isCursorCapturePaused()) {
return;
}

Expand All @@ -271,8 +300,46 @@ export async function startInteractionCapture() {
setLinuxCursorScreenPoint({ x: point.x, y: point.y, updatedAt: Date.now() });
};

const keycodeTokens = buildKeycodeTokenMap();
let lastKeystrokeCode = -1;
let lastKeystrokeTimeMs = Number.NEGATIVE_INFINITY;

const onKeyDown = (event: HookKeyboardEvent) => {
// Privacy gate: only record keystrokes when the overlay is enabled.
if (!isCursorCaptureActive || !isKeystrokeCaptureActive || isCursorCapturePaused()) {
return;
}

const keycode = typeof event.keycode === "number" ? event.keycode : event.data?.keycode;
if (typeof keycode !== "number") {
return;
}

const timeMs = getCursorCaptureElapsedMs();

// Collapse auto-repeat: ignore the same physical key re-firing rapidly.
// Bump the timestamp on collapsed repeats too, so a held key stays
// suppressed for its whole duration instead of leaking every other repeat.
if (keycode === lastKeystrokeCode && timeMs - lastKeystrokeTimeMs < 45) {
lastKeystrokeTimeMs = timeMs;
return;
}
lastKeystrokeCode = keycode;
lastKeystrokeTimeMs = timeMs;

pushKeystrokeSample({
timeMs,
key: keycodeTokens.get(keycode) ?? String(keycode),
ctrl: Boolean(event.ctrlKey ?? event.data?.ctrlKey),
alt: Boolean(event.altKey ?? event.data?.altKey),
shift: Boolean(event.shiftKey ?? event.data?.shiftKey),
meta: Boolean(event.metaKey ?? event.data?.metaKey),
});
};

hook.on("mousedown", onMouseDown);
hook.on("mouseup", onMouseUp);
hook.on("keydown", onKeyDown);
if (process.platform === "linux") {
hook.on("mousemove", onMouseMove);
}
Expand All @@ -282,12 +349,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
136 changes: 125 additions & 11 deletions electron/ipc/cursor/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ import fs from "node:fs/promises";
import {
CURSOR_SAMPLE_INTERVAL_MS,
CURSOR_TELEMETRY_VERSION,
KEYSTROKE_TELEMETRY_VERSION,
MAX_CURSOR_SAMPLES,
MAX_KEYSTROKE_SAMPLES,
} from "../constants";
import {
activeCursorSamples,
activeKeystrokeSamples,
currentCursorVisualType,
cursorCaptureAccumulatedPausedMs,
cursorCaptureInterval,
Expand All @@ -14,16 +17,23 @@ import {
isCursorCaptureActive,
linuxCursorScreenPoint,
pendingCursorSamples,
pendingKeystrokeSamples,
selectedSource,
selectedWindowBounds,
setActiveCursorSamples,
setCursorCaptureAccumulatedPausedMs,
setCursorCaptureInterval,
setCursorCapturePauseStartedAtMs,
setPendingCursorSamples,
setPendingKeystrokeSamples,
} from "../state";
import type { CursorInteractionType, CursorTelemetryPoint, CursorVisualType } from "../types";
import { getScreen, getTelemetryPathForVideo } from "../utils";
import type {
CursorInteractionType,
CursorTelemetryPoint,
CursorVisualType,
KeystrokeTelemetryPoint,
} from "../types";
import { getKeystrokePathForVideo, getScreen, getTelemetryPathForVideo } from "../utils";

export function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
Expand Down Expand Up @@ -91,11 +101,7 @@ export async function writeCursorTelemetry(videoPath: string, samples: unknown)

await fs.writeFile(
telemetryPath,
JSON.stringify(
{ version: CURSOR_TELEMETRY_VERSION, samples: normalizedSamples },
null,
2,
),
JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: normalizedSamples }, null, 2),
"utf-8",
);

Expand Down Expand Up @@ -144,9 +150,7 @@ export function resumeCursorCapture(resumedAtMs: number) {
}

const pauseDurationMs = Math.max(0, resumedAtMs - cursorCapturePauseStartedAtMs);
setCursorCaptureAccumulatedPausedMs(
cursorCaptureAccumulatedPausedMs + pauseDurationMs,
);
setCursorCaptureAccumulatedPausedMs(cursorCaptureAccumulatedPausedMs + pauseDurationMs);
setCursorCapturePauseStartedAtMs(null);
}

Expand Down Expand Up @@ -217,7 +221,16 @@ export function getNormalizedCursorPoint() {
}

export function getHookCursorScreenPoint(
event: { x?: number; y?: number; data?: { x?: number; y?: number; screenX?: number; screenY?: number }; screenX?: number; screenY?: number } | null | undefined,
event:
| {
x?: number;
y?: number;
data?: { x?: number; y?: number; screenX?: number; screenY?: number };
screenX?: number;
screenY?: number;
}
| null
| undefined,
): { x: number; y: number } | null {
const rawX = event?.x ?? event?.data?.x ?? event?.screenX ?? event?.data?.screenX;
const rawY = event?.y ?? event?.data?.y ?? event?.screenY ?? event?.data?.screenY;
Expand Down Expand Up @@ -254,6 +267,107 @@ export function pushCursorSample(
}
}

export function pushKeystrokeSample(sample: KeystrokeTelemetryPoint) {
activeKeystrokeSamples.push({
timeMs: Math.max(0, sample.timeMs),
key: sample.key,
ctrl: sample.ctrl,
alt: sample.alt,
shift: sample.shift,
meta: sample.meta,
});

if (activeKeystrokeSamples.length > MAX_KEYSTROKE_SAMPLES) {
activeKeystrokeSamples.shift();
}
}

export function normalizeKeystrokeTelemetrySamples(rawSamples: unknown): KeystrokeTelemetryPoint[] {
const samples = Array.isArray(rawSamples)
? rawSamples
: Array.isArray((rawSamples as { samples?: unknown[] } | null | undefined)?.samples)
? ((rawSamples as { samples: unknown[] }).samples ?? [])
: [];
const boundedSamples = samples.slice(0, MAX_KEYSTROKE_SAMPLES);

return boundedSamples
.filter((sample: unknown) => Boolean(sample && typeof sample === "object"))
.map((sample: unknown) => {
const point = sample as Partial<KeystrokeTelemetryPoint>;
return {
timeMs:
typeof point.timeMs === "number" && Number.isFinite(point.timeMs)
? Math.max(0, point.timeMs)
: 0,
key: typeof point.key === "string" ? point.key : "",
ctrl: point.ctrl === true ? true : undefined,
alt: point.alt === true ? true : undefined,
shift: point.shift === true ? true : undefined,
meta: point.meta === true ? true : undefined,
};
})
.filter((point) => point.key.length > 0)
.sort((a, b) => a.timeMs - b.timeMs);
}

export async function writeKeystrokeTelemetry(videoPath: string, samples: unknown) {
const keystrokePath = getKeystrokePathForVideo(videoPath);
const normalizedSamples = normalizeKeystrokeTelemetrySamples(samples);

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

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

return normalizedSamples;
}

export async function persistPendingKeystrokeTelemetry(videoPath: string) {
const keystrokePath = getKeystrokePathForVideo(videoPath);
if (pendingKeystrokeSamples.length > 0) {
await fs.writeFile(
keystrokePath,
JSON.stringify(
{ version: KEYSTROKE_TELEMETRY_VERSION, samples: pendingKeystrokeSamples },
null,
2,
),
"utf-8",
);
} else {
await fs.rm(keystrokePath, { force: true });
}
setPendingKeystrokeSamples([]);
}

export function snapshotKeystrokeTelemetryForPersistence() {
if (activeKeystrokeSamples.length === 0) {
return;
}

if (pendingKeystrokeSamples.length === 0) {
setPendingKeystrokeSamples([...activeKeystrokeSamples]);
return;
}

const lastPendingTimeMs =
pendingKeystrokeSamples[pendingKeystrokeSamples.length - 1]?.timeMs ?? -1;
setPendingKeystrokeSamples([
...pendingKeystrokeSamples,
...activeKeystrokeSamples.filter((sample) => sample.timeMs > lastPendingTimeMs),
]);
}

export function sampleCursorPoint() {
const point = getNormalizedCursorPoint();
pushCursorSample(point.cx, point.cy, getCursorCaptureElapsedMs(), "move");
Expand Down
Loading