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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions electron/app-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 11 additions & 1 deletion electron/app-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -44,6 +49,7 @@ export const DEFAULT_RECORDING_PREFERENCES: RecordingPreferences = {
cursorCaptureMode: "editable-overlay",
hideDesktopIcons: false,
autoZoomEnabled: true,
cameraOnly: false,
};

export interface RecordingSourceDescriptor {
Expand Down Expand Up @@ -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),
};
}

Expand Down Expand Up @@ -167,7 +174,10 @@ function validateRecordingPatch(patch: Partial<RecordingPreferences>): 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 (
Expand Down
3 changes: 3 additions & 0 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions electron/ipc/recordingPrefs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const defaults: RecordingPrefs = {
cursorCaptureMode: "editable-overlay",
hideDesktopIcons: false,
autoZoomEnabled: true,
cameraOnly: false,
};
let dir: string;
beforeEach(() => {
Expand Down
2 changes: 2 additions & 0 deletions src/components/ai-edition/v4/RecStage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ describe("RecStage controls", () => {
cursorCaptureMode: "editable-overlay",
hideDesktopIcons: false,
autoZoomEnabled: true,
cameraOnly: false,
};
stubRecordingPrefs();
let answerReread: ((prefs: RecordingPrefs) => void) | undefined;
Expand Down Expand Up @@ -635,6 +636,7 @@ describe("RecStage controls", () => {
cursorCaptureMode: "editable-overlay",
hideDesktopIcons: false,
autoZoomEnabled: true,
cameraOnly: false,
};
act(() => {
recordingPrefsListeners.forEach((listener) => listener(resetPrefs));
Expand Down
32 changes: 32 additions & 0 deletions src/components/launch/HudControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Button } from "../ui/button";
import { TOOLTIP_GAP_PX, Tooltip } from "../ui/tooltip";
import {
CameraIcon,
CameraOnlyIcon,
CursorIcon,
getIcon,
ICON_SIZE,
Expand Down Expand Up @@ -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 (
<HudTooltip content={tooltip}>
<button
type="button"
data-testid="launch-camera-only-button"
className={`${hudIconBtnClasses} ${hudLockedClasses}`}
aria-label={name}
aria-pressed={enabled}
aria-disabled={locked || undefined}
onClick={locked ? undefined : onClick}
>
<CameraOnlyIcon className={enabled ? "text-[#10b981]" : ""} />
</button>
</HudTooltip>
);
});

export const HudSettingsButton = memo(function HudSettingsButton({
locked,
expanded,
Expand Down
11 changes: 11 additions & 0 deletions src/components/launch/HudIcons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,17 @@ export function CameraIcon({ off, className }: { off: boolean; className?: strin
);
}

/** A person inside a frame: the take is the camera's picture alone, with no screen behind it. */
export function CameraOnlyIcon({ className }: { className?: string }) {
return (
<svg {...HUD_SVG_PROPS} className={className} aria-hidden="true">
<rect x="2.5" y="3.5" width="19" height="17" rx="2.4" />
<circle cx="12" cy="10" r="3" />
<path d="M6.5 20.5a5.5 5.5 0 0 1 11 0" />
</svg>
);
}

/** `off` slashes the arrow, like the mic and camera, so the state is not carried by colour alone. */
export function CursorIcon({ off = false, className }: { off?: boolean; className?: string }) {
return (
Expand Down
84 changes: 84 additions & 0 deletions src/components/launch/LaunchWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ const recorderState = vi.hoisted(() => ({
setSystemAudioEnabled: vi.fn(),
cursorCaptureMode: "editable-overlay",
setCursorCaptureMode: vi.fn(),
cameraOnly: false,
setCameraOnly: vi.fn(),
softwareEncoderFallbackNoticeVisible: false,
dismissSoftwareEncoderFallbackNotice: vi.fn(),
recordingPrefsLoaded: true,
Expand Down Expand Up @@ -368,6 +370,8 @@ function resetLaunchMocks() {
recorderState.value.setMicrophoneDeviceId.mockClear();
recorderState.value.webcamEnabled = false;
recorderState.value.setWebcamEnabled.mockClear();
recorderState.value.cameraOnly = false;
recorderState.value.setCameraOnly.mockClear();
recorderState.value.recordingPrefsLoaded = true;
cameraDevicesState.isReady = true;
cameraDevicesState.devices = [];
Expand Down Expand Up @@ -1905,6 +1909,86 @@ describe("LaunchWindow device buttons", () => {
});
});

// getopenscreen/openscreen#1276
describe("LaunchWindow camera only", () => {
beforeEach(() => {
platformState.value = "darwin";
resetLaunchMocks();
});

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

it("turns the camera on with camera only, and stores both", async () => {
renderLaunchWindow();
const toggle = await screen.findByTestId("launch-camera-only-button");
expect(toggle).toHaveAttribute("aria-pressed", "false");

fireEvent.click(toggle);

await waitFor(() => {
expect(window.electronAPI.setRecordingPrefs).toHaveBeenCalledWith({
camEnabled: true,
cameraOnly: true,
});
});
expect(recorderState.value.setWebcamEnabled).toHaveBeenCalledWith(true);
expect(recorderState.value.setCameraOnly).toHaveBeenCalledWith(true);
});

it("stays off when the camera cannot be turned on", async () => {
recorderState.value.setWebcamEnabled.mockResolvedValueOnce(false);
renderLaunchWindow();

fireEvent.click(await screen.findByTestId("launch-camera-only-button"));

await waitFor(() => {
expect(recorderState.value.setWebcamEnabled).toHaveBeenCalledWith(true);
});
await act(async () => undefined);
expect(recorderState.value.setCameraOnly).not.toHaveBeenCalled();
expect(window.electronAPI.setRecordingPrefs).not.toHaveBeenCalled();
});

it("records without a source: no source button, and Record never opens the picker", async () => {
recorderState.value.cameraOnly = true;
recorderState.value.webcamEnabled = true;
renderLaunchWindow();

const recordButton = await screen.findByTestId("launch-record-button");
expect(recordButton).toHaveAttribute("aria-label", "Start recording");
expect(screen.queryByTestId("launch-source-selector-button")).toBeNull();
// Both come from screen capture, which a camera-only take does not run.
expect(screen.queryByTestId("launch-system-audio-button")).toBeNull();
expect(screen.queryByTestId("launch-cursor-mode-button")).toBeNull();

fireEvent.click(recordButton);

await waitFor(() => {
expect(recorderState.value.toggleRecording).toHaveBeenCalledTimes(1);
});
expect(window.electronAPI.openSourceSelector).not.toHaveBeenCalled();
});

it("leaves camera only when the camera is switched off", async () => {
recorderState.value.cameraOnly = true;
recorderState.value.webcamEnabled = true;
renderLaunchWindow();

fireEvent.click(await screen.findByTestId("launch-webcam-button"));

await waitFor(() => {
expect(window.electronAPI.setRecordingPrefs).toHaveBeenCalledWith({
camEnabled: false,
cameraOnly: false,
});
});
expect(recorderState.value.setCameraOnly).toHaveBeenCalledWith(false);
});
});

describe("LaunchWindow device settings", () => {
beforeEach(() => {
platformState.value = "darwin";
Expand Down
Loading
Loading