Skip to content

Commit f8262f8

Browse files
committed
fix(recording): record the window Linux users actually pick
Choosing a window on Wayland produced a recording of the whole screen. Two independent defects, both proven on a real GNOME 46 session. The portal restore token pinned the first grant forever. A token is bound to the source it was minted for, so once any monitor had been approved the portal restored that monitor on every later run and stopped raising its picker — and SelectSources has no parameter naming a source, so the app had no way to ask for anything else. GNOME's permission store held ours as source_type=1 (MONITOR, CMN:0x1468), created 1.7s into one full-screen recording and replayed 93ms into a later one. The token is gone, and PersistMode is DoNot so we stop littering that store. SPA_META_VideoCrop was never requested. mutter pins a window stream to its monitor's size — "we cannot set the stream size to the exact size of the window, because windows can be resized, whereas streams cannot" — and reports the window's live rectangle in that meta, but only writes it if the consumer asked. We never asked, so a window arrived as a monitor-sized buffer: measured 1920x982 of content at (0,0) inside a 1920x1080 frame, 98px of black below. The declaration and per-frame read follow OBS and WebRTC; the validation follows WebRTC's stricter posture because the pointer goes straight to swscale. Neither reference handles a mid-recording resize — both let the frame size vary per frame, which an MP4 track cannot. The crop is latched at encoder open, the live origin is followed but clamped inside the buffer, and the first divergence is reported once as `crop-changed`. The encoder is opened from the first frame carrying a usable crop rather than the first frame at all: mutter's rectangle intersection reports success on an empty result and records a frame synchronously from enable(), so committing to frame zero could pin a window recording at monitor size again. Since the portal owns the choice, the in-app picker is gone on Linux: it could not steer the capture and only raised a second portal dialog whose grant was discarded. Both entry points — the HUD and the editor's Rec stage — now ask one shared hook, and the tray names what the portal granted instead of echoing a selection the capture never heard of. The picker also now comes before the countdown. The helper negotiates the portal, reports `source-selected`, and waits for `record` on stdin; the stream is not connected until then, so nothing is captured and WirePlumber cannot suspend an idle node out from under us. Preparing is optional and best-effort — start still negotiates on its own — so every other platform and any future caller is unaffected.
1 parent c186236 commit f8262f8

49 files changed

Lines changed: 1840 additions & 293 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

electron/electron-env.d.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,23 @@ interface Window {
192192
reason?: "unsupported-platform" | "missing-helper" | string;
193193
error?: string;
194194
}>;
195+
/**
196+
* Raises the compositor's picker and holds the grant until the recording
197+
* actually starts, so a countdown can run AFTER the user has chosen.
198+
*
199+
* Best-effort: a `success: false` means "start normally", never "fail".
200+
*/
201+
prepareNativeLinuxRecording: (
202+
request: import("../src/lib/nativeLinuxRecording").NativeLinuxRecordingRequest,
203+
) => Promise<{
204+
success: boolean;
205+
recordingId?: number;
206+
sourceKind?: "monitor" | "window" | "virtual" | null;
207+
reason?: string;
208+
error?: string;
209+
}>;
210+
/** Drops a prepared session when the countdown was abandoned. */
211+
cancelNativeLinuxPrepare: () => Promise<{ success: boolean }>;
195212
startNativeLinuxRecording: (
196213
request: import("../src/lib/nativeLinuxRecording").NativeLinuxRecordingRequest,
197214
) => Promise<import("../src/lib/nativeLinuxRecording").NativeLinuxRecordingStartResult>;

electron/ipc/handlers.ts

Lines changed: 194 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,10 @@ import {
6262
readCursorTelemetryFile as readCursorTelemetryFileFrom,
6363
} from "../media/cursorSidecar";
6464
import { findMediaLinksByFingerprint, registerMediaLinks } from "../media/mediaLinksRegistry";
65-
import { LinuxNativeCaptureSession } from "../native-bridge/capture/linuxNativeCaptureSession";
65+
import {
66+
type LinuxCaptureSourceKind,
67+
LinuxNativeCaptureSession,
68+
} from "../native-bridge/capture/linuxNativeCaptureSession";
6669
import { createCursorRecordingSession } from "../native-bridge/cursor/recording/factory";
6770
import { requestMacCursorAccessibilityAccess } from "../native-bridge/cursor/recording/macNativeCursorRecordingSession";
6871
import { findPipeWireCursorHelperPath } from "../native-bridge/cursor/recording/pipeWireCursorRecordingSession";
@@ -539,48 +542,83 @@ let activeMacCaptureBounds: Rectangle | null = null;
539542
let linuxNativeCaptureSession: LinuxNativeCaptureSession | null = null;
540543
let linuxNativeCaptureRecordingId: number | null = null;
541544
let linuxNativeCaptureCursorMode: CursorCaptureMode = "editable-overlay";
545+
/** What the portal granted for the running capture, for the tray's label. */
546+
let linuxNativeCaptureSourceLabel: string | null = null;
542547
/**
543-
* The portal's restore token, kept between recordings.
548+
* A portal session negotiated ahead of the countdown, waiting to be armed.
544549
*
545-
* Without it the compositor raises its source picker on EVERY recording, which
546-
* on Wayland is the single most intrusive thing about capturing at all. The
547-
* portal issues the token only after a successful session and honours it until
548-
* the user revokes it in system settings, so it is safe to persist and useless
549-
* to anyone else.
550+
* Held here rather than in the renderer because the helper is a child process of
551+
* THIS process: a renderer that reloads, or a countdown abandoned without a
552+
* cancel, would otherwise leak a live ScreenCast session — the compositor's
553+
* "screen is being shared" indicator with nothing recording behind it.
550554
*/
551-
const LINUX_RESTORE_TOKEN_FILE = "linux-capture-restore-token.json";
555+
let preparedLinuxCapture: { session: LinuxNativeCaptureSession; outputPath: string } | null = null;
552556

553-
async function readLinuxRestoreToken(): Promise<string | undefined> {
554-
try {
555-
const raw = await fs.readFile(
556-
path.join(app.getPath("userData"), LINUX_RESTORE_TOKEN_FILE),
557-
"utf-8",
558-
);
559-
const parsed = JSON.parse(raw) as { restoreToken?: unknown };
560-
return typeof parsed.restoreToken === "string" && parsed.restoreToken
561-
? parsed.restoreToken
562-
: undefined;
563-
} catch {
564-
// Absent or unreadable: the picker appears, which is the old behaviour
565-
// and not worth failing a recording over.
566-
return undefined;
557+
/**
558+
* Claims the prepared session when it matches the recording about to start.
559+
*
560+
* A mismatch means the prepare was for a recording that never happened, so it is
561+
* discarded rather than reused: arming it would record against the wrong output
562+
* path, and leaving it would strand a portal session.
563+
*/
564+
function takePreparedLinuxSession(outputPath: string): LinuxNativeCaptureSession | null {
565+
const prepared = preparedLinuxCapture;
566+
if (!prepared) {
567+
return null;
568+
}
569+
preparedLinuxCapture = null;
570+
if (prepared.outputPath === outputPath) {
571+
return prepared.session;
567572
}
573+
console.warn("[native-linux] discarding a prepared session for a different recording");
574+
prepared.session.discard();
575+
return null;
568576
}
569577

570-
async function writeLinuxRestoreToken(restoreToken?: string) {
571-
if (!restoreToken) {
578+
/** Tears down a prepared-but-unarmed session, e.g. an abandoned countdown. */
579+
function discardPreparedLinuxCapture(reason: string) {
580+
if (!preparedLinuxCapture) {
572581
return;
573582
}
574-
try {
575-
await fs.writeFile(
576-
path.join(app.getPath("userData"), LINUX_RESTORE_TOKEN_FILE),
577-
JSON.stringify({ restoreToken }, null, 2),
578-
"utf-8",
579-
);
580-
} catch (error) {
581-
console.warn("Could not persist the Linux portal restore token:", error);
583+
console.info(`[native-linux] discarding the prepared capture: ${reason}`);
584+
preparedLinuxCapture.session.discard();
585+
preparedLinuxCapture = null;
586+
}
587+
588+
/**
589+
* Names what the portal handed over, for the tray tooltip.
590+
*
591+
* There is no window title to show: the ScreenCast portal reports a kind and a
592+
* PipeWire node id, never a name. Reporting the kind is the most that can be
593+
* said honestly, and an unknown kind stays unknown — calling it "Screen" would
594+
* be the same guess that put a window's name on a full-screen recording.
595+
*/
596+
function linuxSourceLabel(kind?: LinuxCaptureSourceKind): string {
597+
switch (kind) {
598+
case "window":
599+
return "Window";
600+
case "monitor":
601+
return "Screen";
602+
case "virtual":
603+
return "Virtual display";
604+
default:
605+
return "Screen recording";
582606
}
583607
}
608+
/**
609+
* NO PORTAL RESTORE TOKEN IS KEPT, AND THAT IS DELIBERATE.
610+
*
611+
* A token used to be persisted here so the compositor's picker would not appear
612+
* on every recording. It is gone because it made "record this window" record the
613+
* whole screen instead. A restore token is bound to the source it was minted
614+
* for, so once any monitor had been approved the portal restored that monitor on
615+
* every later run and stopped raising the picker at all — and `SelectSources`
616+
* has no parameter naming a source, so the app could not ask for anything else.
617+
* On Wayland the picker IS the source chooser; suppressing it left the user with
618+
* no way to change what they were recording.
619+
*
620+
* Answering the picker each time is the cost of being able to choose at all.
621+
*/
584622

585623
// ponytail: the sidecar readers used to live here, ~150 lines of parsing wedged
586624
// between the capture state machine and the asset-path helpers, reachable only
@@ -1653,6 +1691,19 @@ export function registerIpcHandlers(
16531691
});
16541692

16551693
ipcMain.handle("open-source-selector", async () => {
1694+
// Nothing to open on Linux WHEN THE NATIVE HELPER IS THERE. The selector's
1695+
// own `desktopCapturer.getSources()` raises a portal dialog — a SECOND
1696+
// one, for a session that is thrown away — and whatever it returns cannot
1697+
// reach the helper, because `SelectSources` has no parameter naming a
1698+
// source. Refusing keeps that dialog from appearing at all.
1699+
//
1700+
// Without the helper the recorder falls back to Chromium's capture, which
1701+
// DOES consume a source id, so the picker has to stay reachable there or
1702+
// that path could never start.
1703+
if (process.platform === "linux" && findPipeWireCursorHelperPath()) {
1704+
return { opened: false, reason: "portal-owns-selection" };
1705+
}
1706+
16561707
const access = await requestScreenAccess();
16571708
if (!access.granted) {
16581709
if (process.platform === "darwin" && access.status !== "not-determined") {
@@ -1802,20 +1853,29 @@ export function registerIpcHandlers(
18021853
: { success: true, available: false, reason: "missing-helper" };
18031854
});
18041855

1856+
/**
1857+
* Raises the compositor's picker and stops there, holding the grant.
1858+
*
1859+
* Best-effort by contract: every failure returns `success: false` rather than
1860+
* throwing, because the caller's fallback is simply to start normally and get
1861+
* the picker after its countdown — the behaviour that shipped before this
1862+
* existed. Nothing downstream may depend on a prepare having succeeded.
1863+
*/
18051864
ipcMain.handle(
1806-
"start-native-linux-recording",
1865+
"prepare-native-linux-recording",
18071866
async (_, request: NativeLinuxRecordingRequest) => {
1867+
if (process.platform !== "linux") {
1868+
return { success: false, reason: "unsupported-platform" };
1869+
}
1870+
if (linuxNativeCaptureSession) {
1871+
return { success: false, reason: "already-recording" };
1872+
}
1873+
discardPreparedLinuxCapture("superseded by a new prepare");
1874+
18081875
try {
1809-
if (process.platform !== "linux") {
1810-
return { success: false, error: "Native Linux capture requires Linux." };
1811-
}
1812-
if (linuxNativeCaptureSession) {
1813-
return { success: false, error: "Native Linux capture is already running." };
1814-
}
18151876
if (!findPipeWireCursorHelperPath()) {
1816-
return { success: false, error: "Native Linux capture helper is not available." };
1877+
return { success: false, reason: "missing-helper" };
18171878
}
1818-
18191879
const recordingId =
18201880
typeof request?.recordingId === "number" && Number.isFinite(request.recordingId)
18211881
? request.recordingId
@@ -1825,7 +1885,6 @@ export function registerIpcHandlers(
18251885
normalizeCursorCaptureMode(request?.cursor?.mode) ?? "editable-overlay";
18261886

18271887
await fs.mkdir(RECORDINGS_DIR, { recursive: true });
1828-
const restoreToken = await readLinuxRestoreToken();
18291888

18301889
const session = new LinuxNativeCaptureSession({
18311890
outputPath,
@@ -1843,28 +1902,112 @@ export function registerIpcHandlers(
18431902
},
18441903
},
18451904
maxCursorSamples: MAX_CURSOR_SAMPLES,
1846-
...(restoreToken ? { restoreToken } : {}),
1905+
deferStart: true,
18471906
});
18481907

1908+
await session.start();
1909+
// The picker is up now. No timeout: a human is reading a dialog.
1910+
await session.waitUntilSourceSelected();
1911+
1912+
preparedLinuxCapture = { session, outputPath };
1913+
return {
1914+
success: true,
1915+
recordingId,
1916+
sourceKind: session.grantedSourceKind ?? null,
1917+
};
1918+
} catch (error) {
1919+
console.warn("Could not prepare the native Linux capture:", error);
1920+
discardPreparedLinuxCapture("prepare failed");
1921+
return { success: false, error: String(error) };
1922+
}
1923+
},
1924+
);
1925+
1926+
/** Drops a prepared session, e.g. when the countdown was cancelled. */
1927+
ipcMain.handle("cancel-native-linux-prepare", async () => {
1928+
discardPreparedLinuxCapture("cancelled by the renderer");
1929+
return { success: true };
1930+
});
1931+
1932+
ipcMain.handle(
1933+
"start-native-linux-recording",
1934+
async (_, request: NativeLinuxRecordingRequest) => {
1935+
try {
1936+
if (process.platform !== "linux") {
1937+
return { success: false, error: "Native Linux capture requires Linux." };
1938+
}
1939+
if (linuxNativeCaptureSession) {
1940+
return { success: false, error: "Native Linux capture is already running." };
1941+
}
1942+
if (!findPipeWireCursorHelperPath()) {
1943+
return { success: false, error: "Native Linux capture helper is not available." };
1944+
}
1945+
1946+
const recordingId =
1947+
typeof request?.recordingId === "number" && Number.isFinite(request.recordingId)
1948+
? request.recordingId
1949+
: Date.now();
1950+
const outputPath = path.join(RECORDINGS_DIR, `${RECORDING_FILE_PREFIX}${recordingId}.mp4`);
1951+
const cursorCaptureMode =
1952+
normalizeCursorCaptureMode(request?.cursor?.mode) ?? "editable-overlay";
1953+
1954+
await fs.mkdir(RECORDINGS_DIR, { recursive: true });
1955+
1956+
// A session prepared before the countdown, if there was one. Taking
1957+
// it here rather than requiring it is what keeps every caller
1958+
// working: a path that never prepared still gets a full start
1959+
// below, just with the picker after its countdown instead of
1960+
// before. Nothing has to know which path it is on.
1961+
const prepared = takePreparedLinuxSession(outputPath);
1962+
const session =
1963+
prepared ??
1964+
new LinuxNativeCaptureSession({
1965+
outputPath,
1966+
cursorMode: portalCursorMode(cursorCaptureMode),
1967+
fps: request.video.fps,
1968+
...(request.video.bitrate ? { bitrate: request.video.bitrate } : {}),
1969+
audio: {
1970+
system: { enabled: request.audio.system.enabled },
1971+
microphone: {
1972+
enabled: request.audio.microphone.enabled,
1973+
...(request.audio.microphone.deviceName
1974+
? { deviceName: request.audio.microphone.deviceName }
1975+
: {}),
1976+
gain: request.audio.microphone.gain,
1977+
},
1978+
},
1979+
maxCursorSamples: MAX_CURSOR_SAMPLES,
1980+
});
1981+
18491982
console.info("[native-linux] starting capture", {
18501983
outputPath,
1984+
prepared: Boolean(prepared),
18511985
cursor: { mode: cursorCaptureMode },
18521986
audio: request.audio,
18531987
video: request.video,
18541988
});
18551989

1856-
await session.start();
1857-
// Blocks until the user answers the portal picker, which has no
1858-
// upper bound — the countdown UI is already showing by now.
1990+
if (!prepared) {
1991+
await session.start();
1992+
// Blocks until the user answers the portal picker, which has no
1993+
// upper bound. On this path the countdown has already run.
1994+
await session.waitUntilSourceSelected();
1995+
}
1996+
// Idempotent, and a no-op for a session that was not deferred.
1997+
session.arm();
18591998
await session.waitUntilCapturing();
18601999

18612000
linuxNativeCaptureSession = session;
18622001
linuxNativeCaptureRecordingId = recordingId;
18632002
linuxNativeCaptureCursorMode = cursorCaptureMode;
18642003

1865-
const source = selectedSource || { name: "Screen" };
2004+
// The portal's answer, not an in-app selection — on Wayland there
2005+
// is none to have. This used to read `selectedSource || { name:
2006+
// "Screen" }`, so the tray confidently displayed the name of a
2007+
// window the capture had never been told about.
2008+
linuxNativeCaptureSourceLabel = linuxSourceLabel(session.grantedSourceKind);
18662009
if (onRecordingStateChange) {
1867-
onRecordingStateChange(true, source.name);
2010+
onRecordingStateChange(true, linuxNativeCaptureSourceLabel);
18682011
}
18692012

18702013
return { success: true, recordingId, path: outputPath };
@@ -1915,7 +2058,6 @@ export function registerIpcHandlers(
19152058
}
19162059

19172060
const result = await session.stop();
1918-
await writeLinuxRestoreToken(result.restoreToken);
19192061

19202062
// The helper collects cursor samples itself, from the same portal
19212063
// session that produced the pixels, so there is no separate sampler
@@ -1965,9 +2107,10 @@ export function registerIpcHandlers(
19652107
linuxNativeCaptureSession = null;
19662108
linuxNativeCaptureRecordingId = null;
19672109
linuxNativeCaptureCursorMode = "editable-overlay";
1968-
const source = selectedSource || { name: "Screen" };
2110+
const stoppedLabel = linuxNativeCaptureSourceLabel ?? linuxSourceLabel();
2111+
linuxNativeCaptureSourceLabel = null;
19692112
if (onRecordingStateChange) {
1970-
onRecordingStateChange(false, source.name);
2113+
onRecordingStateChange(false, stoppedLabel);
19712114
}
19722115
}
19732116
});

0 commit comments

Comments
 (0)