diff --git a/electron/ipc/captureSelection.test.ts b/electron/ipc/captureSelection.test.ts index 235f77ea..54d4fe01 100644 --- a/electron/ipc/captureSelection.test.ts +++ b/electron/ipc/captureSelection.test.ts @@ -22,11 +22,7 @@ vi.mock("./state", () => ({ }, })); vi.mock("./utils", () => ({ getScreen: () => ({ getAllDisplays: () => mocks.displays }) })); -import { - rememberCaptureSource, - restoreLastCaptureSource, - validateCaptureSource, -} from "./captureSelection"; +import { getSessionCaptureSource, validateCaptureSource } from "./captureSelection"; const platform = process.platform; const source = { id: "window:101:0", name: "Document", windowTitle: "Document", appName: "Notes" }; beforeEach(() => { @@ -42,15 +38,16 @@ afterEach(() => { Object.defineProperty(process, "platform", { value: platform }); vi.unstubAllEnvs(); }); -describe("remembered capture selection", () => { - it("restores only the same available window", async () => { - expect(await restoreLastCaptureSource()).toMatchObject(source); - expect(mocks.current).toMatchObject(source); +describe("session capture selection", () => { + it("starts without a capture source even when the previous window is still available", async () => { + expect(await getSessionCaptureSource()).toBeNull(); + expect(mocks.current).toBeNull(); }); - it("rejects closed windows and clears stale saved selection", async () => { - mocks.windows.mockResolvedValue([]); - expect(await restoreLastCaptureSource()).toBeNull(); - expect(mocks.write).toHaveBeenCalledWith("recordly.capture.lastSource.v1", null); + it("ignores persisted selection without enumerating capture sources on startup", () => { + expect(getSessionCaptureSource()).toBeNull(); + expect(mocks.read).not.toHaveBeenCalled(); + expect(mocks.windows).not.toHaveBeenCalled(); + expect(mocks.sources).not.toHaveBeenCalled(); }); it("rejects a recycled window ID with a different title", async () => { mocks.windows.mockResolvedValue([ @@ -76,26 +73,15 @@ describe("remembered capture selection", () => { await expect(validateCaptureSource(source)).rejects.toThrow("permission denied"); expect(mocks.sources).not.toHaveBeenCalled(); }); - it("keeps a newer user selection made during restore", async () => { - let resolve!: (value: unknown[]) => void; - mocks.windows.mockReturnValue( - new Promise((done) => { - resolve = done; - }), - ); - const pending = restoreLastCaptureSource(); - const newer = { id: "screen:1:0", name: "Newer" }; - mocks.current = newer; - resolve([]); - expect(await pending).toEqual(newer); + it("keeps a selection only within the current app session", () => { + mocks.current = source; + expect(getSessionCaptureSource()).toEqual(source); + // A fresh main process initializes its in-memory selection to null. + mocks.current = null; + expect(getSessionCaptureSource()).toBeNull(); + expect(mocks.read).not.toHaveBeenCalled(); expect(mocks.write).not.toHaveBeenCalled(); }); - it("saves only selection metadata, excluding thumbnails", () => { - rememberCaptureSource({ ...source, thumbnail: "private pixels" }); - const stored = mocks.write.mock.calls[0][1]; - expect(stored.source).not.toHaveProperty("thumbnail"); - expect(stored.platform).toBe("darwin"); - }); }); describe("Linux selection", () => { @@ -124,20 +110,13 @@ describe("Linux selection", () => { mocks.sources.mockResolvedValue([{ ...source, id: "window:102:0" }]); expect(await validateCaptureSource(source)).toBeNull(); }); - for (const saved of [null, { platform: "linux", source }]) { - it(`prepares Wayland recording without opening a startup portal (${saved ? "saved X11 window" : "fresh session"})`, async () => { - vi.stubEnv("XDG_SESSION_TYPE", "wayland"); - mocks.read.mockReturnValue(saved); - expect(await restoreLastCaptureSource()).toEqual({ - id: "screen:linux-portal", - name: "System picker", - sourceType: "screen", - }); - expect(mocks.read).not.toHaveBeenCalled(); - expect(mocks.sources).not.toHaveBeenCalled(); - expect(mocks.windows).not.toHaveBeenCalled(); - }); - } + it("starts Wayland unselected without opening the portal", () => { + vi.stubEnv("XDG_SESSION_TYPE", "wayland"); + expect(getSessionCaptureSource()).toBeNull(); + expect(mocks.read).not.toHaveBeenCalled(); + expect(mocks.sources).not.toHaveBeenCalled(); + expect(mocks.windows).not.toHaveBeenCalled(); + }); it("rejects old X11 IDs on Wayland without enumerating portal sources", async () => { vi.stubEnv("XDG_SESSION_TYPE", "wayland"); expect(await validateCaptureSource(source)).toBeNull(); diff --git a/electron/ipc/captureSelection.ts b/electron/ipc/captureSelection.ts index 4b459660..de29c83d 100644 --- a/electron/ipc/captureSelection.ts +++ b/electron/ipc/captureSelection.ts @@ -1,4 +1,3 @@ -import { readAppSetting, writeAppSetting } from "../appSettingsStore"; import { listCapturePickerWindows } from "./capturePickerSources"; import { createAreaSource, @@ -6,7 +5,7 @@ import { getSourceArea, normalizeCapturePick, } from "./sourceArea"; -import { selectedSource, setSelectedSource } from "./state"; +import { selectedSource } from "./state"; import type { SelectedSource } from "./types"; import { getScreen } from "./utils"; import { getDesktopSources } from "./register/sources"; @@ -15,8 +14,6 @@ import { LINUX_PORTAL_SCREEN_SOURCE_ID, } from "./register/sourceMapping"; -const LAST_SOURCE_KEY = "recordly.capture.lastSource.v1"; - /** Revalidate identity and geometry; never substitute a broader capture source. */ export async function validateCaptureSource(candidate: unknown): Promise { if (!candidate || typeof candidate !== "object") return null; @@ -95,56 +92,7 @@ export async function validateCaptureSource(candidate: unknown): Promise | null = null; -export async function restoreLastCaptureSource(): Promise { - if (process.platform === "linux" && isLikelyLinuxWaylandSession(process.env)) { - const source: SelectedSource = { - id: LINUX_PORTAL_SCREEN_SOURCE_ID, - name: "System picker", - sourceType: "screen", - }; - setSelectedSource(source); - return source; - } - if (selectedSource) return selectedSource; - if (!restoring) - restoring = (async () => { - const saved = readAppSetting(LAST_SOURCE_KEY) as { - platform?: unknown; - source?: unknown; - } | null; - if (!saved || saved.platform !== process.platform) return null; - try { - const restored = await validateCaptureSource(saved.source); - // A user selection made while validation runs takes precedence. - if (selectedSource) return selectedSource; - setSelectedSource(restored); - if (!restored) writeAppSetting(LAST_SOURCE_KEY, null); - return restored; - } catch { - return null; - } - })().finally(() => { - restoring = null; - }); - return restoring; +/** A new main process starts unselected; old saved preferences are never restored. */ +export function getSessionCaptureSource(): SelectedSource | null { + return selectedSource; } diff --git a/electron/ipc/register/capturePicker.test.ts b/electron/ipc/register/capturePicker.test.ts index 1b5625c3..6a12558a 100644 --- a/electron/ipc/register/capturePicker.test.ts +++ b/electron/ipc/register/capturePicker.test.ts @@ -4,7 +4,7 @@ const mocks = vi.hoisted(() => ({ list: vi.fn(), pickSourceList: vi.fn(), validate: vi.fn(), - remember: vi.fn(), + selected: vi.fn(), recording: false, currentSource: null as Record | null, @@ -27,7 +27,6 @@ vi.mock("../../windows", () => ({ })); vi.mock("../captureSelection", () => ({ validateCaptureSource: mocks.validate, - rememberCaptureSource: mocks.remember, })); vi.mock("../capturePickerSources", () => ({ listCapturePickerWindows: mocks.list })); vi.mock("../cursor/bounds", () => ({ stopWindowBoundsCapture: vi.fn() })); @@ -134,7 +133,6 @@ it("rejects a window that disappeared while the picker was open", async () => { message: "That window or screen is no longer available.", }); expect(mocks.selected).not.toHaveBeenCalled(); - expect(mocks.remember).not.toHaveBeenCalled(); }); it("surfaces detection failures instead of opening a screen-only overlay", async () => { mocks.list.mockRejectedValue(new Error("Unable to detect windows")); @@ -146,14 +144,14 @@ it("surfaces detection failures instead of opening a screen-only overlay", async expect(mocks.selected).not.toHaveBeenCalled(); }); -it("reopens the currently selected saved area, without reusing an old area for a window", async () => { +it("opens a fresh picker even when an area is already selected", async () => { mocks.currentSource = { id: "area:1", display_id: "1", area: { x: 100, y: 200, width: 400, height: 300 }, }; expect(await call("get-capture-picker-context", mocks.overlay, 1)).toMatchObject({ - lastArea: mocks.currentSource.area, + lastArea: null, }); mocks.currentSource = { id: "window:101:0", display_id: "1" }; expect(await call("get-capture-picker-context", mocks.overlay, 1)).toMatchObject({ diff --git a/electron/ipc/register/capturePicker.ts b/electron/ipc/register/capturePicker.ts index 9c6ead38..5f471b03 100644 --- a/electron/ipc/register/capturePicker.ts +++ b/electron/ipc/register/capturePicker.ts @@ -1,4 +1,4 @@ -import { rememberCaptureSource, validateCaptureSource } from "../captureSelection"; +import { validateCaptureSource } from "../captureSelection"; import { listCapturePickerWindows } from "../capturePickerSources"; import { ipcMain } from "electron"; import type { SourceListPickResult } from "../../sourceListPicker"; @@ -8,13 +8,8 @@ import { isCapturePickerWebContents, } from "../../windows"; import { stopWindowBoundsCapture } from "../cursor/bounds"; -import { isCursorCaptureActive, selectedSource, setSelectedSource } from "../state"; -import { - createAreaSource, - createWindowSource, - getSourceArea, - normalizeCapturePick, -} from "../sourceArea"; +import { isCursorCaptureActive, setSelectedSource } from "../state"; +import { createAreaSource, createWindowSource, normalizeCapturePick } from "../sourceArea"; import type { CapturePick, CapturePickerWindow, SelectedSource } from "../types"; import { getScreen } from "../utils"; import { getScreenSourceIdForDisplay, isLikelyLinuxWaylandSession } from "./sourceMapping"; @@ -79,7 +74,6 @@ export function registerCapturePickerHandlers({ sourceType: "screen" as const, }; setSelectedSource(source); - rememberCaptureSource(source); broadcastSelectedSourceChange(); return { success: true, @@ -121,7 +115,6 @@ export function registerCapturePickerHandlers({ if (isCursorCaptureActive) return { success: false, canceled: true }; // Recording brings the selected window forward; selection need not wait for it. setSelectedSource(source); - rememberCaptureSource(source); broadcastSelectedSourceChange(); stopWindowBoundsCapture(); return { success: true, source }; @@ -195,16 +188,10 @@ export function registerCapturePickerHandlers({ window.y < y + height && window.y + window.height > y, ); - const previous = - selectedSource?.display_id === String(display.id) - ? getSourceArea(selectedSource) - : null; return { displayBounds: display.bounds, allowArea: process.platform === "darwin", - lastArea: previous - ? { x: previous.x, y: previous.y, width: previous.width, height: previous.height } - : null, + lastArea: null, windows, cursor: getScreen().getCursorScreenPoint(), }; diff --git a/electron/ipc/register/sources.test.ts b/electron/ipc/register/sources.test.ts index aa3a2fb0..8e77acaa 100644 --- a/electron/ipc/register/sources.test.ts +++ b/electron/ipc/register/sources.test.ts @@ -6,7 +6,6 @@ const mocks = vi.hoisted(() => ({ handlers: new Map unknown>(), sources: vi.fn(), validate: vi.fn(), - remember: vi.fn(), recording: false, selected: null as SelectedSource | null, exec: vi.fn(), @@ -35,8 +34,7 @@ vi.mock("../recording/ffmpeg", () => ({})); vi.mock("../windowsWindowControl", () => ({})); vi.mock("../captureSelection", () => ({ validateCaptureSource: mocks.validate, - rememberCaptureSource: mocks.remember, - restoreLastCaptureSource: vi.fn(), + getSessionCaptureSource: () => mocks.selected, })); vi.mock("../state", () => ({ get isCursorCaptureActive() { @@ -146,7 +144,6 @@ it("completes X11 selection even without optional window raising tools", async ( const source = { id: "window:101:0", name: "Document" }; expect(await picker.select(source)).toEqual(source); expect(await pending).toEqual({ success: true, source }); - expect(mocks.remember).toHaveBeenCalledWith(source); }); it("leaves the source list pending when a selected window no longer exists", async () => { const picker = pickerFixture(); @@ -156,7 +153,6 @@ it("leaves the source list pending when a selected window no longer exists", asy "no longer available", ); expect(mocks.selected).toBeNull(); - expect(mocks.remember).not.toHaveBeenCalled(); picker.win.close(); expect(await pending).toEqual({ success: false, canceled: true }); }); @@ -176,7 +172,6 @@ it("does not apply a selection after the source list is dismissed during validat await selection; expect(await pending).toEqual({ success: false, canceled: true }); expect(mocks.selected).toBeNull(); - expect(mocks.remember).not.toHaveBeenCalled(); }); it("does not change sources if recording starts during validation", async () => { const picker = pickerFixture(); @@ -187,7 +182,32 @@ it("does not change sources if recording starts during validation", async () => }); await picker.select({ id: "screen:1:0", name: "Screen" }); expect(mocks.selected).toBeNull(); - expect(mocks.remember).not.toHaveBeenCalled(); picker.win.close(); expect(await pending).toEqual({ success: false, canceled: true }); }); + +for (const platform of ["darwin", "win32", "linux"]) { + it(`starts ${platform} with no source and keeps explicit selection within the session`, async () => { + Object.defineProperty(process, "platform", { value: platform }); + pickerFixture(); + const read = mocks.handlers.get("get-selected-source")!; + expect(read()).toBeNull(); + const source = { id: "screen:1:0", name: "Screen 1", display_id: "1" }; + mocks.selected = source; + expect(read()).toEqual(source); + }); +} + +it("Wayland starts unselected and prepares its portal only after an explicit picker request", async () => { + vi.stubEnv("XDG_SESSION_TYPE", "wayland"); + const picker = pickerFixture(); + expect(mocks.handlers.get("get-selected-source")!()).toBeNull(); + await mocks.handlers.get("open-source-selector")!(); + expect(mocks.handlers.get("get-selected-source")!()).toEqual({ + id: "screen:linux-portal", + name: "System picker", + sourceType: "screen", + }); + expect(picker.create).not.toHaveBeenCalled(); + expect(mocks.sources).not.toHaveBeenCalled(); +}); diff --git a/electron/ipc/register/sources.ts b/electron/ipc/register/sources.ts index ec2a2c32..e1ae2a23 100644 --- a/electron/ipc/register/sources.ts +++ b/electron/ipc/register/sources.ts @@ -1,10 +1,6 @@ import { createSourceListPicker } from "../../sourceListPicker"; import { writeAppSetting } from "../../appSettingsStore"; -import { - rememberCaptureSource, - restoreLastCaptureSource, - validateCaptureSource, -} from "../captureSelection"; +import { getSessionCaptureSource, validateCaptureSource } from "../captureSelection"; import { createRecordingEditorNavigation } from "../../recordingEditorNavigation"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; @@ -435,7 +431,6 @@ export function registerSourceHandlers({ if (fromSelector && getSourceSelectorWindow()?.webContents !== event.sender) return selectedSource; setSelectedSource(source); - rememberCaptureSource(source); broadcastSelectedSourceChange(); if (sourceListPicker.isSender(event.sender)) sourceListPicker.complete(source); stopWindowBoundsCapture(); @@ -632,13 +627,17 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} }); ipcMain.handle("get-selected-source", () => { - return restoreLastCaptureSource(); + return getSessionCaptureSource(); }); ipcMain.handle("open-source-selector", async () => { if (isCursorCaptureActive) return; if (process.platform === "linux" && isLikelyLinuxWaylandSession(process.env)) { - await restoreLastCaptureSource(); + setSelectedSource({ + id: LINUX_PORTAL_SCREEN_SOURCE_ID, + name: "System picker", + sourceType: "screen", + }); broadcastSelectedSourceChange(); return; } diff --git a/src/components/capture-picker/CapturePicker.tsx b/src/components/capture-picker/CapturePicker.tsx index 0f2454ae..3b5f779c 100644 --- a/src/components/capture-picker/CapturePicker.tsx +++ b/src/components/capture-picker/CapturePicker.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; import type { PointerEvent as ReactPointerEvent } from "react"; import { frontmostAt, @@ -108,27 +109,14 @@ export function CapturePicker() { ), })) .filter((entry) => entry.rect.width > 0 && entry.rect.height > 0); - const next = - context.lastArea && context.allowArea - ? { - kind: "area" as const, - rect: normalizeAreaRect( - { - ...context.lastArea, - x: context.lastArea.x - origin.current.x, - y: context.lastArea.y - origin.current.y, - }, - screenRect(), - ), - } - : context.cursor - ? targetAt( - context.cursor.x - origin.current.x, - context.cursor.y - origin.current.y, - ) - : null; + const next = context.cursor + ? targetAt( + context.cursor.x - origin.current.x, + context.cursor.y - origin.current.y, + ) + : null; current.current = next; - editingArea.current = next?.kind === "area"; + editingArea.current = false; setTarget(next); requestAnimationFrame(() => requestAnimationFrame(() => { @@ -291,6 +279,29 @@ export function CapturePicker() { )} + {target?.kind === "area" && ( +
event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") event.stopPropagation(); + }} + > + +
+ )}
{target?.kind === "area" && editingArea.current diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 04bd154e..f41c6d07 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -222,7 +222,7 @@ function LaunchWindowContent() { .catch(() => { if (mounted) { setSourceReady(true); - toast.error("Unable to restore the previous source. Pick a source again."); + toast.error("Unable to load the recording source. Pick a screen again."); } }); @@ -284,7 +284,7 @@ function LaunchWindowContent() { {hasSelectedSource ? ( ) : ( - Pick source + Pick screen )}
diff --git a/src/components/launch/hooks/useLaunchWindowActions.ts b/src/components/launch/hooks/useLaunchWindowActions.ts index 72bc8722..74c2529d 100644 --- a/src/components/launch/hooks/useLaunchWindowActions.ts +++ b/src/components/launch/hooks/useLaunchWindowActions.ts @@ -3,7 +3,7 @@ import type { ProjectLibraryEntry } from "@/components/video-editor/ProjectBrows import type { DesktopSource } from "../popovers/launchPopoverTypes"; export function useLaunchWindowActions() { - const [selectedSource, setSelectedSource] = useState("Pick source"); + const [selectedSource, setSelectedSource] = useState("Pick screen"); const [hasSelectedSource, setHasSelectedSource] = useState(false); const [projectLibraryEntries, setProjectLibraryEntries] = useState([]); @@ -58,7 +58,7 @@ export function useLaunchWindowActions() { setHasSelectedSource(true); return; } - setSelectedSource("Pick source"); + setSelectedSource("Pick screen"); setHasSelectedSource(false); }, []); diff --git a/tests/ui/capture-picker.spec.ts b/tests/ui/capture-picker.spec.ts index e7d12c5a..8aa6df6c 100644 --- a/tests/ui/capture-picker.spec.ts +++ b/tests/ui/capture-picker.spec.ts @@ -25,15 +25,15 @@ const windows = [ }, ]; -async function openPicker(page: Page, allowArea = true) { +async function openPicker(page: Page, allowArea = true, previousArea = false) { await installDesktopBridge(page); await page.addInitScript( - ({ pickerWindows, allowArea }) => { + ({ pickerWindows, allowArea, previousArea }) => { const api = (window as unknown as { electronAPI: Record }).electronAPI; Object.assign(api, { getCapturePickerContext: async () => ({ displayBounds: { x: 0, y: 0, width: 1440, height: 1000 }, - lastArea: null, + lastArea: previousArea ? { x: 100, y: 100, width: 400, height: 300 } : null, allowArea, windows: pickerWindows, cursor: null, @@ -46,7 +46,7 @@ async function openPicker(page: Page, allowArea = true) { }, }); }, - { pickerWindows: windows, allowArea }, + { pickerWindows: windows, allowArea, previousArea }, ); await page.goto("/?windowType=capture-picker&displayId=1"); await expect(page.locator("html")).toHaveAttribute("data-picker-ready", "true"); @@ -153,7 +153,7 @@ for (const { selected, trigger } of [ if (selected) { await expect(control).toContainText("Existing screen"); } else { - await expect(control).toHaveText("Pick source"); + await expect(control).toHaveText("Pick screen"); } await expect(page.locator("html")).not.toHaveAttribute("data-picker-calls"); await (trigger === "source" @@ -296,7 +296,7 @@ test("HUD reports a failed pick and keeps cancellation silent", async ({ page }) ).toHaveCount(0); }); -test("unselected source reads Pick source once and Record stays disabled while picking", async ({ +test("unselected source reads Pick screen once and Record stays disabled while picking", async ({ page, }) => { await installDesktopBridge(page); @@ -314,14 +314,14 @@ test("unselected source reads Pick source once and Record stays disabled while p await page.goto("/?windowType=hud-overlay"); const source = page.getByRole("button", { name: "Choose recording source" }); await expect(source).toBeEnabled(); - await expect(source).toHaveText("Pick source"); + await expect(source).toHaveText("Pick screen"); await expect(page.locator("html")).not.toHaveAttribute("data-picker-pending"); await source.click(); await expect(page.locator("html")).toHaveAttribute("data-picker-pending", "true"); await expect(page.getByRole("button", { name: "Record", exact: true })).toBeDisabled(); await page.evaluate(() => (window as unknown as { finishPicker: () => void }).finishPicker()); await expect(page.getByRole("button", { name: "Record", exact: true })).toBeEnabled(); - await expect(source).toHaveText("Pick source"); + await expect(source).toHaveText("Pick screen"); }); test("changing source cancels countdown and discards native warm-up before opening the picker", async ({ @@ -441,3 +441,47 @@ test("changing source also cancels countdown on the browser capture path", async await expect(page.locator("html")).toHaveAttribute("data-picked-after-cancel", "true"); await expect(page.getByRole("button", { name: "Record", exact: true })).toBeEnabled(); }); + +test("reopening an area starts with window picking instead of the old area editor", async ({ + page, +}) => { + await openPicker(page, true, true); + await expect(page.locator("[data-area-handle]")).toHaveCount(0); + await page.mouse.move(600, 300); + await expect(page.getByText("Notes — Launch checklist · 620 × 460")).toBeVisible(); + await page.mouse.click(600, 300); + expect(await pick(page)).toMatchObject({ kind: "window", windowId: "window:101:0" }); +}); + +for (const destination of ["window", "screen"] as const) { + test(`a drawn area can return to ${destination} picking without closing the picker`, async ({ + page, + }) => { + await openPicker(page); + await page.mouse.move(300, 200); + await page.mouse.down(); + await page.mouse.move(700, 450, { steps: 5 }); + await page.mouse.up(); + await expect(page.getByText("Area · 400 × 250")).toBeVisible(); + const change = page.getByRole("button", { name: "Choose another source", exact: true }); + if (destination === "window") { + await change.focus(); + await page.keyboard.press("Enter"); + } else { + await change.click(); + } + await expect(page.locator("[data-area-handle]")).toHaveCount(0); + expect(await pick(page)).toBeUndefined(); + const point = destination === "window" ? { x: 600, y: 300 } : { x: 1380, y: 60 }; + await page.mouse.move(point.x, point.y); + await expect( + page.getByText( + destination === "window" + ? "Notes — Launch checklist · 620 × 460" + : "Entire screen · 1440 × 1000", + ), + ).toBeVisible(); + await page.mouse.click(point.x, point.y); + expect(await pick(page)).toMatchObject({ kind: destination, displayId: 1, record: false }); + }); +}