Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
dddeb30
Pick a window, a screen or an area on screen
Kevin-Liu-01 Oct 3, 2026
36c13fb
Merge remote-tracking branch 'origin/pr-1090' into codex/capture-pick…
webadderall Oct 3, 2026
260ee93
Use selection-only capture picker across desktop platforms
webadderall Oct 3, 2026
1313f33
Harden capture picker and remember valid sources
webadderall Oct 4, 2026
8807a7f
Label empty capture selection Pick source
webadderall Oct 4, 2026
4df0ce8
Render motion blur in paused timeline previews
webadderall Oct 4, 2026
0c25be3
Update Tahoe cursor pack to Golden Gate artwork
webadderall Oct 4, 2026
04de7ba
only picker overlays can finish a pick, and failed picks say why
Kevin-Liu-01 Oct 5, 2026
8726d15
Open capture picker only on explicit recording actions
webadderall Oct 6, 2026
ea9f027
Lock recording sources and add permission onboarding
webadderall Oct 6, 2026
3c9e539
Restore Linux source selection and fix portal cancellation
webadderall Oct 6, 2026
81a1134
Merge PR #1105 locally: resizable floating webcam preview
webadderall Oct 7, 2026
87bf66c
Complete account settings and authenticated permission onboarding
webadderall Oct 10, 2026
ca31dc9
Merge remote-tracking branch 'origin/main' into codex/recordly-user-s…
webadderall Oct 10, 2026
ea36f67
Stabilize required-field and animated HUD checks and update staging n…
webadderall Oct 10, 2026
3fb3b54
Add onboarding screenshots and capture-picker screenshot coverage
webadderall Oct 10, 2026
4ade3c7
Merge the author's latest capture-picker fixes from PR #1090
webadderall Oct 10, 2026
9b859c4
Document account settings and capture-picker previews
webadderall Oct 10, 2026
5905287
Fix onboarding, reduced motion, and seek-resume review findings
webadderall Oct 10, 2026
096b3ca
Reset capture selection on launch and fix area picker navigation
webadderall Oct 13, 2026
0d9eb97
Show the Home dashboard skeleton throughout startup loading
webadderall Oct 13, 2026
43e912e
Document Home startup loading preview
webadderall Oct 13, 2026
3227183
Make capture-picker bridge overrides independent of init order
webadderall Oct 13, 2026
4402259
Center the welcome heading and consolidate Recordly branding
webadderall Oct 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Platform notes:

- **macOS** uses native ScreenCaptureKit-based capture helpers.
- **Windows** uses a native Windows Graphics Capture (WGC) helper on supported builds, with native WASAPI audio support.
- **Linux** records through Electron capture APIs. Cursor hiding is not supported on Linux today.
- **Linux** records through Electron capture APIs. X11 uses a screen/window source list; Wayland opens the system picker when recording starts. Cursor hiding is not supported on Linux today.

---

Expand Down
30 changes: 19 additions & 11 deletions docs/authentication.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
# Recordly authentication setup

Recordly uses Supabase Auth for one shared session across email/password and Google. The desktop application uses PKCE and returns from the system browser through a loopback callback in development and `recordly://auth/callback` in production. X is shown in the sign-in dialog and requires its provider to be enabled in Supabase. SAML is intentionally hidden until configured.
Recordly requires a non-anonymous Supabase Auth session before onboarding can advance to permissions or recording can start. The sign-in dialog uses email magic links and whichever of Google, Microsoft, or GitHub OAuth are enabled in the configured project; there is no guest or local demo login. Sessions persist across app restarts. The desktop application uses PKCE and returns from the system browser through a loopback callback in development and `recordly://auth/callback` in production. The dialog reads `/auth/v1/settings` and shows only enabled OAuth providers. SAML is intentionally hidden until configured.

## Account and permission settings

**Settings → Account** is available in both the editor and Projects dashboard. Display-name changes update Supabase user metadata and refresh the account avatar everywhere. Email changes use Supabase's email confirmation flow and the same desktop PKCE callback as sign-in; the existing email stays active until confirmation completes. Pending email changes are shown after reopening Settings. Connected sign-in methods reflect the user's actual account identities. Sign out clears the local session, revokes refresh sessions on all devices, and brings back mandatory sign-in; local projects remain on disk.

**Settings → Permissions** shows live macOS Screen Recording and Accessibility statuses, refreshes when the app regains focus, and links to the onboarding permission setup. Microphone and camera permission requests remain tied to enabling those devices for recording. On other platforms, capture selection remains part of starting a recording.

## 1. Create the project

Expand All @@ -9,7 +15,7 @@ Recordly uses Supabase Auth for one shared session across email/password and Goo
3. Copy `.env.example` to `.env.local` and fill in both values. Never use the service-role key in the desktop application.
4. In **Authentication → URL configuration**, add `recordly://auth/callback` and `http://127.0.0.1:43821/auth/callback` to the redirect allow list.

Email/password works once email authentication is enabled and a user has been created. Password-reset emails use the same desktop callback.
Enable email authentication and allow new sign-ups for magic links. Request the email from Recordly, then open it on the same computer with Recordly running. Configure production SMTP as described in `services/supabase/EMAIL_SETUP.md`.

## 2. Google

Expand All @@ -18,11 +24,9 @@ Email/password works once email authentication is enabled and a user has been cr
3. Enable Google under **Supabase → Authentication → Sign In / Providers** and paste the Google client ID and secret.
4. Keep the requested scopes to `openid`, email, and profile unless Recordly genuinely needs more.

## 3. X
## 3. Microsoft and GitHub

1. Create an OAuth 2.0 app in the X Developer Dashboard and enable requesting the user's email.
2. Set its callback URL to `https://YOUR_PROJECT_REF.supabase.co/auth/v1/callback`.
3. Enable X/Twitter OAuth 2.0 in Supabase and paste the client ID and secret.
Enable Azure and GitHub under **Authentication → Sign In / Providers**, supply their client credentials, and use `https://YOUR_PROJECT_REF.supabase.co/auth/v1/callback` as the provider callback. Microsoft sign-in requests the `email` scope.

## 4. SAML SSO (currently hidden)

Expand All @@ -38,15 +42,19 @@ SAML is configured per customer workspace. Supabase's SAML support requires Pro
--domains customer.example
```

The Recordly modal extracts the domain from the entered email address and starts the matching SAML connection.
The auth helper supports extracting a work email domain for SAML; this option is currently hidden in the modal.

## 5. Verify locally

Restart `npm run dev` after creating `.env.local`. Open an editor and verify:

1. The account button opens **Sign into Recordly**.
2. Email/password creates a persistent Supabase session.
3. Google opens the system browser and returns to Recordly.
4. Clicking **Create link** while signed out opens this modal; after successful authentication it continues to the share dialog.
1. Without a session, **Welcome to Recordly** opens and cannot be skipped or dismissed.
2. Request an email magic link. Sending the email alone must not grant access. Opening the link exchanges its PKCE code for a persistent Supabase session and advances to **Permissions**.
3. OAuth opens the system browser and returns to Recordly through the same callback.
4. On Permissions, **Start recording** opens the recording controls after required system permissions are granted.
5. Restart the app to verify session restoration. Sign out from the account dialog and verify that mandatory sign-in returns.
6. **Show onboarding** opens Permissions directly for signed-in users. The recording HUD also checks for a Supabase session before preparing a recording.

Automated UI tests use an isolated Supabase URL and mocked Auth HTTP responses; no production auth bypass is enabled in development or tests.

Add the same `SUPABASE_URL` and `SUPABASE_PUBLISHABLE_KEY` values to the share Worker's secrets or variables. Set `OWNER_USER_ID` to the owner’s Supabase user ID. The Worker validates the access token with Supabase and requires that owner identity before accepting API requests. `API_SECRET` is server-side only and remains available for library administration and explicitly enabled local integration tests; it is never entered into or exposed by the desktop app.
Binary file added docs/pr/user-system/account-settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr/user-system/capture-picker.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr/user-system/home-loading.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr/user-system/permissions.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr/user-system/sign-in.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions electron/editorStartupView.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { expect, it } from "vitest";
import { resolveEditorStartupView } from "./editorStartupView";

it("shows Home for an empty app session", () => {
expect(resolveEditorStartupView({})).toBe("home");
});
for (const source of [
{ projectPath: "/projects/demo.recordly" },
{ videoPath: "/recordings/demo.mp4" },
{ recordingVideoPath: "/recordings/fresh.mp4" },
{ devInput: "/fixtures/demo.mp4" },
{ smokeExport: true },
]) {
it(`uses the editor skeleton for ${Object.keys(source)[0]}`, () => {
expect(resolveEditorStartupView(source)).toBe("editor");
});
}
18 changes: 18 additions & 0 deletions electron/editorStartupView.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/** Resolve the destination before renderer code or project hydration runs. */
export function resolveEditorStartupView({
projectPath,
videoPath,
recordingVideoPath,
devInput,
smokeExport,
}: {
projectPath?: string | null;
videoPath?: string | null;
recordingVideoPath?: string | null;
devInput?: string;
smokeExport?: boolean;
}): "home" | "editor" {
return projectPath || videoPath || recordingVideoPath || devInput || smokeExport
? "editor"
: "home";
}
52 changes: 51 additions & 1 deletion electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,25 @@ interface Window {
openSourceSelector: () => Promise<void>;
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource>;
showSourceHighlight: (source: ProcessedDesktopSource) => Promise<{ success: boolean }>;
/** Opens the capture picker on every display and resolves when it closes. */
pickCaptureTarget: () => Promise<{
success: boolean;
canceled?: boolean;
record?: boolean;
source?: ProcessedDesktopSource;
message?: string;
}>;
completeCapturePick: (pick: CapturePick | null) => Promise<void>;
getCapturePickerContext: (displayId: number) => Promise<{
displayBounds: CaptureArea | null;
lastArea: CaptureArea | null;
allowArea: boolean;
/** On-screen windows on this display, front to back. */
windows: CapturePickerWindow[];
cursor: { x: number; y: number } | null;
}>;
/** Reveals the picker window once it has painted its first state. */
capturePickerReady: () => void;
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
onSelectedSourceChanged: (
callback: (source: ProcessedDesktopSource | null) => void,
Expand Down Expand Up @@ -841,6 +860,8 @@ interface Window {
path?: string;
error?: string;
}>;
showRecordingPermissions: () => Promise<void>;
onRecordingPermissionsRequested: (callback: () => void) => () => void;
showRecordingHud: () => Promise<void>;
createProjectFile: (
data: unknown,
Expand Down Expand Up @@ -994,6 +1015,7 @@ interface Window {
}>;
getShortcuts: () => Promise<Record<string, unknown> | null>;
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>;
getEditorStartupView: () => "home" | "editor";
getAppSetting: (key: string) => unknown;
setAppSetting: (key: string, value: unknown) => boolean;
setHasUnsavedChanges: (hasChanges: boolean) => void;
Expand Down Expand Up @@ -1042,16 +1064,44 @@ interface Window {
};
}

/** A rectangle in global display points (top-left origin). */
interface CaptureArea {
x: number;
y: number;
width: number;
height: number;
}

/** A window the capture picker can highlight, framed in global display points. */
interface CapturePickerWindow extends CaptureArea {
/** The window's source id, `window:<windowId>:0`. */
id: string;
appName: string;
title: string;
display_id?: string;
}

/**
* What the capture picker reports when the user confirms: an area they drew, a
* window they clicked, or a whole screen. `record` starts recording right away.
*/
type CapturePick =
| (CaptureArea & { kind: "area"; displayId: number; record: boolean })
| { kind: "window"; windowId: string; displayId: number; record: boolean }
| { kind: "screen"; displayId: number; record: boolean };

interface ProcessedDesktopSource {
id: string;
name: string;
display_id: string;
thumbnail: string | null;
appIcon: string | null;
originalName?: string;
sourceType?: "screen" | "window";
sourceType?: "screen" | "window" | "area";
appName?: string;
windowTitle?: string;
/** For area sources, the recorded part of the display. */
area?: CaptureArea;
}

interface CursorTelemetryPoint {
Expand Down
66 changes: 66 additions & 0 deletions electron/ipc/capturePickerSources.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
exec: vi.fn(),
sources: vi.fn(),
mac: vi.fn(),
dip: vi.fn(),
}));
vi.mock("electron", () => ({ systemPreferences: { getMediaAccessStatus: () => "granted" } }));
vi.mock("node:util", () => ({ promisify: () => mocks.exec }));
vi.mock("./cursor/bounds", () => ({
getNativeMacWindowsFrontToBack: mocks.mac,
}));
vi.mock("./register/sources", () => ({ getDesktopSources: mocks.sources }));
vi.mock("./utils", () => ({
parseWindowId: (id?: string) => Number(id?.split(":")[1]),
getScreen: () => ({ screenToDipRect: mocks.dip }),
}));
import { listCapturePickerWindows } from "./capturePickerSources";
const platform = process.platform;
afterEach(() => Object.defineProperty(process, "platform", { value: platform }));
beforeEach(() => {
vi.resetAllMocks();
mocks.sources.mockResolvedValue([
{ id: "window:101:0", name: "Front" },
{ id: "window:102:0", name: "Back" },
]);
});
describe("capture picker source adapters", () => {
it("keeps Linux window selection on its source list without X11 helper probes", async () => {
Object.defineProperty(process, "platform", { value: "linux" });
await expect(listCapturePickerWindows()).rejects.toThrow("Use the source list on Linux");
expect(mocks.exec).not.toHaveBeenCalled();
});
it("matches Windows handles to real capture IDs and converts physical bounds to DIP", async () => {
Object.defineProperty(process, "platform", { value: "win32" });
mocks.exec.mockResolvedValue({
stdout: JSON.stringify([
{ id: 999, x: 0, y: 0, width: 20, height: 20 },
{ id: 101, x: -1600, y: 0, width: 800, height: 600 },
]),
});
mocks.dip.mockReturnValue({ x: -800, y: 0, width: 400, height: 300 });
expect(await listCapturePickerWindows()).toEqual([
{
id: "window:101:0",
appName: "",
title: "Front",
display_id: undefined,
x: -800,
y: 0,
width: 400,
height: 300,
},
]);
expect(mocks.dip).toHaveBeenCalledWith(null, expect.objectContaining({ x: -1600 }));
});
it("excludes Recordly's own macOS windows and invalid geometry", async () => {
Object.defineProperty(process, "platform", { value: "darwin" });
mocks.mac.mockResolvedValue([
{ id: "window:1:0", ownerPid: process.pid, x: 0, y: 0, width: 10, height: 10 },
{ id: "window:2:0", ownerPid: 99, name: "Other", x: 0, y: 0, width: 400, height: 300 },
{ id: "window:3:0", width: Number.NaN },
]);
expect((await listCapturePickerWindows()).map((entry) => entry.id)).toEqual(["window:2:0"]);
});
});
106 changes: 106 additions & 0 deletions electron/ipc/capturePickerSources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { systemPreferences } from "electron";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { getNativeMacWindowsFrontToBack } from "./cursor/bounds";
import type { CapturePickerWindow, SelectedSource } from "./types";
import { getScreen, parseWindowId } from "./utils";
import { getDesktopSources } from "./register/sources";

const exec = promisify(execFile);

// GetTopWindow/GetWindow walks front to back. Restrict the result to Electron's
// capturable sources rather than inventing source IDs or changing capture routes.
const WINDOWS_SCRIPT = `Add-Type -TypeDefinition @"
using System; using System.Runtime.InteropServices; using System.Collections.Generic;
public static class RecordlyPickerWindows {
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
[DllImport("user32.dll")] static extern IntPtr GetTopWindow(IntPtr h);
[DllImport("user32.dll")] static extern IntPtr GetWindow(IntPtr h, uint command);
[DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr h);
[DllImport("user32.dll")] static extern bool IsIconic(IntPtr h);
[DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr h, out RECT rect);
[DllImport("user32.dll")] static extern IntPtr SetThreadDpiAwarenessContext(IntPtr context);
[DllImport("dwmapi.dll")] static extern int DwmGetWindowAttribute(IntPtr h, int attribute, out RECT rect, int size);
public class Entry { public long id; public int x, y, width, height; }
public static Entry[] List() {
SetThreadDpiAwarenessContext(new IntPtr(-4));
var list = new List<Entry>(); var seen = new HashSet<long>();
for (var h = GetTopWindow(IntPtr.Zero); h != IntPtr.Zero && seen.Add(h.ToInt64()); h = GetWindow(h, 2)) {
if (!IsWindowVisible(h) || IsIconic(h)) continue;
RECT rect;
if (DwmGetWindowAttribute(h, 9, out rect, Marshal.SizeOf(typeof(RECT))) != 0 && !GetWindowRect(h, out rect)) continue;
if (rect.Right <= rect.Left || rect.Bottom <= rect.Top) continue;
list.Add(new Entry { id = h.ToInt64(), x = rect.Left, y = rect.Top, width = rect.Right - rect.Left, height = rect.Bottom - rect.Top });
}
return list.ToArray();
}
}
"@
ConvertTo-Json -InputObject @([RecordlyPickerWindows]::List()) -Compress`;

export async function listCapturePickerWindows(): Promise<CapturePickerWindow[]> {
if (process.platform === "darwin") {
if (systemPreferences.getMediaAccessStatus("screen") !== "granted")
throw new Error("Allow screen recording in System Settings before picking a window.");
const entries = await getNativeMacWindowsFrontToBack({ strict: true });
return entries
.filter(
(entry) =>
entry.ownerPid !== process.pid &&
[entry.x, entry.y, entry.width, entry.height].every(Number.isFinite) &&
(entry.width ?? 0) > 0 &&
(entry.height ?? 0) > 0,
)
.map((entry) => ({
id: entry.id,
appName: entry.appName ?? "",
title: entry.windowTitle ?? entry.name,
display_id: entry.display_id,
x: entry.x!,
y: entry.y!,
width: entry.width!,
height: entry.height!,
}));
}
if (process.platform !== "win32") throw new Error("Use the source list on Linux.");
const sources = await getDesktopSources(
{
types: ["window"],
thumbnailSize: { width: 0, height: 0 },
fetchWindowIcons: false,
},
{ strict: true },
);
const capturable = sources.filter(
(source): source is SelectedSource & { id: string } =>
typeof source.id === "string" && source.id.startsWith("window:"),
);
const byId = new Map(capturable.map((source) => [parseWindowId(source.id), source]));
const { stdout } = await exec(
"powershell.exe",
["-NoProfile", "-NonInteractive", "-Command", WINDOWS_SCRIPT],
{ timeout: 10000, maxBuffer: 4 * 1024 * 1024 },
);
const entries: unknown = JSON.parse(stdout);
if (!Array.isArray(entries)) throw new Error("Unable to read window positions.");
return entries.flatMap((entry) => {
const source = byId.get(entry.id);
if (
!source ||
![entry.x, entry.y, entry.width, entry.height].every(Number.isFinite) ||
entry.width <= 0 ||
entry.height <= 0
)
return [];
const bounds = getScreen().screenToDipRect(null, entry);
return [
{
...bounds,
id: source.id,
appName: "",
title: source.name,
display_id: source.display_id,
},
];
});
}
Loading
Loading