Reset capture selection on launch and fix area picker navigation

This commit is contained in:
webadderall committed 2026-10-13 14:01:54 +11:00
1 parent cbefc51d18
commit 8bae983a44
10 files changed
+156 -170

No files matched your search

+24 -45
View File
@@ -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();
+4 -56
View File
@@ -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<SelectedSource | null> {
if (!candidate || typeof candidate !== "object") return null;
@@ -95,56 +92,7 @@ export async function validateCaptureSource(candidate: unknown): Promise<Selecte
: null;
}
export function rememberCaptureSource(source: SelectedSource) {
try {
writeAppSetting(LAST_SOURCE_KEY, {
platform: process.platform,
source: {
id: source.id,
name: source.name,
display_id: source.display_id,
sourceType: source.sourceType,
appName: source.appName,
windowTitle: source.windowTitle,
area: source.area,
},
});
} catch (error) {
console.warn("Unable to remember capture source:", error);
}
}
let restoring: Promise<SelectedSource | null> | null = null;
export async function restoreLastCaptureSource(): Promise<SelectedSource | null> {
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;
}
+3 -5
View File
@@ -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<string, unknown> | 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({
+4 -17
View File
@@ -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(),
};
+27 -7
View File
@@ -6,7 +6,6 @@ const mocks = vi.hoisted(() => ({
handlers: new Map<string, (...args: unknown[]) => 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();
});
+7 -8
View File
@@ -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;
}
+31 -20
View File
@@ -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() {
</div>
</div>
)}
{target?.kind === "area" && (
<div
className="absolute bottom-12 left-1/2 -translate-x-1/2"
onPointerDown={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") event.stopPropagation();
}}
>
<Button
variant="secondary"
size="sm"
onClick={() => {
press.current = null;
editingArea.current = false;
current.current = null;
setTarget(null);
}}
>
Choose another source
</Button>
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-24 flex justify-center">
<div className="rounded-full bg-black/80 px-4 py-2 text-sm text-white">
{target?.kind === "area" && editingArea.current
+2 -2
View File
@@ -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 ? (
<MarqueeText text={selectedSource} />
) : (
<span>Pick source</span>
<span>Pick screen</span>
)}
</div>
</Button>
@@ -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<ProjectLibraryEntry[]>([]);
@@ -58,7 +58,7 @@ export function useLaunchWindowActions() {
setHasSelectedSource(true);
return;
}
setSelectedSource("Pick source");
setSelectedSource("Pick screen");
setHasSelectedSource(false);
}, []);
+52 -8
View File
@@ -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<string, unknown> }).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 });
});
}