diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 4cb4875..284a671 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -11,6 +11,10 @@ import { shell, systemPreferences, } from "electron"; +import { + type CursorTelemetryPoint, + createCursorTelemetryBuffer, +} from "../../src/lib/cursorTelemetryBuffer"; import { normalizeProjectMedia, normalizeRecordingSession, @@ -275,14 +279,14 @@ async function storeRecordedSessionFiles(payload: StoreRecordedSessionInput) { currentProjectPath = null; const telemetryPath = `${screenVideoPath}.cursor.json`; - if (pendingCursorSamples.length > 0) { + const pendingSamples: CursorTelemetryPoint[] = cursorTelemetryBuffer.takeNextBatch(); + if (pendingSamples.length > 0) { await fs.writeFile( telemetryPath, - JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples }, null, 2), + JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: pendingSamples }, null, 2), "utf-8", ); } - pendingCursorSamples = []; const sessionManifestPath = path.join( RECORDINGS_DIR, @@ -302,16 +306,11 @@ const CURSOR_TELEMETRY_VERSION = 1; const CURSOR_SAMPLE_INTERVAL_MS = 100; const MAX_CURSOR_SAMPLES = 60 * 60 * 10; // 1 hour @ 10Hz -interface CursorTelemetryPoint { - timeMs: number; - cx: number; - cy: number; -} - let cursorCaptureInterval: NodeJS.Timeout | null = null; let cursorCaptureStartTimeMs = 0; -let activeCursorSamples: CursorTelemetryPoint[] = []; -let pendingCursorSamples: CursorTelemetryPoint[] = []; +const cursorTelemetryBuffer = createCursorTelemetryBuffer({ + maxActiveSamples: MAX_CURSOR_SAMPLES, +}); function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); @@ -338,15 +337,11 @@ function sampleCursorPoint() { const cx = clamp((cursor.x - bounds.x) / width, 0, 1); const cy = clamp((cursor.y - bounds.y) / height, 0, 1); - activeCursorSamples.push({ + cursorTelemetryBuffer.push({ timeMs: Math.max(0, Date.now() - cursorCaptureStartTimeMs), cx, cy, }); - - if (activeCursorSamples.length > MAX_CURSOR_SAMPLES) { - activeCursorSamples.shift(); - } } export function registerIpcHandlers( @@ -534,15 +529,13 @@ export function registerIpcHandlers( ipcMain.handle("set-recording-state", (_, recording: boolean) => { if (recording) { stopCursorCapture(); - activeCursorSamples = []; - pendingCursorSamples = []; + cursorTelemetryBuffer.startSession(); cursorCaptureStartTimeMs = Date.now(); sampleCursorPoint(); cursorCaptureInterval = setInterval(sampleCursorPoint, CURSOR_SAMPLE_INTERVAL_MS); } else { stopCursorCapture(); - pendingCursorSamples = [...activeCursorSamples]; - activeCursorSamples = []; + cursorTelemetryBuffer.endSession(); } const source = selectedSource || { name: "Screen" }; diff --git a/src/lib/cursorTelemetryBuffer.test.ts b/src/lib/cursorTelemetryBuffer.test.ts new file mode 100644 index 0000000..a626394 --- /dev/null +++ b/src/lib/cursorTelemetryBuffer.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { type CursorTelemetryPoint, createCursorTelemetryBuffer } from "./cursorTelemetryBuffer"; + +function sample(tag: number): CursorTelemetryPoint { + return { timeMs: tag, cx: tag / 10, cy: tag / 10 }; +} + +describe("createCursorTelemetryBuffer", () => { + it("stores samples captured during an active session", () => { + const buf = createCursorTelemetryBuffer({ maxActiveSamples: 10 }); + buf.startSession(); + for (let i = 0; i < 3; i++) buf.push(sample(i)); + buf.endSession(); + + const batch = buf.takeNextBatch(); + expect(batch).toHaveLength(3); + expect(batch[0]?.timeMs).toBe(0); + }); + + it("trims active samples past maxActiveSamples (ring behaviour)", () => { + const buf = createCursorTelemetryBuffer({ maxActiveSamples: 2 }); + buf.startSession(); + buf.push(sample(1)); + buf.push(sample(2)); + buf.push(sample(3)); + buf.endSession(); + + const batch = buf.takeNextBatch(); + expect(batch).toEqual([sample(2), sample(3)]); + }); + + it("preserves earlier pending batches when a new session starts before store", () => { + const buf = createCursorTelemetryBuffer({ maxActiveSamples: 10 }); + + // Recording 1 + buf.startSession(); + buf.push(sample(101)); + buf.push(sample(102)); + buf.endSession(); + + // Recording 2 starts before recording 1's batch has been consumed + buf.startSession(); + buf.push(sample(201)); + buf.endSession(); + + const batch1 = buf.takeNextBatch(); + const batch2 = buf.takeNextBatch(); + expect(batch1.map((s) => s.timeMs)).toEqual([101, 102]); + expect(batch2.map((s) => s.timeMs)).toEqual([201]); + }); + + it("returns an empty batch when nothing is pending", () => { + const buf = createCursorTelemetryBuffer({ maxActiveSamples: 10 }); + expect(buf.takeNextBatch()).toEqual([]); + }); + + it("drops empty sessions instead of queuing empty batches", () => { + const buf = createCursorTelemetryBuffer({ maxActiveSamples: 10 }); + buf.startSession(); + buf.endSession(); + expect(buf.pendingCount).toBe(0); + expect(buf.takeNextBatch()).toEqual([]); + }); + + it("caps the pending queue at maxPendingBatches to bound memory", () => { + const buf = createCursorTelemetryBuffer({ maxActiveSamples: 10, maxPendingBatches: 3 }); + + for (let round = 1; round <= 5; round++) { + buf.startSession(); + buf.push(sample(round)); + buf.endSession(); + } + + expect(buf.pendingCount).toBe(3); + // Oldest two batches (rounds 1 and 2) should have been dropped + expect(buf.takeNextBatch().map((s) => s.timeMs)).toEqual([3]); + expect(buf.takeNextBatch().map((s) => s.timeMs)).toEqual([4]); + expect(buf.takeNextBatch().map((s) => s.timeMs)).toEqual([5]); + }); + + it("starting a new session clears in-progress samples but keeps pending batches", () => { + const buf = createCursorTelemetryBuffer({ maxActiveSamples: 10 }); + + buf.startSession(); + buf.push(sample(1)); + buf.endSession(); + + buf.startSession(); + buf.push(sample(99)); + // Simulate another startSession before endSession (e.g. rapid restart) + buf.startSession(); + expect(buf.activeCount).toBe(0); + expect(buf.pendingCount).toBe(1); + + const batch = buf.takeNextBatch(); + expect(batch.map((s) => s.timeMs)).toEqual([1]); + }); + + it("reset() clears both active and pending state", () => { + const buf = createCursorTelemetryBuffer({ maxActiveSamples: 10 }); + buf.startSession(); + buf.push(sample(1)); + buf.endSession(); + buf.startSession(); + buf.push(sample(2)); + + buf.reset(); + + expect(buf.activeCount).toBe(0); + expect(buf.pendingCount).toBe(0); + expect(buf.takeNextBatch()).toEqual([]); + }); +}); diff --git a/src/lib/cursorTelemetryBuffer.ts b/src/lib/cursorTelemetryBuffer.ts new file mode 100644 index 0000000..2b4ef0c --- /dev/null +++ b/src/lib/cursorTelemetryBuffer.ts @@ -0,0 +1,66 @@ +export interface CursorTelemetryPoint { + timeMs: number; + cx: number; + cy: number; +} + +export interface CursorTelemetryBuffer { + startSession(): void; + push(point: CursorTelemetryPoint): void; + endSession(): void; + takeNextBatch(): CursorTelemetryPoint[]; + reset(): void; + readonly activeCount: number; + readonly pendingCount: number; +} + +export interface CursorTelemetryBufferOptions { + maxActiveSamples: number; + maxPendingBatches?: number; +} + +const DEFAULT_MAX_PENDING_BATCHES = 8; + +export function createCursorTelemetryBuffer( + options: CursorTelemetryBufferOptions, +): CursorTelemetryBuffer { + const maxActive = options.maxActiveSamples; + const maxPending = options.maxPendingBatches ?? DEFAULT_MAX_PENDING_BATCHES; + + let active: CursorTelemetryPoint[] = []; + let pending: CursorTelemetryPoint[][] = []; + + return { + startSession() { + active = []; + }, + push(point) { + active.push(point); + if (active.length > maxActive) { + active.shift(); + } + }, + endSession() { + if (active.length > 0) { + pending.push(active); + while (pending.length > maxPending) { + pending.shift(); + } + } + active = []; + }, + takeNextBatch() { + return pending.shift() ?? []; + }, + reset() { + active = []; + pending = []; + }, + get activeCount() { + return active.length; + }, + get pendingCount() { + return pending.length; + }, + }; +}