Skip to content
Merged
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
91 changes: 90 additions & 1 deletion electron/ipc/cursor/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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";
Expand All @@ -16,21 +17,23 @@ import {
isCursorCaptureActive,
linuxCursorScreenPoint,
pendingCursorSamples,
pendingKeystrokeSamples,
selectedSource,
selectedWindowBounds,
setActiveCursorSamples,
setCursorCaptureAccumulatedPausedMs,
setCursorCaptureInterval,
setCursorCapturePauseStartedAtMs,
setPendingCursorSamples,
setPendingKeystrokeSamples,
} from "../state";
import type {
CursorInteractionType,
CursorTelemetryPoint,
CursorVisualType,
KeystrokeTelemetryPoint,
} from "../types";
import { getScreen, getTelemetryPathForVideo } from "../utils";
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 @@ -279,6 +282,92 @@ export function pushKeystrokeSample(sample: KeystrokeTelemetryPoint) {
}
}

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