mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-11-10 16:04:38 +00:00
Lock recording sources and add permission onboarding
This commit is contained in:
1 parent
6c49ffbc2b
commit
a60685b63b
19 files changed
+834
-208
No files matched your search
Vendored
+2
@@ -860,6 +860,8 @@ interface Window {
|
||||
path?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
showRecordingPermissions: () => Promise<void>;
|
||||
onRecordingPermissionsRequested: (callback: () => void) => () => void;
|
||||
showRecordingHud: () => Promise<void>;
|
||||
createProjectFile: (
|
||||
data: unknown,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user