Lock recording sources and add permission onboarding

This commit is contained in:
webadderall committed 2026-10-06 13:38:48 +11:00
1 parent 6c49ffbc2b
commit a60685b63b
19 files changed
+834 -208

No files matched your search

+2
View File
@@ -860,6 +860,8 @@ interface Window {
path?: string;
error?: string;
}>;
showRecordingPermissions: () => Promise<void>;
onRecordingPermissionsRequested: (callback: () => void) => () => void;
showRecordingHud: () => Promise<void>;
createProjectFile: (
data: unknown,
+8 -12
View File
@@ -127,20 +127,16 @@ export async function listCapturePickerWindows(): Promise<CapturePickerWindow[]>
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<typeof entry> => entry !== null),
);
windows.push(...batch);
}
return windows;
}
@@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({
validate: vi.fn(),
remember: vi.fn(),
selected: vi.fn(),
recording: false,
currentSource: null as Record<string, unknown> | 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();
});
+9 -13
View File
@@ -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<SelectedSource | null> {
@@ -68,6 +64,7 @@ export function registerCapturePickerHandlers() {
let activePick: Promise<unknown> | 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<CapturePick | null>((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;
}
+67
View File
@@ -0,0 +1,67 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
handlers: new Map<string, (...args: unknown[]) => 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();
});
+12 -1
View File
@@ -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) {
+14 -1
View File
@@ -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);
+6
View File
@@ -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),
+13
View File
@@ -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.
+23 -2
View File
@@ -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<boolean>(SEEN_KEY)) setOpen(true);
if (loadAppSetting<boolean>("recordly.onboarding.permissionsRequested")) {
setStartWithPermissions(true);
setOpen(true);
} else if (!loadAppSetting<boolean>(SEEN_KEY)) setOpen(true);
}, [ready]);
useEffect(
() =>
window.electronAPI.onRecordingPermissionsRequested?.(() => {
setStartWithPermissions(true);
setOpen(true);
}),
[],
);
const finish = () => {
close();
void window.electronAPI.showRecordingHud();
};
return (
<RecordlySignInDialog
onboarding
startWithPermissions={startWithPermissions}
variant="wide"
open={open}
onOpenChange={(value) => {
@@ -45,7 +66,7 @@ export function OnboardingController({
user={user}
configured={configured}
callbackError={callbackError}
onAuthenticated={close}
onAuthenticated={finish}
/>
);
}
+273 -109
View File
@@ -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<string>("/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<HTMLVideoElement>(null);
useEffect(() => {
if (!src) return;
if (reduceMotion) player.current?.pause();
else void player.current?.play().catch(() => undefined);
}, [reduceMotion, src]);
const Icon = step.icon;
return (
<div
aria-hidden="true"
className="flex h-full items-center justify-center p-6 sm:p-10"
>
<div className="relative w-full max-w-[580px] overflow-hidden rounded-2xl border border-white/50 bg-white/85 text-zinc-900 shadow-2xl backdrop-blur-xl">
<div className="flex items-center gap-1.5 border-b border-black/5 px-4 py-3">
{[0, 1, 2].map((i) => (
<span key={i} className="size-2 rounded-full bg-zinc-300" />
))}
<span className="ml-3 text-xs font-medium">
{features[step].name === "Share" ? "Your recording" : "Recordly"}
</span>
</div>
<div className="relative mx-5 mb-5 mt-4 flex aspect-video items-center justify-center overflow-hidden rounded-lg bg-gradient-to-br from-indigo-200 via-sky-100 to-rose-200">
<div className="flex w-3/5 flex-col gap-3 rounded-lg bg-white/90 p-5 shadow-lg">
<div className="h-2 w-1/3 rounded bg-indigo-300" />
<div className="h-3 w-4/5 rounded bg-zinc-700" />
<div className="h-2 w-full rounded bg-zinc-200" />
<div className="h-2 w-2/3 rounded bg-zinc-200" />
</div>
{step === 0 && (
<div className="absolute bottom-3 flex items-center gap-3 rounded-xl bg-white px-4 py-2 shadow-lg">
<Monitor size={20} />
<span className="text-xs">Entire screen</span>
<Record size={24} weight="fill" className="text-red-500" />
</div>
)}
{step === 1 && (
<div className="absolute bottom-3 flex items-center gap-3 rounded-xl bg-white px-4 py-2 shadow-lg">
<SlidersHorizontal size={20} />
<div className="h-1.5 w-24 rounded-full bg-blue-500" />
<span className="text-xs">Preview</span>
</div>
)}
{step === 2 && (
<div className="absolute flex size-14 items-center justify-center rounded-full bg-white/90 shadow-lg">
<Play size={24} weight="fill" />
</div>
)}
</div>
{step === 2 && (
<div className="mx-5 mb-5 flex items-center justify-center gap-2 rounded-lg bg-blue-500 p-3 text-sm font-medium text-white">
<Link size={18} />
Copy link
<Check size={16} />
</div>
<div className="min-w-0 flex-1 text-center">
<div
className={`relative flex aspect-square items-center justify-center overflow-hidden rounded-[24px] border border-black/5 bg-gradient-to-br ${step.color}`}
>
<Icon aria-hidden="true" size={52} weight="duotone" className="text-zinc-700" />
{src && !failed && (
<video
ref={player}
aria-label={`${step.name} demonstration`}
className="absolute inset-0 size-full object-cover"
autoPlay={!reduceMotion}
muted
loop
playsInline
controls={Boolean(reduceMotion)}
preload="metadata"
onError={() => setFailed(true)}
>
<source src={src} type="video/mp4" onError={() => setFailed(true)} />
</video>
)}
</div>
<h3 className="mt-4 text-base font-semibold sm:text-lg">{step.name}</h3>
</div>
);
}
export function OnboardingFeature({
step,
onStep,
onBack,
onFinish,
media = {},
}: {
step: number;
onStep: (step: number) => void;
onBack: () => void;
onFinish: () => void;
media?: Partial<Record<Step, string>>;
}) {
const reduceMotion = useReducedMotion();
const feature = features[step];
const [page, setPage] = useState<"checking" | "permissions" | "overview">("checking");
const [reduceMotion, setReduceMotion] = useState(
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
);
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const change = () => setReduceMotion(query.matches);
query.addEventListener("change", change);
return () => query.removeEventListener("change", change);
}, []);
const [screenGranted, setScreenGranted] = useState(false);
const [accessibilityGranted, setAccessibilityGranted] = useState(false);
const [busy, setBusy] = useState<string>();
const [error, setError] = useState<string>();
const refresh = useCallback(async () => {
const [screen, accessibility] = await Promise.all([
window.electronAPI.getScreenRecordingPermissionStatus(),
window.electronAPI.getAccessibilityPermissionStatus(),
]);
if (!screen.success || !accessibility.success)
throw new Error("Could not check permissions. Try again.");
setScreenGranted(screen.status === "granted");
setAccessibilityGranted(accessibility.trusted);
setError(undefined);
return screen.status === "granted" && accessibility.trusted;
}, []);
useEffect(() => {
let disposed = false;
void (async () => {
try {
const mac = (await window.electronAPI.getPlatform()) === "darwin";
const granted = !mac || (await refresh());
if (!disposed) setPage(granted ? "overview" : "permissions");
} catch (error) {
if (!disposed) {
setError(String(error));
setPage("permissions");
}
}
})();
return () => {
disposed = true;
};
}, [refresh]);
useEffect(() => {
if (page !== "permissions") return;
const check = () => {
void refresh().catch((error) => setError(String(error)));
};
const visible = () => {
if (document.visibilityState === "visible") check();
};
window.addEventListener("focus", check);
document.addEventListener("visibilitychange", visible);
return () => {
window.removeEventListener("focus", check);
document.removeEventListener("visibilitychange", visible);
};
}, [page, refresh]);
const allow = async (permission: "screen" | "accessibility") => {
if (busy) return;
setBusy(permission);
try {
if (permission === "accessibility") {
const result = await window.electronAPI.requestAccessibilityPermission();
if (!result.success)
throw new Error(result.error || "Could not request Accessibility access.");
if (!result.trusted) {
const opened = await window.electronAPI.openAccessibilityPreferences();
if (!opened.success)
throw new Error(opened.error || "Could not open System Settings.");
}
} else {
const result = await window.electronAPI.openScreenRecordingPreferences();
if (!result.success)
throw new Error(result.error || "Could not open System Settings.");
}
await refresh();
} catch (error) {
setError(error instanceof Error ? error.message : String(error));
} finally {
setBusy(undefined);
}
};
return (
<div className="absolute inset-0 flex flex-col">
<div className="relative min-h-0 flex-1 overflow-hidden">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={step}
initial={{ opacity: 0, y: reduceMotion ? 0 : 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: reduceMotion ? 0 : 0.2 }}
className="absolute inset-0"
>
<FeatureIllustration step={step} />
</motion.div>
</AnimatePresence>
</div>
<Surface className="shrink-0 border-t border-separator px-6 py-5 sm:px-8 sm:py-6">
<div className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
<Modal.Header aria-live="polite" className="min-w-0 flex-1 gap-2">
<Modal.Heading className="text-2xl font-semibold tracking-tight sm:text-3xl">
{feature.title}
<Surface className="absolute inset-0 flex flex-col justify-center overflow-y-auto px-6 py-10 sm:px-12 sm:py-12">
{page === "checking" ? (
<p role="status" className="text-center text-sm text-muted">
Checking permissions…
</p>
) : page === "permissions" ? (
<div className="mx-auto w-full max-w-[600px]">
<ShieldCheck size={36} className="mb-5 text-accent" />
<Modal.Header className="gap-3">
<Modal.Heading className="text-3xl font-semibold tracking-tight">
Allow Recordly to record
</Modal.Heading>
<Description className="text-sm leading-relaxed">
{feature.description}
Enable these permissions in macOS System Settings. Your screen is
captured only when you start a recording.
</Description>
</Modal.Header>
<Modal.Footer className="shrink-0 gap-2">
<div className="my-7 space-y-3">
{[
{
key: "screen" as const,
name: "Screen Recording",
description: "Capture a screen, window or selected area.",
granted: screenGranted,
icon: Monitor,
},
{
key: "accessibility" as const,
name: "Accessibility",
description: "Track your cursor and highlight clicks.",
granted: accessibilityGranted,
icon: Cursor,
},
].map(({ key, name, description, granted, icon: Icon }) => (
<div
key={key}
className="flex items-center gap-4 rounded-2xl border border-separator p-4"
>
<Icon size={24} className="shrink-0 text-muted" />
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold">{name}</h3>
<p className="mt-1 text-xs text-muted">{description}</p>
</div>
{granted ? (
<span className="flex shrink-0 items-center gap-1.5 text-xs text-success">
<Check size={16} />
Allowed
</span>
) : (
<Button
variant="secondary"
isDisabled={Boolean(busy)}
onPress={() => void allow(key)}
aria-label={`Allow ${name}`}
>
{busy === key ? "Opening…" : "Allow"}
</Button>
)}
</div>
))}
</div>
<p className="text-xs leading-relaxed text-muted">
Return here after enabling access. macOS may ask you to quit and reopen
Recordly for Screen Recording changes to take effect. Microphone and camera
access are requested when you use them.
</p>
{error && (
<p role="alert" className="mt-3 text-sm text-danger">
{error}
</p>
)}
<Modal.Footer className="mt-7 gap-3">
<Button
isIconOnly
aria-label={step === 0 ? "Back to sign in" : "Previous feature"}
variant="secondary"
onPress={() => onStep(step - 1)}
onPress={() => void refresh().catch((error) => setError(String(error)))}
>
Check again
</Button>
<Button
isDisabled={!screenGranted || !accessibilityGranted || Boolean(busy)}
onPress={() => setPage("overview")}
>
Continue
</Button>
</Modal.Footer>
</div>
) : (
<div className="mx-auto w-full max-w-[780px]">
<Modal.Header className="mb-8 items-center gap-2 text-center sm:mb-10">
<Modal.Heading className="text-3xl font-semibold tracking-tight sm:text-4xl">
Your next great recording
</Modal.Heading>
</Modal.Header>
<div className="flex items-start gap-2 sm:gap-4">
{steps.map((step, index) => (
<div key={step.key} className="contents">
<StepTile
step={step}
reduceMotion={reduceMotion}
src={
media[step.key] ??
clips[`/src/assets/onboarding/${step.key}.mp4`]
}
/>
{index < steps.length - 1 && (
<div
aria-hidden="true"
className="flex aspect-square w-5 shrink-0 items-center justify-center self-center pb-10 sm:w-8"
>
<ArrowRight size={24} className="text-muted" />
</div>
)}
</div>
))}
</div>
<div className="mx-auto mt-8 max-w-[550px] text-center sm:mt-10">
<Description className="text-sm leading-relaxed">
Capture a moment, polish it in the preview, and share it with anyone.
Recordly brings your screen, voice and camera together so your ideas are
easy to follow.
</Description>
</div>
<Modal.Footer className="mt-8 justify-center gap-3 sm:mt-10">
<Button
variant="secondary"
isIconOnly
aria-label="Back to sign in"
onPress={onBack}
>
<ArrowLeft size={18} />
</Button>
<Button
isIconOnly
aria-label={step === 2 ? "Get started" : "Next"}
variant="secondary"
onPress={() => (step === 2 ? onFinish() : onStep(step + 1))}
>
<Button onPress={onFinish}>
Start recording
<ArrowRight size={18} />
</Button>
</Modal.Footer>
</div>
</Surface>
</div>
)}
</Surface>
);
}
+7 -2
View File
@@ -32,6 +32,7 @@ export type SignInReason = "account" | "share";
type Props = {
variant?: "compact" | "wide";
onboarding?: boolean;
startWithPermissions?: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
reason?: SignInReason;
@@ -61,6 +62,7 @@ function friendlyAuthError(
export function RecordlySignInDialog({
variant = "compact",
onboarding = false,
startWithPermissions = false,
open,
onOpenChange,
user,
@@ -91,6 +93,10 @@ export function RecordlySignInDialog({
}
}, [open]);
useEffect(() => {
if (open && startWithPermissions) setFeatureStep(0);
}, [open, startWithPermissions]);
useEffect(() => {
if (!open) return;
if (!user) awaitingSignIn.current = true;
@@ -184,8 +190,7 @@ export function RecordlySignInDialog({
/>
{feature && (
<OnboardingFeature
step={featureStep}
onStep={setFeatureStep}
onBack={() => setFeatureStep(-1)}
onFinish={onAuthenticated}
/>
)}
@@ -35,7 +35,6 @@ export function CapturePicker() {
drawing: boolean;
rect?: AreaRect;
handle?: AreaHandle;
moving?: boolean;
} | null>(null);
const editingArea = useRef(false);
const completed = useRef(false);
@@ -46,6 +45,17 @@ export function CapturePicker() {
: { kind: "screen", rect: screenRect() };
}, []);
const update = (next: Target) => {
const previous = current.current;
if (
previous?.kind === next.kind &&
(previous.kind !== "window" ||
(next.kind === "window" && previous.window.id === next.window.id)) &&
previous.rect.x === next.rect.x &&
previous.rect.y === next.rect.y &&
previous.rect.width === next.rect.width &&
previous.rect.height === next.rect.height
)
return;
current.current = next;
setTarget(next);
};
@@ -204,7 +214,7 @@ export function CapturePicker() {
y: event.clientY,
drawing: false,
...(area && (handle || rectContains(area, event.clientX, event.clientY))
? { rect: area, handle, moving: !handle }
? { rect: area, handle }
: {}),
};
event.currentTarget.setPointerCapture(event.pointerId);
+13 -3
View File
@@ -60,6 +60,7 @@ function LaunchWindowContent() {
pauseRecording,
resumeRecording,
cancelRecording,
cancelRecordingStart,
microphoneEnabled,
setMicrophoneEnabled,
microphoneDeviceId,
@@ -171,11 +172,13 @@ function LaunchWindowContent() {
const [isPickerPending, setIsPickerPending] = useState(false);
const [sourceReady, setSourceReady] = useState(false);
const handlePickOnScreen = useCallback(async () => {
if (pickerPending.current || recording || countdownActive || finalizing) return;
if (pickerPending.current || recording || finalizing) return;
pickerPending.current = true;
setIsPickerPending(true);
beginInteractiveHudAction();
try {
if (!(await cancelRecordingStart())) return;
if (!(await preparePermissions())) return;
const result = await window.electronAPI.pickCaptureTarget();
if (result.success && result.source) syncSelectedSource(result.source);
if (result.message && !result.canceled) {
@@ -188,7 +191,14 @@ function LaunchWindowContent() {
pickerPending.current = false;
setIsPickerPending(false);
}
}, [recording, countdownActive, finalizing, beginInteractiveHudAction, syncSelectedSource]);
}, [
recording,
cancelRecordingStart,
preparePermissions,
finalizing,
beginInteractiveHudAction,
syncSelectedSource,
]);
useEffect(() => {
let mounted = true;
@@ -259,7 +269,7 @@ function LaunchWindowContent() {
className={`${styles.electronNoDrag} group gap-2 px-3 min-w-0 max-w-[180px] shrink-0`}
title={selectedSource}
aria-label="Choose recording source"
disabled={isPickerPending || !sourceReady || countdownActive}
disabled={isPickerPending || !sourceReady || recording || finalizing}
onClick={() => void handlePickOnScreen()}
>
<MonitorIcon size={18} className="size-5 shrink-0" />
@@ -1,8 +1,6 @@
import { useEffect, useState } from "react";
export function useLaunchWindowSystemState(
preparePermissions: (args: { startup?: boolean }) => Promise<unknown>,
) {
export function useLaunchWindowSystemState(preparePermissions: () => Promise<unknown>) {
const [hudOverlayMousePassthroughSupported, setHudOverlayMousePassthroughSupported] = useState<
boolean | null
>(null);
@@ -47,7 +45,7 @@ export function useLaunchWindowSystemState(
}, []);
useEffect(() => {
void preparePermissions({ startup: true });
void preparePermissions();
}, [preparePermissions]);
return { hudOverlayMousePassthroughSupported, platform };
+63 -41
View File
@@ -135,7 +135,8 @@ type UseScreenRecorderReturn = {
pauseRecording: () => void;
resumeRecording: () => void;
cancelRecording: () => void;
preparePermissions: (options?: { startup?: boolean }) => Promise<boolean>;
cancelRecordingStart: () => Promise<boolean>;
preparePermissions: () => Promise<boolean>;
isMacOS: boolean;
microphoneEnabled: boolean;
setMicrophoneEnabled: (enabled: boolean) => void;
@@ -374,7 +375,12 @@ async function createAudioInputDeviceSnapshot(): Promise<
}
export function useScreenRecorder(): UseScreenRecorderReturn {
const [recording, setRecording] = useState(false);
const [recording, setRecordingValue] = useState(false);
const recordingActive = useRef(false);
const setRecording = useCallback((value: boolean) => {
recordingActive.current = value;
setRecordingValue(value);
}, []);
const [paused, setPaused] = useState(false);
const [starting, setStarting] = useState(false);
const [finalizing, setFinalizing] = useState(false);
@@ -406,6 +412,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const recordingStartGeneration = useRef(0);
const nativeStopRequestInFlight = useRef(false);
const startInFlight = useRef(false);
const pendingStart = useRef<Promise<void> | null>(null);
const hasPromptedForReselect = useRef(false);
const hasShownNativeWindowsFallbackToast = useRef(false);
const countdownDelayLoaded = useRef(false);
@@ -546,44 +553,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
micFallbackPauseIntervals.current = [];
}, []);
const preparePermissions = useCallback(async (options: { startup?: boolean } = {}) => {
const platform = await window.electronAPI.getPlatform();
if (platform !== "darwin") {
const preparePermissions = useCallback(async () => {
if ((await window.electronAPI.getPlatform()) !== "darwin") return true;
const [screen, accessibility] = await Promise.all([
window.electronAPI.getScreenRecordingPermissionStatus(),
window.electronAPI.getAccessibilityPermissionStatus(),
]);
if (
screen.success &&
screen.status === "granted" &&
accessibility.success &&
accessibility.trusted
)
return true;
}
const screenPermission = await window.electronAPI.getScreenRecordingPermissionStatus();
if (!screenPermission.success || screenPermission.status !== "granted") {
await window.electronAPI.openScreenRecordingPreferences();
alert(
options.startup
? "Recordly needs Screen Recording permission before you start. System Settings has been opened. After enabling it, quit and reopen Recordly."
: "Screen Recording permission is still missing. System Settings has been opened again. Enable it, then quit and reopen Recordly before recording.",
);
return false;
}
const accessibilityPermission = await window.electronAPI.getAccessibilityPermissionStatus();
if (!accessibilityPermission.success) {
return false;
}
if (accessibilityPermission.trusted) {
return true;
}
const requestedAccessibility = await window.electronAPI.requestAccessibilityPermission();
if (requestedAccessibility.success && requestedAccessibility.trusted) {
return true;
}
await window.electronAPI.openAccessibilityPreferences();
alert(
options.startup
? "Recordly also needs Accessibility permission for cursor tracking. System Settings has been opened. After enabling it, quit and reopen Recordly."
: "Accessibility permission is still missing. System Settings has been opened again. Enable it, then quit and reopen Recordly before recording.",
);
await window.electronAPI.showRecordingPermissions();
return false;
}, []);
@@ -1657,7 +1640,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
cleanupCapturedMedia();
};
}, [cleanupCapturedMedia, discardActiveNativeCapture, recoverNativeRecordingSession]);
}, [
cleanupCapturedMedia,
discardActiveNativeCapture,
recoverNativeRecordingSession,
setRecording,
]);
const startRecording = async () => {
if (startInFlight.current) {
@@ -1679,6 +1667,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
hasPromptedForReselect.current = false;
startInFlight.current = true;
let finishStart!: () => void;
pendingStart.current = new Promise<void>((resolve) => {
finishStart = resolve;
});
setStarting(true);
try {
@@ -1863,6 +1855,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
0,
Date.now() - mainStartedAt,
);
if (startWasCancelled()) return;
recorder.start(RECORDER_TIMESLICE_MS);
micFallbackRecorder.current = recorder;
} catch (micError) {
@@ -1906,11 +1899,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
}
if (startWasCancelled()) return;
if (nativeWindowsCaptureStartFailed && countdownDelay > 0) {
setCountdownActive(true);
try {
const result = await window.electronAPI.startCountdown(countdownDelay);
if (!result.success || result.cancelled) {
if (!result.success || result.cancelled || startWasCancelled()) {
cleanupCapturedMedia();
await stopWebcamRecorder();
return;
@@ -1922,6 +1916,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
resetRecordingClock(recordingSessionTimestamp.current);
}
if (startWasCancelled()) return;
const browserCursorPolicy = resolveBrowserCaptureCursorPolicy({
nativeWindowsCaptureStartFailed,
});
@@ -2247,6 +2242,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
resetRecordingClock(mainStartedAt);
webcamTimeOffsetMs.current =
webcamStartTime.current === null ? 0 : webcamStartTime.current - mainStartedAt;
if (startWasCancelled()) return;
recorder.start(RECORDER_TIMESLICE_MS);
setRecording(true);
try {
@@ -2274,6 +2270,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
await stopWebcamRecorder();
}
} finally {
try {
if (startWasCancelled()) {
await discardActiveNativeCapture();
cleanupCapturedMedia();
await Promise.allSettled([stopMicFallbackRecorder(), stopWebcamRecorder()]);
}
} catch (error) {
console.warn("Failed to clean up canceled recording startup:", error);
}
try {
await window.electronAPI.finishRecordingStartup();
} catch (error) {
@@ -2281,10 +2286,25 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
setHudSourceSelectionActive(false);
startInFlight.current = false;
pendingStart.current = null;
setStarting(false);
finishStart();
}
};
const cancelRecordingStart = useCallback(async () => {
if (recordingActive.current) return false;
const pending = pendingStart.current;
if (!pending) return true;
recordingStartGeneration.current += 1;
await window.electronAPI.cancelCountdown();
// Wait for native warm-up, webcam and microphone cleanup before picking again.
await pending;
if (nativeScreenRecording.current || pendingNativeCleanupPath.current)
throw new Error("Could not stop the pending capture. Try again.");
return true;
}, []);
const pauseRecording = useCallback(() => {
if (!recording || paused) return;
if (nativeScreenRecording.current) {
@@ -2418,6 +2438,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
cleanupCapturedMedia,
discardActiveNativeCapture,
markRecordingResumed,
setRecording,
recording,
stopMicFallbackRecorder,
]);
@@ -2444,6 +2465,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
pauseRecording,
resumeRecording,
cancelRecording,
cancelRecordingStart,
preparePermissions,
isMacOS,
microphoneEnabled,
+13 -1
View File
@@ -11,7 +11,8 @@ export async function installDesktopBridge(page: Page, videoFixture = "preview.m
};
Object.assign(window, {
electronAPI: {
getAppSetting: (key: string) => key === "recordly.onboarding.v1.seen" ? true : null,
getAppSetting: (key: string) =>
key === "recordly.onboarding.v1.seen" ? true : null,
finishRecordingStartup: async () => undefined,
showProjectDashboard: async () => {
document.documentElement.dataset.dashboardOpened = "true";
@@ -30,6 +31,17 @@ export async function installDesktopBridge(page: Page, videoFixture = "preview.m
getRecordingThumbnail: async () => ({ success: false, error: "Unavailable" }),
setAppSetting: () => true,
getExperimentalUpdatesEnabled: async () => false,
showRecordingPermissions: async () => {
document.documentElement.dataset.permissionsOpened = "true";
},
onRecordingPermissionsRequested: subscribe,
openScreenRecordingPreferences: success,
openAccessibilityPreferences: success,
requestAccessibilityPermission: async () => ({
success: true,
trusted: false,
prompted: true,
}),
getScreenRecordingPermissionStatus: async () => ({
success: true,
status: "granted",
+120 -1
View File
@@ -157,7 +157,8 @@ for (const { selected, trigger } of [
await expect(page.locator("html")).not.toHaveAttribute("data-picker-calls");
await (trigger === "source"
? control
: page.getByRole("button", { name: "Record", exact: true })).click();
: page.getByRole("button", { name: "Record", exact: true })
).click();
await expect(page.locator("html")).toHaveAttribute("data-picker-calls", "1");
await expect(control).toContainText("Selected window");
await expect(page.getByRole("button", { name: "Record", exact: true })).toBeVisible();
@@ -249,3 +250,121 @@ test("unselected source reads Pick source once and Record stays disabled while p
await expect(page.getByRole("button", { name: "Record", exact: true })).toBeEnabled();
await expect(source).toHaveText("Pick source");
});
test("changing source cancels countdown and discards native warm-up before opening the picker", async ({
page,
}) => {
await installDesktopBridge(page);
await page.addInitScript(() => {
let finishCountdown: (result: { success: boolean; cancelled: boolean }) => void;
const events: string[] = [];
Object.assign(window.electronAPI, {
startNativeScreenRecording: async () => ({ success: true }),
pauseNativeScreenRecording: async () => ({ success: true }),
resumeNativeScreenRecording: async () => {
events.push("resume");
return { success: true };
},
startCountdown: async () => {
document.documentElement.dataset.countdownStarted = "true";
return new Promise((resolve) => {
finishCountdown = resolve;
});
},
cancelCountdown: async () => {
events.push("cancel-countdown");
finishCountdown({ success: false, cancelled: true });
return { success: true };
},
stopNativeScreenRecording: async () => {
events.push("stop-native");
return { success: true, path: "/recordings/pending.mp4" };
},
deleteRecordingFile: async () => {
events.push("delete-file");
return { success: true };
},
setRecordingState: async (active: boolean) => {
if (active) events.push("recording");
},
pickCaptureTarget: async () => {
events.push("pick");
document.documentElement.dataset.events = JSON.stringify(events);
return { success: false, canceled: true };
},
});
});
await page.goto("/?windowType=hud-overlay");
await page.getByRole("button", { name: "Record", exact: true }).click();
await expect(page.locator("html")).toHaveAttribute("data-countdown-started", "true");
const source = page.getByRole("button", { name: "Choose recording source" });
await expect(source).toBeEnabled();
await source.click();
await expect(page.locator("html")).toHaveAttribute(
"data-events",
JSON.stringify(["cancel-countdown", "stop-native", "delete-file", "pick"]),
);
await expect(page.getByRole("button", { name: "Record", exact: true })).toBeEnabled();
});
test("recording and pause expose no interactive source picker", async ({ page }) => {
await installDesktopBridge(page);
await page.addInitScript(() => {
window.electronAPI.onRecordingStateChanged = (callback) => {
const listener = (event: Event) => callback((event as CustomEvent).detail);
window.addEventListener("test-recording", listener);
return () => window.removeEventListener("test-recording", listener);
};
window.electronAPI.pickCaptureTarget = async () => {
document.documentElement.dataset.pickerOpened = "true";
return { success: false, canceled: true };
};
});
await page.goto("/?windowType=hud-overlay");
await expect(page.getByRole("button", { name: "Choose recording source" })).toBeEnabled();
for (const paused of [false, true]) {
await page.evaluate(
(paused) =>
window.dispatchEvent(
new CustomEvent("test-recording", { detail: { recording: true, paused } }),
),
paused,
);
await expect(
page.getByRole("button", { name: "Choose recording source" }),
).not.toBeVisible();
await expect(page.locator("html")).not.toHaveAttribute("data-picker-opened");
}
});
test("changing source also cancels countdown on the browser capture path", async ({ page }) => {
await installDesktopBridge(page);
await page.addInitScript(() => {
let finishCountdown: (result: { success: boolean; cancelled: boolean }) => void;
Object.assign(window.electronAPI, {
getPlatform: async () => "linux",
startCountdown: async () => {
document.documentElement.dataset.countdownStarted = "true";
return new Promise((resolve) => {
finishCountdown = resolve;
});
},
cancelCountdown: async () => {
document.documentElement.dataset.countdownCancelled = "true";
finishCountdown({ success: false, cancelled: true });
return { success: true };
},
pickCaptureTarget: async () => {
document.documentElement.dataset.pickedAfterCancel =
document.documentElement.dataset.countdownCancelled;
return { success: false, canceled: true };
},
});
});
await page.goto("/?windowType=hud-overlay");
await page.getByRole("button", { name: "Record", exact: true }).click();
await expect(page.locator("html")).toHaveAttribute("data-countdown-started", "true");
await page.getByRole("button", { name: "Choose recording source" }).click();
await expect(page.locator("html")).toHaveAttribute("data-picked-after-cancel", "true");
await expect(page.getByRole("button", { name: "Record", exact: true })).toBeEnabled();
});
+140 -16
View File
@@ -1,10 +1,12 @@
import { expect, test } from "@playwright/test";
import { installDesktopBridge } from "./bridge";
test("settings button replays login and the expanding feature tour", async ({ page }) => {
test("settings button replays login and the recording overview", async ({ page }) => {
await installDesktopBridge(page);
await page.goto("/?windowType=editor");
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible({
timeout: 15000,
});
await page.getByRole("radio", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Show onboarding" }).click();
const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
@@ -21,19 +23,18 @@ test("settings button replays login and the expanding feature tour", async ({ pa
const loginWidth = (await artwork.boundingBox())!.width;
await tour.getByRole("button", { name: "Continue without signing in" }).click();
await expect(tour).toHaveAttribute("data-onboarding-state", "feature");
await expect(tour.getByRole("heading", { name: "Start with a moment." })).toBeVisible();
await expect(tour.getByRole("heading", { name: "Your next great recording" })).toBeVisible();
await expect
.poll(async () => (await artwork.boundingBox())!.width)
.toBeGreaterThan(loginWidth * 1.7);
await expect(artwork).toHaveCSS("transform", "none");
await expect(artwork.locator(":scope > img")).toHaveCSS("transform", "none");
await page.screenshot({ path: "test-results/onboarding-record.png", animations: "disabled" });
await tour.getByRole("button", { name: "Next", exact: true }).click();
await expect(tour.getByRole("heading", { name: "Make it yours." })).toBeVisible();
await tour.getByRole("button", { name: "Next", exact: true }).click();
await expect(tour.getByRole("heading", { name: "Ready when you are." })).toBeVisible();
await tour.getByRole("button", { name: "Get started" }).click();
for (const name of ["Record", "Preview", "Share"])
await expect(tour.getByRole("heading", { name, exact: true })).toBeVisible();
await tour.getByRole("button", { name: "Start recording" }).click();
await expect(tour).not.toBeVisible();
await expect(page.locator("html")).toHaveAttribute("data-hud-opened", "true");
await page.getByRole("radio", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Show onboarding" }).click();
await expect(tour).toHaveAttribute("data-onboarding-state", "login");
@@ -52,14 +53,14 @@ test("first launch persists completion and signed-in users can continue", async
await page.goto("/?windowType=editor");
const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
await tour.getByRole("button", { name: "Continue", exact: true }).click();
await tour.getByRole("button", { name: "Next", exact: true }).click();
await tour.getByRole("button", { name: "Next", exact: true }).click();
await tour.getByRole("button", { name: "Get started" }).click();
await tour.getByRole("button", { name: "Start recording" }).click();
await expect
.poll(() => page.evaluate(() => localStorage.getItem("recordly.onboarding.v1.seen")))
.toBe("true");
await page.reload();
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible({
timeout: 15000,
});
await expect(tour).not.toBeVisible();
});
@@ -68,12 +69,16 @@ test("feature banner fits a compact window with reduced motion", async ({ page }
await page.setViewportSize({ width: 620, height: 740 });
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/?windowType=editor");
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible({
timeout: 15000,
});
await page.getByRole("radio", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Show onboarding" }).click();
const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
await tour.getByRole("button", { name: "Continue without signing in" }).click();
await expect(tour.getByRole("button", { name: "Next", exact: true })).toBeInViewport();
await expect(
tour.getByRole("button", { name: "Start recording", exact: true }),
).toBeInViewport();
await page.screenshot({ path: "test-results/onboarding-compact.png" });
await tour.getByRole("button", { name: "Back to sign in" }).click();
await expect(tour).toHaveAttribute("data-onboarding-state", "login");
@@ -82,7 +87,9 @@ test("feature banner fits a compact window with reduced motion", async ({ page }
test("successful sign-in advances to Feature without closing onboarding", async ({ page }) => {
await installDesktopBridge(page);
await page.goto("/?windowType=editor");
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible({
timeout: 15000,
});
await page.getByRole("radio", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Show onboarding" }).click();
const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
@@ -90,5 +97,122 @@ test("successful sign-in advances to Feature without closing onboarding", async
await tour.locator('input[type="password"]').fill("1234");
await tour.getByRole("button", { name: "Sign in", exact: true }).click();
await expect(tour).toHaveAttribute("data-onboarding-state", "feature");
await expect(tour.getByRole("heading", { name: "Start with a moment." })).toBeVisible();
await expect(tour.getByRole("heading", { name: "Your next great recording" })).toBeVisible();
});
test("missing macOS permissions lead to explicit access controls before the overview", async ({
page,
}) => {
await installDesktopBridge(page);
await page.addInitScript(() => {
let screen = false;
let accessibility = false;
Object.assign(window.electronAPI, {
getScreenRecordingPermissionStatus: async () => ({
success: true,
status: screen ? "granted" : "denied",
}),
getAccessibilityPermissionStatus: async () => ({
success: true,
trusted: accessibility,
prompted: false,
}),
openScreenRecordingPreferences: async () => {
screen = true;
document.documentElement.dataset.screenSettings = "true";
return { success: true };
},
openAccessibilityPreferences: async () => {
accessibility = true;
document.documentElement.dataset.accessibilitySettings = "true";
return { success: true };
},
});
});
await page.goto("/?windowType=editor");
await page.getByRole("radio", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Show onboarding" }).click();
const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
await tour.getByRole("button", { name: "Continue without signing in" }).click();
await expect(tour.getByRole("heading", { name: "Allow Recordly to record" })).toBeVisible();
await expect(tour.getByRole("button", { name: "Continue", exact: true })).toBeDisabled();
await expect(page.locator("html")).not.toHaveAttribute("data-screen-settings");
await tour.getByRole("button", { name: "Allow Screen Recording", exact: true }).click();
await expect(page.locator("html")).toHaveAttribute("data-screen-settings", "true");
await expect(tour.getByRole("button", { name: "Continue", exact: true })).toBeDisabled();
await tour.getByRole("button", { name: "Allow Accessibility", exact: true }).click();
await expect(page.locator("html")).toHaveAttribute("data-accessibility-settings", "true");
await expect(tour.getByText("Allowed", { exact: true })).toHaveCount(2);
await page.screenshot({
path: "test-results/onboarding-permissions.png",
animations: "disabled",
});
await tour.getByRole("button", { name: "Continue", exact: true }).click();
await expect(tour.getByRole("button", { name: "Start recording" })).toBeVisible();
});
for (const platform of ["win32", "linux"]) {
test(`${platform} onboarding skips macOS permission APIs`, async ({ page }) => {
await installDesktopBridge(page);
await page.addInitScript((platform) => {
window.electronAPI.getPlatform = async () => platform;
window.electronAPI.getScreenRecordingPermissionStatus = async () => {
throw new Error("macOS-only check");
};
window.electronAPI.getAccessibilityPermissionStatus = async () => {
throw new Error("macOS-only check");
};
}, platform);
await page.goto("/?windowType=editor");
await page.getByRole("radio", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Show onboarding" }).click();
const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
await tour.getByRole("button", { name: "Continue without signing in" }).click();
await expect(tour.getByRole("button", { name: "Start recording" })).toBeVisible();
await expect(
tour.getByRole("heading", { name: "Allow Recordly to record" }),
).not.toBeVisible();
});
}
test("HUD routes missing permissions to onboarding without opening System Settings on launch", async ({
page,
}) => {
await installDesktopBridge(page);
await page.addInitScript(() => {
window.electronAPI.getScreenRecordingPermissionStatus = async () => ({
success: true,
status: "denied",
});
window.electronAPI.openScreenRecordingPreferences = async () => {
document.documentElement.dataset.settingsOpened = "true";
return { success: true };
};
});
await page.goto("/?windowType=hud-overlay");
await expect(page.locator("html")).toHaveAttribute("data-permissions-opened", "true");
await expect(page.locator("html")).not.toHaveAttribute("data-settings-opened");
});
test("a permission request opens its page even after onboarding was already completed", async ({
page,
}) => {
await installDesktopBridge(page);
await page.addInitScript(() => {
window.electronAPI.getAppSetting = (key) =>
key === "recordly.onboarding.v1.seen" ||
key === "recordly.onboarding.permissionsRequested"
? true
: null;
window.electronAPI.getScreenRecordingPermissionStatus = async () => ({
success: true,
status: "denied",
});
});
await page.goto("/?windowType=editor");
const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
await expect(tour.getByRole("heading", { name: "Allow Recordly to record" })).toBeVisible();
await expect(
tour.getByRole("button", { name: "Continue without signing in" }),
).not.toBeVisible();
});