diff --git a/AGENTS.md b/AGENTS.md index ceea69bb61..84b7537391 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,7 +126,7 @@ This section is the *mechanics*. **What to actually run is [`technical-documenta - **The HUD and the editor never share the screen.** Opening the editor closes the HUD, and closing the editor, the last window, quits the app. The way back is the editor's Record mode (see the flow below). - Frameless, transparent, always-on-top, `skipTaskbar`, centered at the **bottom of the primary display** (`createHudOverlayWindow`, 820×560 at construction, then resized to fit its content — measured 904×698 with the bar at the bottom and mostly empty reserve above it). It is **click-through** (`setIgnoreMouseEvents(ignore)`): moving the real cursor over an interactive control makes that region clickable and shows its tooltip, so `mouse_move` → screenshot → `left_click` works; a blind click on empty HUD area passes through to the desktop. - **Only a real OS mouse move reaches the HUD — on macOS as much as on Windows.** While the window is input-transparent Chromium delivers it no pointer events at all, so the main process samples the OS cursor instead: the `hud-overlay-cursor` poll in `electron/windows.ts` reads `screen.getCursorScreenPoint()` while the HUD is click-through and pushes the window-relative point to the renderer, which hit-tests it with `elementFromPoint(…).closest("[data-hud-interactive='true']")`. One path, both platforms — there is no platform branch. **Linux is the exception** (`!enabled && !isLinuxHud` in `LaunchWindow.tsx`, where the call is a no-op), so it is the one platform where a blind click on the HUD simply lands. What the poll keys off is the OS cursor's position *relative to the window*, so a resize or re-anchor that slides the bar under a motionless pointer produces a fresh sample too. What it can never key off is synthesised input: Playwright's `.click()`, `javascript_tool`-dispatched pointer events and everything like them move no pointer at all, so they never put one on a control. They arrive *below* the OS hit-test, fire the DOM handler, and look like they worked — while the click-through path was never exercised at all. `tests/e2e/windows-native-checklist.spec.ts` does click HUD test IDs and stays green for exactly that reason; it proves renderer wiring, not reachability, and a macOS spec written the same way would prove no more. Use computer-use (`mouse_move` → `left_click`), and never conclude from a passing injected click that a user could have clicked it. (Until #385 the lift was Electron's `{ forward: true }` — a global `WH_MOUSE_LL` hook on Windows — which Windows can revoke without telling the app, leaving the HUD painted and permanently dead. The poll replaced it. The rule for you is unchanged, because both mechanisms key off the real cursor.) -- Control row (left→right): drag handle; horizontal/vertical bar toggle; **source** button (`Screen` until a source is picked, then its name); system audio; microphone; **camera**; a gear, *Device settings*, which picks the microphone and the camera and shows a level meter and a preview; cursor mode (editable or system cursor); **record**; *Open Studio* while idle, or pause, restart and cancel while recording; notes; language (the locale code, e.g. `EN`); *Hide recording bar*; *Quit OpenScreen*. Per platform: on macOS 15.2+ the source button opens Apple's system picker and the HUD hides until it closes; on macOS 13 and 14 there is no microphone; on Linux there is no source button (the portal asks when recording starts) and no notes button. +- Control row (left→right): drag handle; horizontal/vertical bar toggle; **camera only** (records the camera alone; while on, the source button, system audio and the cursor toggle are hidden); **source** button (`Screen` until a source is picked, then its name); system audio; microphone; **camera**; a gear, *Device settings*, which picks the microphone and the camera and shows a level meter and a preview; cursor mode (editable or system cursor); **record**; *Open Studio* while idle, or pause, restart and cancel while recording; notes; language (the locale code, e.g. `EN`); *Hide recording bar*; *Quit OpenScreen*. Per platform: on macOS 15.2+ the source button opens Apple's system picker and the HUD hides until it closes; on macOS 13 and 14 there is no microphone; on Linux there is no source button (the portal asks when recording starts) and no notes button. - Record is never disabled for want of a source. With none picked, its tooltip reads "Choose a screen or window to record", and a click opens the picker, then starts recording once a source is chosen. **The tray icon** (notification area on Windows, menu bar on macOS, the desktop's indicator area on Linux) diff --git a/electron/app-settings.test.ts b/electron/app-settings.test.ts index f3781734f8..21736b63fb 100644 --- a/electron/app-settings.test.ts +++ b/electron/app-settings.test.ts @@ -62,6 +62,24 @@ describe("app settings store", () => { ); }); + // getopenscreen/openscreen#1276 — camera only is a choice that has to survive a restart, and + // every settings file written before it existed records a screen. + it("persists camera only and reads a missing or invalid key as off", () => { + const dir = temp(); + const file = path.join(dir, "recording-settings.json"); + const store = new AppSettingsStore(dir); + expect(store.getSnapshot().recording.cameraOnly).toBe(false); + expect(store.setRecordingPreferences({ cameraOnly: true }).recording.cameraOnly).toBe(true); + expect(JSON.parse(readFileSync(file, "utf8"))).toMatchObject({ cameraOnly: true }); + expect(new AppSettingsStore(dir).getSnapshot().recording.cameraOnly).toBe(true); + + writeFileSync(file, JSON.stringify({ micEnabled: true }), "utf8"); + expect(store.getSnapshot().recording.cameraOnly).toBe(false); + writeFileSync(file, JSON.stringify({ cameraOnly: "true" }), "utf8"); + expect(store.getSnapshot().recording.cameraOnly).toBe(false); + expect(() => store.setRecordingPreferences({ cameraOnly: "yes" as never })).toThrow(TypeError); + }); + it("stores the last source beside the recording preferences", () => { const dir = temp(); const store = new AppSettingsStore(dir); diff --git a/electron/app-settings.ts b/electron/app-settings.ts index 81d334260c..2602bb49e8 100644 --- a/electron/app-settings.ts +++ b/electron/app-settings.ts @@ -30,6 +30,11 @@ export interface RecordingPreferences { * behaviour it gates has been on for every such installation. */ autoZoomEnabled: boolean; + /** + * Record the camera alone, with no screen or window. The camera's footage becomes the + * take's main video, so the editor's background and padding frame it like a screen. + */ + cameraOnly: boolean; } export const DEFAULT_RECORDING_PREFERENCES: RecordingPreferences = { @@ -44,6 +49,7 @@ export const DEFAULT_RECORDING_PREFERENCES: RecordingPreferences = { cursorCaptureMode: "editable-overlay", hideDesktopIcons: false, autoZoomEnabled: true, + cameraOnly: false, }; export interface RecordingSourceDescriptor { @@ -123,6 +129,7 @@ function parseRecording(raw: RawSettings): RecordingPreferences { : DEFAULT_RECORDING_PREFERENCES.cursorCaptureMode, hideDesktopIcons: bool(raw.hideDesktopIcons, DEFAULT_RECORDING_PREFERENCES.hideDesktopIcons), autoZoomEnabled: bool(raw.autoZoomEnabled, DEFAULT_RECORDING_PREFERENCES.autoZoomEnabled), + cameraOnly: bool(raw.cameraOnly, DEFAULT_RECORDING_PREFERENCES.cameraOnly), }; } @@ -167,7 +174,10 @@ function validateRecordingPatch(patch: Partial): void { for (const [key, value] of Object.entries(patch)) { if (!allowed.has(key)) throw new TypeError(`unknown recording preference: ${key}`); if (value === undefined) continue; - if ((key.endsWith("Enabled") || key === "hideDesktopIcons") && typeof value !== "boolean") { + if ( + (key.endsWith("Enabled") || key === "hideDesktopIcons" || key === "cameraOnly") && + typeof value !== "boolean" + ) { throw new TypeError(`${key} must be a boolean`); } if ( diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 0fcd435b1b..04d13a8836 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -699,6 +699,8 @@ export interface RecordingPrefs { hideDesktopIcons: boolean; /** Whether a fresh take gets automatic zooms on import. Persisted; defaults on. */ autoZoomEnabled: boolean; + /** Record the camera alone, as the take's main video. Persisted; defaults off. */ + cameraOnly: boolean; } const defaultRecordingPrefs: RecordingPrefs = { micEnabled: false, @@ -712,6 +714,7 @@ const defaultRecordingPrefs: RecordingPrefs = { cursorCaptureMode: "editable-overlay", hideDesktopIcons: false, autoZoomEnabled: true, + cameraOnly: false, }; // Cached source from the user's pick. Used by setDisplayMediaRequestHandler in main.ts for cursor-free capture. @@ -4122,6 +4125,13 @@ export function registerIpcHandlers( await startCursorRecording(recordingId); } else { await stopCursorRecording(); + // A take that records no cursor (system mode, or the camera alone) starts with + // no telemetry, as startCursorRecording's take does. Whatever is pending here + // belongs to an earlier take, and store-recorded-session would otherwise write + // it beside this one as its `.cursor.json`. + if (recording) { + pendingCursorRecordingData = null; + } } const source = selectedSource || { name: "Screen" }; diff --git a/electron/ipc/recordingPrefs.test.ts b/electron/ipc/recordingPrefs.test.ts index 2c3317af93..858c95c9dc 100644 --- a/electron/ipc/recordingPrefs.test.ts +++ b/electron/ipc/recordingPrefs.test.ts @@ -24,6 +24,7 @@ const defaults: RecordingPrefs = { cursorCaptureMode: "editable-overlay", hideDesktopIcons: false, autoZoomEnabled: true, + cameraOnly: false, }; let dir: string; beforeEach(() => { diff --git a/electron/ipc/recordingStateRouting.test.ts b/electron/ipc/recordingStateRouting.test.ts index 13c7193f18..5af564a7f7 100644 --- a/electron/ipc/recordingStateRouting.test.ts +++ b/electron/ipc/recordingStateRouting.test.ts @@ -48,4 +48,18 @@ describe("recording state routing", () => { it("the browser path notifies it", () => { expect(handlerBody("set-recording-state")).toContain("setAppRecording(recording"); }); + + // getopenscreen/openscreen#1398 review: a take that records no cursor (system mode, or + // camera only) must not inherit an earlier take's samples, which store-recorded-session + // would write beside it. The editable-overlay start clears them in startCursorRecording. + it("a take started without cursor telemetry drops what an earlier take left pending", () => { + const body = handlerBody("set-recording-state"); + const noCursorBranch = body.slice(body.indexOf("} else {")); + expect(noCursorBranch).toContain("await stopCursorRecording()"); + expect(noCursorBranch).toMatch(/if \(recording\) \{\s*pendingCursorRecordingData = null;/); + const start = source.slice(source.indexOf("async function startCursorRecording")); + expect(start.slice(0, start.indexOf("createCursorRecordingSession"))).toContain( + "pendingCursorRecordingData = null;", + ); + }); }); diff --git a/electron/mcp/app-recorder.test.ts b/electron/mcp/app-recorder.test.ts index 78b76be4e7..f766250749 100644 --- a/electron/mcp/app-recorder.test.ts +++ b/electron/mcp/app-recorder.test.ts @@ -221,6 +221,7 @@ describe("the recording bar's settings for a client's take", () => { camDeviceName: "Logitech BRIO", systemAudioEnabled: false, cursorCaptureMode: "system", + cameraOnly: false, }); }); @@ -228,6 +229,14 @@ describe("the recording bar's settings for a client's take", () => { expect(takePrefs(REQUEST, null)).toMatchObject({ micEnabled: false, camEnabled: false }); }); + // getopenscreen/openscreen#1398: a client asks for a screen, window or display, so a + // camera only left on in the bar is off for its take, and comes back with the rest. + it("records a screen even when the user left camera only on", () => { + expect(takePrefs({ ...REQUEST, camera: true }, null).cameraOnly).toBe(false); + const user = { ...takePrefs({ ...REQUEST, camera: true }, null), cameraOnly: true }; + expect(pickTakePrefs(user).cameraOnly).toBe(true); + }); + it("refuses a device no listed name contains", () => { expect(() => takePrefs({ ...REQUEST, camera: true, cameraDevice: "Facetime" }, listed)).toThrow( `No camera's name contains "Facetime"`, diff --git a/electron/mcp/app-recorder.ts b/electron/mcp/app-recorder.ts index 6f21341479..356a8eae9f 100644 --- a/electron/mcp/app-recorder.ts +++ b/electron/mcp/app-recorder.ts @@ -26,6 +26,9 @@ export const TAKE_PREF_KEYS = [ "camDeviceName", "systemAudioEnabled", "cursorCaptureMode", + // A client asks for a screen, window or display take: a camera only left on in the bar + // would record the camera instead. Put back with the rest once the take is captured. + "cameraOnly", ] as const; export type TakePrefs = Pick; @@ -65,6 +68,7 @@ export function takePrefs(request: RecordRequest, listed: CliSourcesResult | nul camDeviceName: cam.name, systemAudioEnabled: request.systemAudio, cursorCaptureMode: request.cursorMode, + cameraOnly: false, }; } diff --git a/src/components/ai-edition/v4/RecStage.test.tsx b/src/components/ai-edition/v4/RecStage.test.tsx index 49230e2784..c7aee45c81 100644 --- a/src/components/ai-edition/v4/RecStage.test.tsx +++ b/src/components/ai-edition/v4/RecStage.test.tsx @@ -423,6 +423,7 @@ describe("RecStage controls", () => { cursorCaptureMode: "editable-overlay", hideDesktopIcons: false, autoZoomEnabled: true, + cameraOnly: false, }; stubRecordingPrefs(); let answerReread: ((prefs: RecordingPrefs) => void) | undefined; @@ -635,6 +636,7 @@ describe("RecStage controls", () => { cursorCaptureMode: "editable-overlay", hideDesktopIcons: false, autoZoomEnabled: true, + cameraOnly: false, }; act(() => { recordingPrefsListeners.forEach((listener) => listener(resetPrefs)); diff --git a/src/components/launch/HudControls.tsx b/src/components/launch/HudControls.tsx index 2c5fa7df1f..10300af30c 100644 --- a/src/components/launch/HudControls.tsx +++ b/src/components/launch/HudControls.tsx @@ -14,6 +14,7 @@ import { Button } from "../ui/button"; import { TOOLTIP_GAP_PX, Tooltip } from "../ui/tooltip"; import { CameraIcon, + CameraOnlyIcon, CursorIcon, getIcon, ICON_SIZE, @@ -327,6 +328,37 @@ export const HudCameraButton = memo(function HudCameraButton({ ); }); +/** Switches the take between a screen recording and the camera alone. Same pattern as the toggles above. */ +export const HudCameraOnlyButton = memo(function HudCameraOnlyButton({ + enabled, + locked, + name, + tooltip, + onClick, +}: { + enabled: boolean; + locked: boolean; + name: string; + tooltip: string; + onClick: () => void; +}) { + return ( + + + + ); +}); + export const HudSettingsButton = memo(function HudSettingsButton({ locked, expanded, @@ -400,6 +432,7 @@ export const HudRecordButton = memo(function HudRecordButton({ recording, paused, saving, + pending = false, elapsedSeconds, label, savingLabel, @@ -408,6 +441,8 @@ export const HudRecordButton = memo(function HudRecordButton({ recording: boolean; paused: boolean; saving: boolean; + /** Held off for a moment: what the take will record is still being decided. */ + pending?: boolean; elapsedSeconds: number; label: string; savingLabel: string; @@ -419,7 +454,7 @@ export const HudRecordButton = memo(function HudRecordButton({