From a60685b63b62621db2f795ab71cc22ade83505e2 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:38:48 +1100 Subject: [PATCH] Lock recording sources and add permission onboarding --- electron/electron-env.d.ts | 2 + electron/ipc/capturePickerSources.ts | 20 +- electron/ipc/register/capturePicker.test.ts | 37 ++ electron/ipc/register/capturePicker.ts | 22 +- electron/ipc/register/permissions.test.ts | 67 +++ electron/ipc/register/permissions.ts | 13 +- electron/ipc/register/sources.ts | 15 +- electron/preload.ts | 6 + src/assets/onboarding/README.md | 13 + src/components/auth/OnboardingController.tsx | 25 +- src/components/auth/OnboardingFeature.tsx | 382 +++++++++++++----- src/components/auth/RecordlySignInDialog.tsx | 9 +- .../capture-picker/CapturePicker.tsx | 14 +- src/components/launch/LaunchWindow.tsx | 16 +- .../hooks/useLaunchWindowSystemState.ts | 6 +- src/hooks/useScreenRecorder.ts | 104 +++-- tests/ui/bridge.ts | 14 +- tests/ui/capture-picker.spec.ts | 121 +++++- tests/ui/onboarding.spec.ts | 156 ++++++- 19 files changed, 834 insertions(+), 208 deletions(-) create mode 100644 electron/ipc/register/permissions.test.ts create mode 100644 src/assets/onboarding/README.md diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 246521f9..4fe2ecd4 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -860,6 +860,8 @@ interface Window { path?: string; error?: string; }>; + showRecordingPermissions: () => Promise; + onRecordingPermissionsRequested: (callback: () => void) => () => void; showRecordingHud: () => Promise; createProjectFile: ( data: unknown, diff --git a/electron/ipc/capturePickerSources.ts b/electron/ipc/capturePickerSources.ts index accfb061..2c9b8da4 100644 --- a/electron/ipc/capturePickerSources.ts +++ b/electron/ipc/capturePickerSources.ts @@ -127,20 +127,16 @@ export async function listCapturePickerWindows(): Promise const bounds = await resolveLinuxWindowBounds(source); if (!bounds) throw new Error("Unable to detect a window position. Try the picker again."); - return bounds - ? { - ...bounds, - id: source.id, - appName: "", - title: source.name, - display_id: source.display_id, - } - : null; + return { + ...bounds, + id: source.id, + appName: "", + title: source.name, + display_id: source.display_id, + }; }), ); - windows.push( - ...batch.filter((entry): entry is NonNullable => entry !== null), - ); + windows.push(...batch); } return windows; } diff --git a/electron/ipc/register/capturePicker.test.ts b/electron/ipc/register/capturePicker.test.ts index 70ce8892..e00a15d4 100644 --- a/electron/ipc/register/capturePicker.test.ts +++ b/electron/ipc/register/capturePicker.test.ts @@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({ validate: vi.fn(), remember: vi.fn(), selected: vi.fn(), + recording: false, currentSource: null as Record | null, create: vi.fn(), close: vi.fn(), @@ -32,6 +33,9 @@ vi.mock("../capturePickerSources", () => ({ listCapturePickerWindows: mocks.list vi.mock("../cursor/bounds", () => ({ stopWindowBoundsCapture: vi.fn() })); vi.mock("../state", () => ({ setSelectedSource: mocks.selected, + get isCursorCaptureActive() { + return mocks.recording; + }, get selectedSource() { return mocks.currentSource; }, @@ -58,6 +62,7 @@ beforeEach(() => { vi.clearAllMocks(); mocks.handlers.clear(); mocks.currentSource = null; + mocks.recording = false; mocks.windows = [mocks.overlay]; Object.defineProperty(process, "platform", { value: "darwin" }); mocks.list.mockResolvedValue([]); @@ -155,3 +160,35 @@ it("reopens the currently selected saved area, without reusing an old area for a lastArea: null, }); }); + +it("does not open any source picker during recording or pause", async () => { + mocks.recording = true; + expect(await call("pick-capture-target", mocks.outsider)).toEqual({ + success: false, + canceled: true, + }); + expect(mocks.list).not.toHaveBeenCalled(); + expect(mocks.create).not.toHaveBeenCalled(); + expect(mocks.selected).not.toHaveBeenCalled(); +}); + +it("rejects a pending selection if recording starts while the picker is open", async () => { + const pending = call("pick-capture-target", mocks.outsider); + await vi.waitFor(() => expect(mocks.create).toHaveBeenCalledOnce()); + mocks.recording = true; + await call("complete-capture-pick", mocks.overlay, { kind: "screen", displayId: 1 }); + expect(await pending).toEqual({ success: false, canceled: true }); + expect(mocks.selected).not.toHaveBeenCalled(); +}); + +it("does not open overlays if recording starts while listing windows", async () => { + mocks.list.mockImplementation(async () => { + mocks.recording = true; + return []; + }); + expect(await call("pick-capture-target", mocks.outsider)).toEqual({ + success: false, + canceled: true, + }); + expect(mocks.create).not.toHaveBeenCalled(); +}); diff --git a/electron/ipc/register/capturePicker.ts b/electron/ipc/register/capturePicker.ts index af3eeef4..ab2b1be4 100644 --- a/electron/ipc/register/capturePicker.ts +++ b/electron/ipc/register/capturePicker.ts @@ -7,7 +7,7 @@ import { getCapturePickerWindows, } from "../../windows"; import { stopWindowBoundsCapture } from "../cursor/bounds"; -import { selectedSource, setSelectedSource } from "../state"; +import { isCursorCaptureActive, selectedSource, setSelectedSource } from "../state"; import { createAreaSource, createWindowSource, @@ -17,11 +17,7 @@ import { import type { CapturePick, CapturePickerWindow, SelectedSource } from "../types"; import { getScreen } from "../utils"; import { getScreenSourceIdForDisplay, isLikelyLinuxWaylandSession } from "./sourceMapping"; -import { - bringSelectedWindowForward, - broadcastSelectedSourceChange, - getDesktopSources, -} from "./sources"; +import { broadcastSelectedSourceChange, getDesktopSources } from "./sources"; /** The same source the screen list offers for this display. */ async function createScreenSource(displayId: number): Promise { @@ -68,6 +64,7 @@ export function registerCapturePickerHandlers() { let activePick: Promise | null = null; const pickTarget = async () => { + if (isCursorCaptureActive) return { success: false, canceled: true }; if (process.platform === "linux" && isLikelyLinuxWaylandSession(process.env)) { // Preserve the existing portal recording path: its system picker opens // when recording starts; overlay selection cannot identify Wayland windows. @@ -89,13 +86,14 @@ export function registerCapturePickerHandlers() { finishPick(null); // List windows before the overlays cover them. pickableWindows = Promise.resolve(await listCapturePickerWindows()); + if (isCursorCaptureActive) return { success: false, canceled: true }; const pick = await new Promise((resolve) => { resolvePendingPick = resolve; // Closing an overlay without confirming cancels the pick. createCapturePickerWindows(() => finishPick(null)); }); closeCapturePickerWindows(); - if (!pick) { + if (!pick || isCursorCaptureActive) { return { success: false, canceled: true }; } @@ -113,11 +111,8 @@ export function registerCapturePickerHandlers() { return { success: false, message: "That window or screen is no longer available." }; } - // Recording raises the window itself, so only raise it here when it is - // just being selected. - if (source.id?.startsWith("window:") && !pick.record) { - await bringSelectedWindowForward(source); - } + if (isCursorCaptureActive) return { success: false, canceled: true }; + // Recording brings the selected window forward; selection need not wait for it. setSelectedSource(source); rememberCaptureSource(source); broadcastSelectedSourceChange(); @@ -125,6 +120,7 @@ export function registerCapturePickerHandlers() { return { success: true, source }; }; ipcMain.handle("pick-capture-target", () => { + if (isCursorCaptureActive) return { success: false, canceled: true }; if (!activePick) { activePick = pickTarget() .catch((error) => { @@ -155,7 +151,7 @@ export function registerCapturePickerHandlers() { process.platform !== "darwin" ) return; - if (!input) { + if (!input || isCursorCaptureActive) { finishPick(null); return; } diff --git a/electron/ipc/register/permissions.test.ts b/electron/ipc/register/permissions.test.ts new file mode 100644 index 00000000..4491267b --- /dev/null +++ b/electron/ipc/register/permissions.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +const mocks = vi.hoisted(() => ({ + handlers: new Map unknown>(), + status: vi.fn(), + sources: vi.fn(), + open: vi.fn(), +})); +vi.mock("electron", () => ({ + ipcMain: { + handle: (key: string, handler: (...args: unknown[]) => unknown) => + mocks.handlers.set(key, handler), + }, + desktopCapturer: { getSources: mocks.sources }, + systemPreferences: { getMediaAccessStatus: mocks.status }, + shell: { openExternal: mocks.open }, +})); +vi.mock("../utils", () => ({ getMacPrivacySettingsUrl: (pane: string) => `settings:${pane}` })); +import { registerPermissionHandlers } from "./permissions"; +const platform = process.platform; +afterEach(() => Object.defineProperty(process, "platform", { value: platform })); +beforeEach(() => { + vi.resetAllMocks(); + Object.defineProperty(process, "platform", { value: "darwin" }); + mocks.handlers.clear(); + registerPermissionHandlers(); +}); +it("does not prompt or open settings merely by registering permission handlers", () => { + expect(mocks.sources).not.toHaveBeenCalled(); + expect(mocks.open).not.toHaveBeenCalled(); +}); +it("registers first-time Screen Recording access before opening its settings", async () => { + mocks.status.mockReturnValue("not-determined"); + mocks.sources.mockResolvedValue([]); + expect(await mocks.handlers.get("open-screen-recording-preferences")!()).toEqual({ + success: true, + }); + expect(mocks.sources).toHaveBeenCalledWith({ + types: ["screen"], + thumbnailSize: { width: 0, height: 0 }, + }); + expect(mocks.sources.mock.invocationCallOrder[0]).toBeLessThan( + mocks.open.mock.invocationCallOrder[0], + ); + expect(mocks.open).toHaveBeenCalledWith("settings:screen"); +}); +it("opens settings even when the first-time capture request is rejected", async () => { + mocks.status.mockReturnValue("not-determined"); + mocks.sources.mockRejectedValue(new Error("Denied")); + expect(await mocks.handlers.get("open-screen-recording-preferences")!()).toEqual({ + success: true, + }); + expect(mocks.open).toHaveBeenCalledWith("settings:screen"); +}); +it("does not enumerate screens for an already determined permission", async () => { + mocks.status.mockReturnValue("denied"); + await mocks.handlers.get("open-screen-recording-preferences")!(); + expect(mocks.sources).not.toHaveBeenCalled(); + expect(mocks.open).toHaveBeenCalledWith("settings:screen"); +}); +it("leaves native macOS permission controls unused on other platforms", async () => { + Object.defineProperty(process, "platform", { value: "win32" }); + expect(await mocks.handlers.get("open-screen-recording-preferences")!()).toEqual({ + success: true, + }); + expect(mocks.sources).not.toHaveBeenCalled(); + expect(mocks.open).not.toHaveBeenCalled(); +}); diff --git a/electron/ipc/register/permissions.ts b/electron/ipc/register/permissions.ts index f3b8b86f..b529c739 100644 --- a/electron/ipc/register/permissions.ts +++ b/electron/ipc/register/permissions.ts @@ -1,4 +1,4 @@ -import { ipcMain, shell, systemPreferences } from "electron"; +import { desktopCapturer, ipcMain, shell, systemPreferences } from "electron"; import { getMacPrivacySettingsUrl } from "../utils"; export function registerPermissionHandlers() { @@ -63,6 +63,17 @@ export function registerPermissionHandlers() { } try { + // First-time access must register Recordly in macOS before its settings row can be enabled. + if (systemPreferences.getMediaAccessStatus("screen") === "not-determined") { + try { + await desktopCapturer.getSources({ + types: ["screen"], + thumbnailSize: { width: 0, height: 0 }, + }); + } catch { + // A denied prompt still needs the settings page so access can be enabled later. + } + } await shell.openExternal(getMacPrivacySettingsUrl("screen")); return { success: true }; } catch (error) { diff --git a/electron/ipc/register/sources.ts b/electron/ipc/register/sources.ts index e62d6563..6f52a208 100644 --- a/electron/ipc/register/sources.ts +++ b/electron/ipc/register/sources.ts @@ -1,3 +1,4 @@ +import { writeAppSetting } from "../../appSettingsStore"; import { restoreLastCaptureSource } from "../captureSelection"; import { createRecordingEditorNavigation } from "../../recordingEditorNavigation"; import { execFile } from "node:child_process"; @@ -17,7 +18,7 @@ import { stopWindowBoundsCapture, } from "../cursor/bounds"; import { getDisplayBoundsForSource, getDisplayWorkAreaForSource } from "../recording/ffmpeg"; -import { selectedSource, setSelectedSource } from "../state"; +import { isCursorCaptureActive, selectedSource, setSelectedSource } from "../state"; import type { SelectedSource, WindowBounds } from "../types"; import { getSourceArea } from "../sourceArea"; import { getScreen, parseWindowId } from "../utils"; @@ -400,9 +401,11 @@ export function registerSourceHandlers({ ipcMain.handle("get-sources", (_, opts) => getDesktopSources(opts)); ipcMain.handle("select-source", async (_, source: SelectedSource) => { + if (isCursorCaptureActive) return selectedSource; if (source.id?.startsWith("window:")) { await bringSelectedWindowForward(source); } + if (isCursorCaptureActive) return selectedSource; setSelectedSource(source); broadcastSelectedSourceChange(); stopWindowBoundsCapture(); @@ -603,6 +606,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} }); ipcMain.handle("open-source-selector", () => { + if (isCursorCaptureActive) return; const sourceSelectorWin = getSourceSelectorWindow(); if (sourceSelectorWin) { sourceSelectorWin.focus(); @@ -621,6 +625,15 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} createHudOverlayWindow(); } }); + ipcMain.handle("show-recording-permissions", () => { + if (process.platform !== "darwin" || isCursorCaptureActive) return; + writeAppSetting("recordly.onboarding.permissionsRequested", true); + setHudRecordingPreparationActive(false); + recordingNavigation.open(false); + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) window.webContents.send("recording-permissions-requested"); + } + }); ipcMain.handle("show-project-dashboard", () => { setHudRecordingPreparationActive(false); recordingNavigation.open(false); diff --git a/electron/preload.ts b/electron/preload.ts index f5e20d94..014914fc 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -513,6 +513,12 @@ contextBridge.exposeInMainWorld("electronAPI", { getSources: async (opts: Electron.SourcesOptions) => { return await ipcRenderer.invoke("get-sources", opts); }, + showRecordingPermissions: () => ipcRenderer.invoke("show-recording-permissions"), + onRecordingPermissionsRequested: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("recording-permissions-requested", listener); + return () => ipcRenderer.removeListener("recording-permissions-requested", listener); + }, showRecordingHud: () => ipcRenderer.invoke("show-recording-hud"), createProjectFile: (data: unknown, thumbnail?: string | null) => ipcRenderer.invoke("create-project-file", data, thumbnail), diff --git a/src/assets/onboarding/README.md b/src/assets/onboarding/README.md new file mode 100644 index 00000000..558cf6c7 --- /dev/null +++ b/src/assets/onboarding/README.md @@ -0,0 +1,13 @@ +# Onboarding clips + +Place optional square MP4 clips in this folder to replace the three illustrations: + +- `record.mp4` +- `preview.mp4` +- `share.mp4` + +They are discovered at build time. Use short, silent H.264 MP4s with the important +content inside the square frame. Clips loop muted while the page is open, and +show playback controls instead of autoplay when reduced motion is enabled. +Missing or failed clips retain the illustration. Change the paragraph in +`src/components/auth/OnboardingFeature.tsx` to update the introductory copy. diff --git a/src/components/auth/OnboardingController.tsx b/src/components/auth/OnboardingController.tsx index 455780de..7c1208d8 100644 --- a/src/components/auth/OnboardingController.tsx +++ b/src/components/auth/OnboardingController.tsx @@ -19,24 +19,45 @@ export function OnboardingController({ callbackError?: string; }) { const [open, setOpen] = useState(false); + const [startWithPermissions, setStartWithPermissions] = useState(false); const checked = useRef(false); useEffect(() => { if (requestNonce > 0) setOpen(true); }, [requestNonce]); const close = useCallback(() => { saveAppSetting(SEEN_KEY, true); + saveAppSetting("recordly.onboarding.permissionsRequested", false); + setStartWithPermissions(false); setOpen(false); }, []); useEffect(() => { if (!ready || checked.current) return; checked.current = true; - if (!loadAppSetting(SEEN_KEY)) setOpen(true); + if (loadAppSetting("recordly.onboarding.permissionsRequested")) { + setStartWithPermissions(true); + setOpen(true); + } else if (!loadAppSetting(SEEN_KEY)) setOpen(true); }, [ready]); + useEffect( + () => + window.electronAPI.onRecordingPermissionsRequested?.(() => { + setStartWithPermissions(true); + setOpen(true); + }), + [], + ); + + const finish = () => { + close(); + void window.electronAPI.showRecordingHud(); + }; + return ( { @@ -45,7 +66,7 @@ export function OnboardingController({ user={user} configured={configured} callbackError={callbackError} - onAuthenticated={close} + onAuthenticated={finish} /> ); } diff --git a/src/components/auth/OnboardingFeature.tsx b/src/components/auth/OnboardingFeature.tsx index ea8817f6..44d6db91 100644 --- a/src/components/auth/OnboardingFeature.tsx +++ b/src/components/auth/OnboardingFeature.tsx @@ -1,147 +1,311 @@ +import { useCallback, useEffect, useRef, useState } from "react"; import { Button, Description, Modal, Surface } from "@heroui/react"; -import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { ArrowLeft, ArrowRight, Check, - Link, + Cursor, Monitor, Play, - Record, - SlidersHorizontal, + ShareNetwork, + ShieldCheck, } from "@phosphor-icons/react"; -const features = [ - { - name: "Record", - title: "Start with a moment.", - description: "Choose your screen or a window. Add your voice and camera, then hit record.", - }, - { - name: "Preview", - title: "Make it yours.", - description: "Trim the take, frame your video, and bring the important details into focus.", - }, - { - name: "Share", - title: "Ready when you are.", - description: - "Publish a link for anyone to watch, or save the finished video to your device.", - }, -]; +const steps = [ + { name: "Record", key: "record", icon: Monitor, color: "from-sky-100 to-blue-200" }, + { name: "Preview", key: "preview", icon: Play, color: "from-violet-100 to-indigo-200" }, + { name: "Share", key: "share", icon: ShareNetwork, color: "from-rose-100 to-orange-100" }, +] as const; -// Decorative product illustrations stay crisp as the image frame changes size. -function FeatureIllustration({ step }: { step: number }) { +type Step = (typeof steps)[number]["key"]; +// Optional clips are bundled only when provided. Missing clips use illustrations, without 404s. +const clips = import.meta.glob("/src/assets/onboarding/{record,preview,share}.mp4", { + eager: true, + query: "?url", + import: "default", +}); + +function StepTile({ + step, + src, + reduceMotion, +}: { + step: (typeof steps)[number]; + src?: string; + reduceMotion: boolean; +}) { + const [failed, setFailed] = useState(false); + const player = useRef(null); + useEffect(() => { + if (!src) return; + if (reduceMotion) player.current?.pause(); + else void player.current?.play().catch(() => undefined); + }, [reduceMotion, src]); + const Icon = step.icon; return ( -