Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
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
88 changes: 77 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,43 @@ 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.
if (keycode === lastKeystrokeCode && timeMs - lastKeystrokeTimeMs < 45) {
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 +346,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
45 changes: 35 additions & 10 deletions electron/ipc/cursor/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import {
CURSOR_SAMPLE_INTERVAL_MS,
CURSOR_TELEMETRY_VERSION,
MAX_CURSOR_SAMPLES,
MAX_KEYSTROKE_SAMPLES,
} from "../constants";
import {
activeCursorSamples,
activeKeystrokeSamples,
currentCursorVisualType,
cursorCaptureAccumulatedPausedMs,
cursorCaptureInterval,
Expand All @@ -22,7 +24,12 @@ import {
setCursorCapturePauseStartedAtMs,
setPendingCursorSamples,
} from "../state";
import type { CursorInteractionType, CursorTelemetryPoint, CursorVisualType } from "../types";
import type {
CursorInteractionType,
CursorTelemetryPoint,
CursorVisualType,
KeystrokeTelemetryPoint,
} from "../types";
import { getScreen, getTelemetryPathForVideo } from "../utils";

export function clamp(value: number, min: number, max: number) {
Expand Down Expand Up @@ -91,11 +98,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 +147,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 +218,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 +264,21 @@ 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 sampleCursorPoint() {
const point = getNormalizedCursorPoint();
pushCursorSample(point.cx, point.cy, getCursorCaptureElapsedMs(), "move");
Expand Down
17 changes: 17 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,
KeystrokeTelemetryPoint,
NativeCaptureDiagnostics,
RecordingSessionData,
SelectedSource,
Expand Down Expand Up @@ -82,6 +83,13 @@ export let activeCursorSamples: CursorTelemetryPoint[] = [];
export let pendingCursorSamples: CursorTelemetryPoint[] = [];
export let isCursorCaptureActive = false;
export let interactionCaptureCleanup: (() => void) | null = null;

// ── Keystroke telemetry (opt-in; privacy-gated) ──────────────────────────────
// Disabled by default: a global key hook can observe secrets typed while
// recording, so nothing is stored unless the keystroke overlay is enabled.
export let isKeystrokeCaptureActive = false;
export let activeKeystrokeSamples: KeystrokeTelemetryPoint[] = [];
export let pendingKeystrokeSamples: KeystrokeTelemetryPoint[] = [];
export let hasLoggedInteractionHookFailure = false;
export let lastLeftClick: { timeMs: number; cx: number; cy: number } | null = null;
export let linuxCursorScreenPoint: { x: number; y: number; updatedAt: number } | null = null;
Expand Down Expand Up @@ -254,6 +262,15 @@ export function setPendingCursorSamples(v: CursorTelemetryPoint[]) {
export function setIsCursorCaptureActive(v: boolean) {
isCursorCaptureActive = v;
}
export function setIsKeystrokeCaptureActive(v: boolean) {
isKeystrokeCaptureActive = v;
}
export function setActiveKeystrokeSamples(v: KeystrokeTelemetryPoint[]) {
activeKeystrokeSamples = v;
}
export function setPendingKeystrokeSamples(v: KeystrokeTelemetryPoint[]) {
pendingKeystrokeSamples = v;
}
export function setInteractionCaptureCleanup(v: (() => void) | null) {
interactionCaptureCleanup = v;
}
Expand Down
38 changes: 36 additions & 2 deletions electron/ipc/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,21 @@ export interface CursorTelemetryPoint {
cursorType?: CursorVisualType;
}

/**
* A single captured keystroke. `key` is a stable semantic token resolved from
* uiohook-napi's UiohookKey table (e.g. "A", "Enter", "Space", "Comma"), or the
* raw numeric keycode as a string when the token is unknown. Modifier booleans
* describe which modifiers were held at press time.
*/
export interface KeystrokeTelemetryPoint {
timeMs: number;
key: string;
ctrl?: boolean;
alt?: boolean;
shift?: boolean;
meta?: boolean;
}

export type NativeMacWindowSource = {
id: string;
name: string;
Expand All @@ -120,7 +135,7 @@ export type NativeMacWindowSource = {
height?: number;
};

export type HookEventName = "mousedown" | "mouseup" | "mousemove";
export type HookEventName = "mousedown" | "mouseup" | "mousemove" | "keydown" | "keyup";

export type HookMouseEvent = {
button?: number;
Expand All @@ -139,7 +154,26 @@ export type HookMouseEvent = {
};
};

export type HookEventListener = (event: HookMouseEvent) => void;
export type HookKeyboardEvent = {
keycode?: number;
rawcode?: number;
altKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
shiftKey?: boolean;
data?: {
keycode?: number;
altKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
shiftKey?: boolean;
};
};

/** Union of the mouse and keyboard event shapes a single uiohook listener may receive. */
export type HookInputEvent = HookMouseEvent & HookKeyboardEvent;

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

export type UiohookLike = {
on: (eventName: HookEventName, listener: HookEventListener) => void;
Expand Down
Loading