diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 8c4920fd3..e5381999b 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -192,6 +192,23 @@ interface Window { reason?: "unsupported-platform" | "missing-helper" | string; error?: string; }>; + /** + * Raises the compositor's picker and holds the grant until the recording + * actually starts, so a countdown can run AFTER the user has chosen. + * + * Best-effort: a `success: false` means "start normally", never "fail". + */ + prepareNativeLinuxRecording: ( + request: import("../src/lib/nativeLinuxRecording").NativeLinuxRecordingRequest, + ) => Promise<{ + success: boolean; + recordingId?: number; + sourceKind?: "monitor" | "window" | "virtual" | null; + reason?: string; + error?: string; + }>; + /** Drops a prepared session when the countdown was abandoned. */ + cancelNativeLinuxPrepare: () => Promise<{ success: boolean }>; startNativeLinuxRecording: ( request: import("../src/lib/nativeLinuxRecording").NativeLinuxRecordingRequest, ) => Promise; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 0000ec0e7..715dc8515 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -62,7 +62,10 @@ import { readCursorTelemetryFile as readCursorTelemetryFileFrom, } from "../media/cursorSidecar"; import { findMediaLinksByFingerprint, registerMediaLinks } from "../media/mediaLinksRegistry"; -import { LinuxNativeCaptureSession } from "../native-bridge/capture/linuxNativeCaptureSession"; +import { + type LinuxCaptureSourceKind, + LinuxNativeCaptureSession, +} from "../native-bridge/capture/linuxNativeCaptureSession"; import { createCursorRecordingSession } from "../native-bridge/cursor/recording/factory"; import { requestMacCursorAccessibilityAccess } from "../native-bridge/cursor/recording/macNativeCursorRecordingSession"; import { findPipeWireCursorHelperPath } from "../native-bridge/cursor/recording/pipeWireCursorRecordingSession"; @@ -539,48 +542,129 @@ let activeMacCaptureBounds: Rectangle | null = null; let linuxNativeCaptureSession: LinuxNativeCaptureSession | null = null; let linuxNativeCaptureRecordingId: number | null = null; let linuxNativeCaptureCursorMode: CursorCaptureMode = "editable-overlay"; +/** What the portal granted for the running capture, for the tray's label. */ +let linuxNativeCaptureSourceLabel: string | null = null; +/** + * A portal session negotiated ahead of the countdown, waiting to be armed. + * + * Held here rather than in the renderer because the helper is a child process of + * THIS process: a renderer that reloads, or a countdown abandoned without a + * cancel, would otherwise leak a live ScreenCast session — the compositor's + * "screen is being shared" indicator with nothing recording behind it. + */ +let preparedLinuxCapture: { + session: LinuxNativeCaptureSession; + outputPath: string; + /** What the helper was actually spawned with. See [`captureSettingsOf`]. */ + request: NativeLinuxRecordingRequest; +} | null = null; /** - * The portal's restore token, kept between recordings. + * Identifies the prepare that is still waiting on the picker. * - * Without it the compositor raises its source picker on EVERY recording, which - * on Wayland is the single most intrusive thing about capturing at all. The - * portal issues the token only after a successful session and honours it until - * the user revokes it in system settings, so it is safe to persist and useless - * to anyone else. + * The slot above is only filled AFTER an await with no upper bound — a human is + * reading a dialog. For that whole window it is null, so without this token a + * cancel would find nothing to cancel and a second prepare would find nothing to + * supersede: the first session would then assign itself afterwards and stay + * alive, holding a ScreenCast grant and the compositor's sharing indicator with + * nothing recording behind it. */ -const LINUX_RESTORE_TOKEN_FILE = "linux-capture-restore-token.json"; +let preparingLinuxCaptureToken: symbol | null = null; -async function readLinuxRestoreToken(): Promise { - try { - const raw = await fs.readFile( - path.join(app.getPath("userData"), LINUX_RESTORE_TOKEN_FILE), - "utf-8", - ); - const parsed = JSON.parse(raw) as { restoreToken?: unknown }; - return typeof parsed.restoreToken === "string" && parsed.restoreToken - ? parsed.restoreToken - : undefined; - } catch { - // Absent or unreadable: the picker appears, which is the old behaviour - // and not worth failing a recording over. - return undefined; +/** + * Claims the prepared session when it matches the recording about to start. + * + * A mismatch means the prepare was for a recording that never happened, so it is + * discarded rather than reused: arming it would record against the wrong output + * path, and leaving it would strand a portal session. + */ +function takePreparedLinuxSession( + outputPath: string, + request: NativeLinuxRecordingRequest, +): LinuxNativeCaptureSession | null { + const prepared = preparedLinuxCapture; + if (!prepared) { + return null; + } + preparedLinuxCapture = null; + if (prepared.outputPath !== outputPath) { + console.warn("[native-linux] discarding a prepared session for a different recording"); + prepared.session.discard(); + return null; } + // EVERY CAPTURE SETTING IS FIXED AT SPAWN. `arm()` only writes `record`, so a + // prepared helper is already running with the audio and cursor settings it + // was created with — and the HUD does not lock its controls during the + // countdown, so the user really can change them in between. Reusing the + // session would record one thing while the app believed another, including + // the cursor mode that decides whether the editor draws its own pointer. + if (captureSettingsOf(prepared.request) !== captureSettingsOf(request)) { + console.info("[native-linux] settings changed during the countdown; renegotiating"); + prepared.session.discard(); + return null; + } + return prepared.session; +} + +/** The request fields baked into the helper's spawn arguments, canonicalised. */ +function captureSettingsOf(request: NativeLinuxRecordingRequest): string { + return JSON.stringify({ + fps: request.video?.fps ?? null, + bitrate: request.video?.bitrate ?? null, + system: request.audio?.system?.enabled ?? false, + microphone: request.audio?.microphone?.enabled ?? false, + deviceName: request.audio?.microphone?.deviceName ?? null, + gain: request.audio?.microphone?.gain ?? null, + cursor: normalizeCursorCaptureMode(request?.cursor?.mode) ?? "editable-overlay", + }); } -async function writeLinuxRestoreToken(restoreToken?: string) { - if (!restoreToken) { +/** Tears down a prepared-but-unarmed session, e.g. an abandoned countdown. */ +function discardPreparedLinuxCapture(reason: string) { + // Invalidate any negotiation still in flight, so the session it is about to + // produce is discarded on arrival instead of stranded. + preparingLinuxCaptureToken = null; + if (!preparedLinuxCapture) { return; } - try { - await fs.writeFile( - path.join(app.getPath("userData"), LINUX_RESTORE_TOKEN_FILE), - JSON.stringify({ restoreToken }, null, 2), - "utf-8", - ); - } catch (error) { - console.warn("Could not persist the Linux portal restore token:", error); + console.info(`[native-linux] discarding the prepared capture: ${reason}`); + preparedLinuxCapture.session.discard(); + preparedLinuxCapture = null; +} + +/** + * Names what the portal handed over, for the tray tooltip. + * + * There is no window title to show: the ScreenCast portal reports a kind and a + * PipeWire node id, never a name. Reporting the kind is the most that can be + * said honestly, and an unknown kind stays unknown — calling it "Screen" would + * be the same guess that put a window's name on a full-screen recording. + */ +function linuxSourceLabel(kind?: LinuxCaptureSourceKind): string { + switch (kind) { + case "window": + return mainT("common", "recordingSource.window"); + case "monitor": + return mainT("common", "recordingSource.screen"); + case "virtual": + return mainT("common", "recordingSource.virtual"); + default: + return mainT("common", "recordingSource.unknown"); } } +/** + * NO PORTAL RESTORE TOKEN IS KEPT, AND THAT IS DELIBERATE. + * + * A token used to be persisted here so the compositor's picker would not appear + * on every recording. It is gone because it made "record this window" record the + * whole screen instead. A restore 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 the picker at all — and `SelectSources` + * has no parameter naming a source, so the app could not ask for anything else. + * On Wayland the picker IS the source chooser; suppressing it left the user with + * no way to change what they were recording. + * + * Answering the picker each time is the cost of being able to choose at all. + */ // ponytail: the sidecar readers used to live here, ~150 lines of parsing wedged // between the capture state machine and the asset-path helpers, reachable only @@ -1653,6 +1737,19 @@ export function registerIpcHandlers( }); ipcMain.handle("open-source-selector", async () => { + // Nothing to open on Linux WHEN THE NATIVE HELPER IS THERE. The selector's + // own `desktopCapturer.getSources()` raises a portal dialog — a SECOND + // one, for a session that is thrown away — and whatever it returns cannot + // reach the helper, because `SelectSources` has no parameter naming a + // source. Refusing keeps that dialog from appearing at all. + // + // Without the helper the recorder falls back to Chromium's capture, which + // DOES consume a source id, so the picker has to stay reachable there or + // that path could never start. + if (process.platform === "linux" && findPipeWireCursorHelperPath()) { + return { opened: false, reason: "portal-owns-selection" }; + } + const access = await requestScreenAccess(); if (!access.granted) { if (process.platform === "darwin" && access.status !== "not-determined") { @@ -1802,20 +1899,31 @@ export function registerIpcHandlers( : { success: true, available: false, reason: "missing-helper" }; }); + /** + * Raises the compositor's picker and stops there, holding the grant. + * + * Best-effort by contract: every failure returns `success: false` rather than + * throwing, because the caller's fallback is simply to start normally and get + * the picker after its countdown — the behaviour that shipped before this + * existed. Nothing downstream may depend on a prepare having succeeded. + */ ipcMain.handle( - "start-native-linux-recording", + "prepare-native-linux-recording", async (_, request: NativeLinuxRecordingRequest) => { + if (process.platform !== "linux") { + return { success: false, reason: "unsupported-platform" }; + } + if (linuxNativeCaptureSession) { + return { success: false, reason: "already-recording" }; + } + discardPreparedLinuxCapture("superseded by a new prepare"); + const token = Symbol("prepare-native-linux-recording"); + preparingLinuxCaptureToken = token; + try { - if (process.platform !== "linux") { - return { success: false, error: "Native Linux capture requires Linux." }; - } - if (linuxNativeCaptureSession) { - return { success: false, error: "Native Linux capture is already running." }; - } if (!findPipeWireCursorHelperPath()) { - return { success: false, error: "Native Linux capture helper is not available." }; + return { success: false, reason: "missing-helper" }; } - const recordingId = typeof request?.recordingId === "number" && Number.isFinite(request.recordingId) ? request.recordingId @@ -1825,7 +1933,6 @@ export function registerIpcHandlers( normalizeCursorCaptureMode(request?.cursor?.mode) ?? "editable-overlay"; await fs.mkdir(RECORDINGS_DIR, { recursive: true }); - const restoreToken = await readLinuxRestoreToken(); const session = new LinuxNativeCaptureSession({ outputPath, @@ -1843,28 +1950,121 @@ export function registerIpcHandlers( }, }, maxCursorSamples: MAX_CURSOR_SAMPLES, - ...(restoreToken ? { restoreToken } : {}), + deferStart: true, }); + await session.start(); + // The picker is up now. No timeout: a human is reading a dialog. + await session.waitUntilSourceSelected(); + + // Cancelled or superseded while the picker was up. Discard rather + // than assign: this grant is for a recording nobody is waiting for + // any more, and keeping it would leave the sharing indicator on. + if (preparingLinuxCaptureToken !== token) { + session.discard(); + return { success: false, reason: "cancelled" }; + } + preparingLinuxCaptureToken = null; + + preparedLinuxCapture = { session, outputPath, request }; + return { + success: true, + recordingId, + sourceKind: session.grantedSourceKind ?? null, + }; + } catch (error) { + console.warn("Could not prepare the native Linux capture:", error); + discardPreparedLinuxCapture("prepare failed"); + return { success: false, error: String(error) }; + } + }, + ); + + /** Drops a prepared session, e.g. when the countdown was cancelled. */ + ipcMain.handle("cancel-native-linux-prepare", async () => { + discardPreparedLinuxCapture("cancelled by the renderer"); + return { success: true }; + }); + + ipcMain.handle( + "start-native-linux-recording", + async (_, request: NativeLinuxRecordingRequest) => { + try { + if (process.platform !== "linux") { + return { success: false, error: "Native Linux capture requires Linux." }; + } + if (linuxNativeCaptureSession) { + return { success: false, error: "Native Linux capture is already running." }; + } + if (!findPipeWireCursorHelperPath()) { + return { success: false, error: "Native Linux capture helper is not available." }; + } + + const recordingId = + typeof request?.recordingId === "number" && Number.isFinite(request.recordingId) + ? request.recordingId + : Date.now(); + const outputPath = path.join(RECORDINGS_DIR, `${RECORDING_FILE_PREFIX}${recordingId}.mp4`); + const cursorCaptureMode = + normalizeCursorCaptureMode(request?.cursor?.mode) ?? "editable-overlay"; + + await fs.mkdir(RECORDINGS_DIR, { recursive: true }); + + // A session prepared before the countdown, if there was one. Taking + // it here rather than requiring it is what keeps every caller + // working: a path that never prepared still gets a full start + // below, just with the picker after its countdown instead of + // before. Nothing has to know which path it is on. + const prepared = takePreparedLinuxSession(outputPath, request); + const session = + prepared ?? + new LinuxNativeCaptureSession({ + outputPath, + cursorMode: portalCursorMode(cursorCaptureMode), + fps: request.video.fps, + ...(request.video.bitrate ? { bitrate: request.video.bitrate } : {}), + audio: { + system: { enabled: request.audio.system.enabled }, + microphone: { + enabled: request.audio.microphone.enabled, + ...(request.audio.microphone.deviceName + ? { deviceName: request.audio.microphone.deviceName } + : {}), + gain: request.audio.microphone.gain, + }, + }, + maxCursorSamples: MAX_CURSOR_SAMPLES, + }); + console.info("[native-linux] starting capture", { outputPath, + prepared: Boolean(prepared), cursor: { mode: cursorCaptureMode }, audio: request.audio, video: request.video, }); - await session.start(); - // Blocks until the user answers the portal picker, which has no - // upper bound — the countdown UI is already showing by now. + if (!prepared) { + await session.start(); + // Blocks until the user answers the portal picker, which has no + // upper bound. On this path the countdown has already run. + await session.waitUntilSourceSelected(); + } + // Idempotent, and a no-op for a session that was not deferred. + session.arm(); await session.waitUntilCapturing(); linuxNativeCaptureSession = session; linuxNativeCaptureRecordingId = recordingId; linuxNativeCaptureCursorMode = cursorCaptureMode; - const source = selectedSource || { name: "Screen" }; + // The portal's answer, not an in-app selection — on Wayland there + // is none to have. This used to read `selectedSource || { name: + // "Screen" }`, so the tray confidently displayed the name of a + // window the capture had never been told about. + linuxNativeCaptureSourceLabel = linuxSourceLabel(session.grantedSourceKind); if (onRecordingStateChange) { - onRecordingStateChange(true, source.name); + onRecordingStateChange(true, linuxNativeCaptureSourceLabel); } return { success: true, recordingId, path: outputPath }; @@ -1915,7 +2115,6 @@ export function registerIpcHandlers( } const result = await session.stop(); - await writeLinuxRestoreToken(result.restoreToken); // The helper collects cursor samples itself, from the same portal // session that produced the pixels, so there is no separate sampler @@ -1965,9 +2164,10 @@ export function registerIpcHandlers( linuxNativeCaptureSession = null; linuxNativeCaptureRecordingId = null; linuxNativeCaptureCursorMode = "editable-overlay"; - const source = selectedSource || { name: "Screen" }; + const stoppedLabel = linuxNativeCaptureSourceLabel ?? linuxSourceLabel(); + linuxNativeCaptureSourceLabel = null; if (onRecordingStateChange) { - onRecordingStateChange(false, source.name); + onRecordingStateChange(false, stoppedLabel); } } }); diff --git a/electron/native-bridge/capture/linuxNativeCaptureSession.test.ts b/electron/native-bridge/capture/linuxNativeCaptureSession.test.ts new file mode 100644 index 000000000..d450a8a87 --- /dev/null +++ b/electron/native-bridge/capture/linuxNativeCaptureSession.test.ts @@ -0,0 +1,298 @@ +import { EventEmitter } from "node:events"; +import { PassThrough, Writable } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Same hoisting caveat as the cursor-session test next door: `vi.mock` factories + * run above every top-level statement, so the cast is written out inline rather + * than shared through a helper that would still be in its temporal dead zone. + */ +type WithDefault = { default?: Record }; + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + const spawn = vi.fn(); + return { ...actual, spawn, default: { ...((actual as WithDefault).default ?? {}), spawn } }; +}); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + // No helper binary in a test checkout; pretend the first candidate resolves so + // path lookup is not what is under test. + return { + ...actual, + accessSync: vi.fn(), + default: { ...((actual as WithDefault).default ?? {}), accessSync: vi.fn() }, + }; +}); + +import { spawn } from "node:child_process"; +import { LinuxNativeCaptureSession } from "./linuxNativeCaptureSession"; + +class FakeHelper extends EventEmitter { + stdout = new PassThrough(); + stderr = new PassThrough(); + stdinWrites: string[] = []; + killed = false; + stdin: Writable; + + constructor() { + super(); + const writes = this.stdinWrites; + this.stdin = new Writable({ + write(chunk, _encoding, callback) { + writes.push(chunk.toString()); + callback(); + }, + }); + } + + kill() { + this.killed = true; + return true; + } + + emitEvent(event: Record) { + this.stdout.write(`${JSON.stringify({ schemaVersion: 1, ...event })}\n`); + } +} + +const spawnMock = vi.mocked(spawn); +let helper: FakeHelper; + +function newSession(deferStart = false) { + return new LinuxNativeCaptureSession({ + ...(deferStart ? { deferStart: true } : {}), + outputPath: "/tmp/recording.mp4", + cursorMode: "metadata", + fps: 30, + audio: { + system: { enabled: false }, + microphone: { enabled: false, gain: 1 }, + }, + maxCursorSamples: 100, + }); +} + +async function startReady(session: LinuxNativeCaptureSession) { + const started = session.start(); + await Promise.resolve(); + helper.emitEvent({ event: "ready", timestampMs: 1_000 }); + await started; +} + +function flushStdout() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +/** Drives a session to a finished recording, optionally granting a source kind. */ +async function record(sourceKind?: string) { + const session = newSession(); + await startReady(session); + + helper.emitEvent({ + event: "stream-started", + timestampMs: 1_100, + nodeId: 42, + width: 1280, + height: 720, + ...(sourceKind ? { sourceKind } : {}), + }); + await flushStdout(); + + const stopping = session.stop(); + await flushStdout(); + helper.emitEvent({ + event: "capture-stopped", + timestampMs: 2_000, + path: "/tmp/recording.mp4", + durationMs: 900, + frames: 27, + dropped: 0, + }); + await flushStdout(); + helper.emit("exit", 0, null); + + return { session, result: await stopping }; +} + +beforeEach(() => { + helper = new FakeHelper(); + spawnMock.mockReset(); + spawnMock.mockReturnValue(helper as unknown as ReturnType); + const silence = () => { + // The session logs every helper diagnostic; keep the test output readable. + }; + vi.spyOn(console, "info").mockImplementation(silence); + vi.spyOn(console, "warn").mockImplementation(silence); + vi.spyOn(console, "error").mockImplementation(silence); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("LinuxNativeCaptureSession", () => { + /** + * The regression this file exists for. A restore token is bound to the source + * it was minted for, so replaying one made the portal hand back an + * already-approved MONITOR on every later run and stop raising its picker — + * and since `SelectSources` cannot name a source, "record this window" then + * had no way to mean anything. The request must carry no token at all. + */ + it("sends no restore token to the helper", async () => { + await startReady(newSession()); + + const [, args] = spawnMock.mock.calls[0]; + const request = JSON.parse((args as string[])[0]) as Record; + + expect(request).not.toHaveProperty("restoreToken"); + expect(JSON.stringify(request)).not.toContain("restoreToken"); + }); + + it("reports the source kind the portal granted", async () => { + const { session, result } = await record("window"); + + expect(session.grantedSourceKind).toBe("window"); + expect(result.sourceKind).toBe("window"); + }); + + it("distinguishes a granted monitor from a granted window", async () => { + const { session, result } = await record("monitor"); + + expect(session.grantedSourceKind).toBe("monitor"); + expect(result.sourceKind).toBe("monitor"); + }); + + /** + * Absent is not "monitor". A backend that omits the field leaves the kind + * unknown, and collapsing that into a default is how the UI came to assert a + * source the capture had never been told about. + */ + it("leaves the granted kind unknown when the portal does not report one", async () => { + const { session, result } = await record(); + + expect(session.grantedSourceKind).toBeUndefined(); + expect(result.sourceKind).toBeUndefined(); + }); + + /** + * The sequencing fix. The picker has to be answered BEFORE the countdown, so + * "a source was chosen" must be observable separately from "pixels are + * flowing" — otherwise the only thing to wait on is the first frame, which is + * far too late to start counting down. + */ + it("resolves the source selection before any frame is captured", async () => { + const session = newSession(true); + await startReady(session); + + let selected = false; + const selecting = session.waitUntilSourceSelected().then(() => { + selected = true; + }); + await flushStdout(); + expect(selected).toBe(false); + + helper.emitEvent({ + event: "source-selected", + timestampMs: 1_100, + nodeId: 42, + sourceKind: "window", + }); + await selecting; + + expect(selected).toBe(true); + // The kind is known one phase earlier than before, which is when the tray + // label is needed. + expect(session.grantedSourceKind).toBe("window"); + }); + + it("asks the helper to defer, and arms it only when told to", async () => { + const session = newSession(true); + await startReady(session); + + const [, args] = spawnMock.mock.calls[0]; + expect(JSON.parse((args as string[])[0])).toMatchObject({ deferStart: true }); + expect(helper.stdinWrites).not.toContain("record\n"); + + session.arm(); + expect(helper.stdinWrites).toContain("record\n"); + }); + + it("arms at most once, so a caller need not track whether it prepared", async () => { + const session = newSession(true); + await startReady(session); + + session.arm(); + session.arm(); + session.arm(); + + expect(helper.stdinWrites.filter((line) => line === "record\n")).toHaveLength(1); + }); + + it("does not ask the helper to defer unless it was configured to", async () => { + await startReady(newSession()); + + const [, args] = spawnMock.mock.calls[0]; + expect(JSON.parse((args as string[])[0])).not.toHaveProperty("deferStart"); + }); + + /** + * A prepared session is held open across the countdown, so it can die before + * it is ever armed — the user revoking the share from the compositor's + * indicator, or closing the window they picked. Whoever is waiting on the + * picker must learn that instead of waiting forever. + */ + it("rejects a pending source selection when the helper dies", async () => { + const session = newSession(true); + await startReady(session); + + const selecting = session.waitUntilSourceSelected(); + await flushStdout(); + helper.emit("exit", 1, null); + + await expect(selecting).rejects.toThrow(); + }); + + it("resolves the source selection immediately once it has already arrived", async () => { + const session = newSession(true); + await startReady(session); + + helper.emitEvent({ + event: "source-selected", + timestampMs: 1_100, + nodeId: 42, + sourceKind: "monitor", + }); + await flushStdout(); + + // Callers must not have to race the event to observe it. + await expect(session.waitUntilSourceSelected()).resolves.toBeUndefined(); + }); + + it("knows the granted kind by the time the capture is confirmed running", async () => { + const session = newSession(); + await startReady(session); + + const capturing = session.waitUntilCapturing(); + helper.emitEvent({ + event: "stream-started", + timestampMs: 1_100, + nodeId: 7, + width: 800, + height: 600, + sourceKind: "window", + }); + helper.emitEvent({ + event: "capture-started", + timestampMs: 1_200, + path: "/tmp/recording.mp4", + width: 800, + height: 600, + fps: 30, + }); + await capturing; + + expect(session.grantedSourceKind).toBe("window"); + }); +}); diff --git a/electron/native-bridge/capture/linuxNativeCaptureSession.ts b/electron/native-bridge/capture/linuxNativeCaptureSession.ts index 96c5917cf..f1582ee59 100644 --- a/electron/native-bridge/capture/linuxNativeCaptureSession.ts +++ b/electron/native-bridge/capture/linuxNativeCaptureSession.ts @@ -41,18 +41,29 @@ export interface LinuxCaptureConfig { microphone: { enabled: boolean; deviceName?: string; gain: number }; }; maxCursorSamples: number; - /** From a previous recording. Lets the portal skip its picker. */ - restoreToken?: string; + /** + * Negotiate the portal and stop there, until [`arm`] is called. + * + * Splits "the user has chosen what to share" from "pixels are flowing" so a + * countdown can sit between them. Without it the countdown runs first and the + * picker appears afterwards, which is backwards: the picker's wait has no + * upper bound, so the countdown finishes while the user is still reading a + * dialog they have not been shown yet. + */ + deferStart?: boolean; } +/** What the portal handed over. `undefined` means the backend did not say. */ +export type LinuxCaptureSourceKind = "monitor" | "window" | "virtual"; + export interface LinuxCaptureResult { path: string; durationMs: number; frames: number; droppedFrames: number; cursor: CursorRecordingData; - /** Present when the portal issued one; persist it for the next recording. */ - restoreToken?: string; + /** What the portal actually granted, when it said. */ + sourceKind?: LinuxCaptureSourceKind; videoEncoder?: string; } @@ -68,11 +79,16 @@ export class LinuxNativeCaptureSession { private startedResolve: (() => void) | null = null; private startedReject: ((error: Error) => void) | null = null; + private sourceSelected = false; + private sourceSelectedResolve: (() => void) | null = null; + private sourceSelectedReject: ((error: Error) => void) | null = null; + private armed = false; + private stopped: LinuxCaptureResult | null = null; private stoppedResolve: (() => void) | null = null; private stoppedReject: ((error: Error) => void) | null = null; - private restoreToken: string | undefined; + private sourceKind: LinuxCaptureSourceKind | undefined; private videoEncoder: string | undefined; private lastError: string | null = null; private paused = false; @@ -105,7 +121,7 @@ export class LinuxNativeCaptureSession { ...(this.config.bitrate ? { bitrate: this.config.bitrate } : {}), }, audio: this.config.audio, - ...(this.config.restoreToken ? { restoreToken: this.config.restoreToken } : {}), + ...(this.config.deferStart ? { deferStart: true } : {}), }; const child = spawn(helperPath, [JSON.stringify(request)], { @@ -131,6 +147,13 @@ export class LinuxNativeCaptureSession { this.startedReject?.(new Error(reason)); this.startedReject = null; this.startedResolve = null; + // A session held open across a countdown can die before it is armed — + // the user revoking the share from the compositor's indicator, or + // closing the window they picked. Whoever is waiting on the picker + // has to learn that rather than wait forever. + this.sourceSelectedReject?.(new Error(reason)); + this.sourceSelectedReject = null; + this.sourceSelectedResolve = null; // A clean exit after `capture-stopped` is the normal path; anything // else means the file may not have its trailer. if (this.stopped) { @@ -160,6 +183,44 @@ export class LinuxNativeCaptureSession { } } + /** + * Resolves once the user has answered the compositor's picker. + * + * No timeout, for the same reason as [`waitUntilCapturing`]: a human is + * reading a dialog and there is no upper bound on that. Resolves immediately + * if the answer already arrived, so callers need not race the event. + * + * A session started WITHOUT `deferStart` still reports this — it simply + * arrives moments before capture rather than being waited on. + */ + waitUntilSourceSelected(): Promise { + if (this.sourceSelected) { + return Promise.resolve(); + } + if (!this.process) { + return Promise.reject(new Error("The Linux capture helper is not running.")); + } + return new Promise((resolve, reject) => { + this.sourceSelectedResolve = resolve; + this.sourceSelectedReject = reject; + }); + } + + /** + * Releases a deferred session: connect to PipeWire and start encoding. + * + * Safe to call on a session that was not deferred (the helper ignores the + * verb) and safe to call twice, so a caller never has to track whether a + * prepare happened. + */ + arm() { + if (this.armed) { + return; + } + this.armed = true; + this.write("record"); + } + /** * Resolves when the first frame has been encoded — i.e. once the user has * answered the portal picker. No timeout, on purpose: see the class doc. @@ -194,6 +255,18 @@ export class LinuxNativeCaptureSession { return this.paused; } + /** + * What the portal granted. Known from `stream-started`, so it is populated by + * the time [`waitUntilCapturing`] resolves. + * + * `undefined` means the backend did not report a kind — which is NOT the same + * as "a screen", and callers must not collapse the two. Guessing is what this + * whole field exists to stop. + */ + get grantedSourceKind(): LinuxCaptureSourceKind | undefined { + return this.sourceKind; + } + /** * Asks the helper to write the trailer and exit, then returns what it made. * @@ -283,13 +356,36 @@ export class LinuxNativeCaptureSession { this.resolveReady(); return; + case "source-selected": + if (payload.sourceKind) { + this.sourceKind = payload.sourceKind; + } + this.sourceSelected = true; + console.info( + "[capture-linux] source selected", + JSON.stringify({ sourceKind: payload.sourceKind ?? null }), + ); + this.sourceSelectedResolve?.(); + this.sourceSelectedResolve = null; + this.sourceSelectedReject = null; + return; + case "stream-started": - if (payload.restoreToken) { - this.restoreToken = payload.restoreToken; + if (payload.sourceKind) { + this.sourceKind = payload.sourceKind; } + // The source kind is logged unconditionally for the same reason + // as the audio node: when someone reports "I picked a window and + // got my whole screen", this line is the answer, and it is the + // only place the truth appears — the app cannot name a source + // when asking the portal, so nothing upstream knows it. console.info( "[capture-linux] portal stream started", - JSON.stringify({ width: payload.width, height: payload.height }), + JSON.stringify({ + width: payload.width, + height: payload.height, + sourceKind: payload.sourceKind ?? null, + }), ); return; @@ -346,7 +442,7 @@ export class LinuxNativeCaptureSession { frames: payload.frames, droppedFrames: payload.dropped, cursor: this.cursor.toRecordingData(), - ...(this.restoreToken ? { restoreToken: this.restoreToken } : {}), + ...(this.sourceKind ? { sourceKind: this.sourceKind } : {}), ...(this.videoEncoder ? { videoEncoder: this.videoEncoder } : {}), }; console.info( @@ -372,6 +468,9 @@ export class LinuxNativeCaptureSession { // than just the exit code, which on its own says nothing useful. this.lastError = payload.message; this.rejectReady(new Error(payload.message)); + this.sourceSelectedReject?.(new Error(payload.message)); + this.sourceSelectedReject = null; + this.sourceSelectedResolve = null; return; case "debug": diff --git a/electron/native-bridge/cursor/recording/pipeWireCursorAccumulator.ts b/electron/native-bridge/cursor/recording/pipeWireCursorAccumulator.ts index 6cbeb9ee1..84a13130e 100644 --- a/electron/native-bridge/cursor/recording/pipeWireCursorAccumulator.ts +++ b/electron/native-bridge/cursor/recording/pipeWireCursorAccumulator.ts @@ -28,13 +28,24 @@ export interface PipeWireCursorAssetPayload { export type PipeWireHelperEvent = | { event: "ready"; timestampMs: number; pipewireVersion?: string | null } + | { + /** The picker has been answered. Fires before any pixel moves. */ + event: "source-selected"; + timestampMs: number; + nodeId: number; + sourceKind?: "monitor" | "window" | "virtual" | null; + positionX?: number | null; + positionY?: number | null; + } | { event: "stream-started"; timestampMs: number; nodeId: number; width: number; height: number; - restoreToken?: string | null; + /** What the compositor actually handed over. Absent on backends that + * omit it — treat that as unknown, never as a screen. */ + sourceKind?: "monitor" | "window" | "virtual" | null; } | { event: "cursor-sample"; diff --git a/electron/native/pipewire-capture/csrc/pw_shim.c b/electron/native/pipewire-capture/csrc/pw_shim.c index d12665f35..7646b8863 100644 --- a/electron/native/pipewire-capture/csrc/pw_shim.c +++ b/electron/native/pipewire-capture/csrc/pw_shim.c @@ -324,6 +324,26 @@ static const struct spa_pod *osc_build_cursor_meta(struct spa_pod_builder *build (int32_t)OSC_CURSOR_META_SIZE(1024, 1024))); } +/* + * The consumer side of the SPA_META_VideoCrop negotiation. + * + * THIS DECLARATION IS THE WHOLE POINT. `pw_buffers_negotiate` intersects the two + * sides' ParamMeta lists, and mutter writes the rectangle only inside + * `if (spa_meta_video_crop)` — so a consumer that never asks is simply never + * given one, with no error and nothing in any log. Omitting this object is what + * made a window arrive as a monitor-sized buffer with the window in one corner. + * + * A FIXED Int, never a CHOICE_RANGE: that is what OBS, WebRTC and mutter's own + * producer declaration all emit, so a range here could only fail to intersect. + */ +static const struct spa_pod *osc_build_video_crop_meta(struct spa_pod_builder *builder) +{ + return spa_pod_builder_add_object(builder, SPA_TYPE_OBJECT_ParamMeta, SPA_PARAM_Meta, + SPA_PARAM_META_type, SPA_POD_Id(SPA_META_VideoCrop), + SPA_PARAM_META_size, + SPA_POD_Int(sizeof(struct spa_meta_region))); +} + int osc_pw_cursor_meta_accepts_producer_size(uint32_t width, uint32_t height) { uint8_t ours_storage[512]; @@ -357,7 +377,7 @@ static void osc_on_param_changed(void *userdata, uint32_t id, const struct spa_p struct osc_pw_session *session = userdata; uint8_t buffer[1024]; struct spa_pod_builder builder = SPA_POD_BUILDER_INIT(buffer, sizeof(buffer)); - const struct spa_pod *params[3]; + const struct spa_pod *params[4]; struct osc_pw_format reported; uint32_t media_type; uint32_t media_subtype; @@ -411,11 +431,12 @@ static void osc_on_param_changed(void *userdata, uint32_t id, const struct spa_p SPA_POD_Int(sizeof(struct spa_meta_header))); params[2] = osc_build_cursor_meta(&builder); + params[3] = osc_build_video_crop_meta(&builder); /* The builder returns NULL if its fixed buffer overflowed. 1 KiB is far more - * than these three objects need, but handing NULLs to update_params would be + * than these four objects need, but handing NULLs to update_params would be * a null deref inside libpipewire, so it is checked rather than assumed. */ - if (params[0] == NULL || params[1] == NULL || params[2] == NULL) { + if (params[0] == NULL || params[1] == NULL || params[2] == NULL || params[3] == NULL) { return; } @@ -424,7 +445,7 @@ static void osc_on_param_changed(void *userdata, uint32_t id, const struct spa_p * set actually in use rather than a set that no longer exists. */ session->buffer_info_reports = 0; - api.stream_update_params(session->stream, params, 3); + api.stream_update_params(session->stream, params, SPA_N_ELEMENTS(params)); } static void osc_on_state_changed(void *userdata, enum pw_stream_state old, @@ -538,6 +559,7 @@ static int osc_read_frame(struct osc_pw_session *session, const struct spa_buffe { struct spa_data *data; struct spa_meta_header *header; + struct spa_meta_region *region; uint32_t offset; uint32_t size; int32_t stride; @@ -591,6 +613,41 @@ static int osc_read_frame(struct osc_pw_session *session, const struct spa_buffe if (header != NULL) { out->pts_ns = header->pts; } + + /* Default to the whole frame, so every consumer of `out` can read the crop + * fields unconditionally and a missing meta degrades to today's behaviour. */ + out->crop_x = 0; + out->crop_y = 0; + out->crop_width = out->width; + out->crop_height = out->height; + out->has_crop = 0; + + region = spa_buffer_find_meta_data(buffer, SPA_META_VideoCrop, sizeof(*region)); + if (region != NULL && spa_meta_region_is_valid(region)) { + /* Widened before comparing: `position` is signed int32 and `size` is + * uint32, so a hostile or buggy rect can overflow int32 arithmetic. The + * region is written by another process into shared memory and a bad one + * becomes an out-of-bounds read inside swscale, so it is rejected rather + * than trusted — WebRTC's posture, and the right one here because this + * pointer is handed straight to the encoder. */ + int64_t x = region->region.position.x; + int64_t y = region->region.position.y; + int64_t w = region->region.size.width; + int64_t h = region->region.size.height; + + if (x >= 0 && y >= 0 && w > 0 && h > 0 && x + w <= (int64_t)out->width && + y + h <= (int64_t)out->height) { + out->crop_x = (int32_t)x; + out->crop_y = (int32_t)y; + out->crop_width = (int32_t)w; + out->crop_height = (int32_t)h; + /* A crop covering the whole frame is not a crop. Distinguishing it + * from a genuine one is what lets the caller tell a monitor stream + * apart from a window stream whose rectangle never arrived. */ + out->has_crop = (x != 0 || y != 0 || w < (int64_t)out->width || + h < (int64_t)out->height); + } + } return 1; } diff --git a/electron/native/pipewire-capture/csrc/pw_shim.h b/electron/native/pipewire-capture/csrc/pw_shim.h index f67cd33f0..cabaace3c 100644 --- a/electron/native/pipewire-capture/csrc/pw_shim.h +++ b/electron/native/pipewire-capture/csrc/pw_shim.h @@ -67,6 +67,33 @@ struct osc_pw_frame { * arrival time: it is stamped when the frame was composited, not when this * process got round to looking at it. */ int64_t pts_ns; + /* + * The sub-rectangle of `data` that actually holds content, from + * SPA_META_VideoCrop. Always populated: it defaults to the whole frame. + * + * WHY A WINDOW STREAM NEEDS THIS. A PipeWire stream cannot change size once + * negotiated, but a window can be resized at any moment, so mutter sizes a + * window stream to the whole MONITOR and reports the window's live rectangle + * here instead — "We cannot set the stream size to the exact size of the + * window, because windows can be resized, whereas streams cannot" + * (meta-screen-cast-window-stream.c). Encoding the buffer without applying + * this crop is what produced window recordings padded out to screen size + * with black. Both OBS and WebRTC's PipeWire capturers apply it. + */ + int32_t crop_x; + int32_t crop_y; + int32_t crop_width; + int32_t crop_height; + /* + * The crop is real AND narrower than the frame in at least one dimension. + * + * Zero means "there is nothing to crop to", covering three distinct cases + * that must not be told apart by guessing: the compositor sent no meta, it + * sent an invalid or out-of-bounds one, or it sent one covering the whole + * frame. OBS (`has_effective_crop`) and WebRTC (`videocrop_metadata_use`) + * both draw the line in exactly this place. + */ + int has_crop; }; /* The negotiated video format. Reported once, from param_changed. */ diff --git a/electron/native/pipewire-capture/src/capture.rs b/electron/native/pipewire-capture/src/capture.rs index aaed89278..0b07c34a4 100644 --- a/electron/native/pipewire-capture/src/capture.rs +++ b/electron/native/pipewire-capture/src/capture.rs @@ -210,6 +210,15 @@ pub struct Capture { /// The next output frame index to write. next_index: i64, frames_written: u64, + /// The size the encoder was opened at, latched for the whole file. + /// + /// An MP4 track cannot change resolution mid-file, but a window's crop rect + /// can change on ANY buffer — mutter never renegotiates the format for a + /// window stream, so a resize travels down the crop and nothing else. The + /// committed size is therefore the contract, and a later crop is read + /// through it rather than replacing it. + committed_width: i32, + committed_height: i32, } impl Capture { @@ -274,16 +283,63 @@ impl Capture { paused_at: None, next_index: 0, frames_written: 0, + committed_width: width, + committed_height: height, }, selection, )) } + /// Whether this frame's crop still matches what the encoder was opened at. + /// + /// A divergence means the recorded window was resized. The recording keeps + /// its original dimensions — see [`Self::committed_width`] — so the caller + /// reports it once rather than silently reframing. + pub fn crop_diverged(&self, frame: &shim::Frame) -> bool { + // Compared at ENCODED parity, not raw. The committed size was rounded + // down to even for H.264 chroma, so a window sitting stably at 321x241 + // commits 320x240 and would otherwise be reported as resized on every + // single frame — a warning about a window that never moved. + (frame.crop.width & !1) != self.committed_width + || (frame.crop.height & !1) != self.committed_height + } + + /// Where to start reading this frame, in source pixels. + /// + /// Follows the LIVE crop origin, so moving the recorded window tracks it, + /// but clamps so a committed-size read always stays inside the buffer. That + /// clamp is the only thing standing between a shrunken window and an + /// out-of-bounds read inside swscale. + fn read_origin(&self, frame: &shim::Frame) -> (i32, i32) { + let max_x = (frame.width - self.committed_width).max(0); + let max_y = (frame.height - self.committed_height).max(0); + ( + frame.crop.x.clamp(0, max_x), + frame.crop.y.clamp(0, max_y), + ) + } + /// Converts a captured frame into the encoder's staging buffer. Nothing is /// written until [`Self::advance`] runs. pub fn stage(&mut self, frame: &shim::Frame) -> Result<(), String> { let format = pixel_format(frame.video_format)?; - self.encoder.stage(&frame.pixels, frame.stride, format)?; + + // Address the crop by moving the START of the slice, and hand swscale the + // frame's OWN stride unchanged. The stride is the distance between rows + // in the source buffer, which cropping does not alter — WebRTC's memfd + // path subtracts the x offset from it, which is wrong for any non-zero x + // and is latent there only because no shipping compositor sets one. + let (x, y) = self.read_origin(frame); + let offset = (y as usize) + .checked_mul(frame.stride) + .and_then(|rows| rows.checked_add((x as usize) * BYTES_PER_SOURCE_PIXEL)) + .ok_or_else(|| "crop offset overflows".to_owned())?; + let pixels = frame + .pixels + .get(offset..) + .ok_or_else(|| format!("crop offset {offset} is past the end of the frame"))?; + + self.encoder.stage(pixels, frame.stride, format)?; if self.epoch.is_none() { self.epoch = Some(Instant::now()); // Audio has been accumulating since the process started, while the @@ -424,6 +480,14 @@ impl Capture { /// `osc_build_enum_format` advertises can appear here; anything else means the /// two lists drifted apart, which is worth an error rather than a guess at the /// channel order. +/// Bytes per pixel in every format [`pixel_format`] accepts. +/// +/// All four that `osc_build_enum_format` advertises are 32-bit, so this is a +/// constant rather than a lookup. It lives HERE, next to the table it describes, +/// because the two must change together: adding a 24-bit format below without +/// revisiting this would silently mis-address every cropped row. +pub const BYTES_PER_SOURCE_PIXEL: usize = 4; + fn pixel_format(spa_format: u32) -> Result { let constants = shim::constants(); // `*0` rather than `*A`: the padding byte carries no alpha, and telling @@ -460,9 +524,25 @@ mod tests { height, video_format: format, pts_ns: -1, + crop: shim::CropRect { x: 0, y: 0, width, height }, + has_crop: false, } } + /// A window's frame: a monitor-sized buffer whose content is the rectangle + /// at (x, y). This is what mutter actually delivers for a window stream. + fn cropped_frame( + width: i32, + height: i32, + crop: shim::CropRect, + format: u32, + ) -> shim::Frame { + let mut frame = frame(width, height, format); + frame.crop = crop; + frame.has_crop = true; + frame + } + #[test] fn advertised_formats_all_map_to_a_pixel_format() { // The two lists — what osc_build_enum_format offers and what @@ -524,6 +604,123 @@ mod tests { let _ = std::fs::remove_file(&output); } + /// The window-capture bug, at the layer where it produced wrong pixels. + /// + /// mutter hands a window stream MONITOR-sized buffers and reports the + /// window's rectangle as SPA_META_VideoCrop. Encoding the buffer without + /// applying that rectangle is what padded window recordings out to screen + /// size with black. + #[test] + fn a_window_is_staged_from_its_crop_inside_a_larger_frame() { + let output = std::env::temp_dir().join("openscreen-capture-crop.mp4"); + let (mut capture, _) = + Capture::start(&output, 320, 240, 30, Some(1_000_000), Some(Backend::Software), Vec::new()) + .expect("start"); + + // A 1920x1080 stream carrying a 320x240 window at (100, 50). + let staged = capture.stage(&cropped_frame( + 1920, + 1080, + shim::CropRect { x: 100, y: 50, width: 320, height: 240 }, + shim::constants().video_format_bgrx, + )); + + assert!(staged.is_ok(), "a crop inside the frame must stage: {staged:?}"); + + // Frames are clock-driven, so let the timeline advance far enough for the + // staged picture to actually reach the encoder at the cropped geometry. + std::thread::sleep(Duration::from_millis(120)); + let written = capture.advance().expect("advance"); + assert!(written >= 1, "the cropped picture should have been encoded, wrote {written}"); + + let summary = capture.finish().expect("finish"); + assert_eq!(summary.frames, written as u64); + let _ = std::fs::remove_file(&output); + } + + /// A crop flush against the right edge leaves the last row short of a full + /// stride. The old `stride * height` bounds check rejected exactly those — + /// i.e. every window not touching the left edge. + #[test] + fn a_crop_against_the_right_edge_is_not_rejected_as_truncated() { + let output = std::env::temp_dir().join("openscreen-capture-edge.mp4"); + let (mut capture, _) = + Capture::start(&output, 320, 240, 30, Some(1_000_000), Some(Backend::Software), Vec::new()) + .expect("start"); + + let staged = capture.stage(&cropped_frame( + 1920, + 1080, + shim::CropRect { x: 1600, y: 840, width: 320, height: 240 }, + shim::constants().video_format_bgrx, + )); + + assert!(staged.is_ok(), "a crop at the far corner must stage: {staged:?}"); + let _ = capture.finish(); + let _ = std::fs::remove_file(&output); + } + + /// The safety property. A window that SHRANK after the encoder was opened + /// still reports its own smaller rect, and reading a committed-sized picture + /// from its origin must stay inside the buffer rather than running off the + /// end into whatever follows it in the mapping. + #[test] + fn a_shrunken_window_is_read_from_inside_the_frame() { + let output = std::env::temp_dir().join("openscreen-capture-shrunk.mp4"); + let (mut capture, _) = + Capture::start(&output, 320, 240, 30, Some(1_000_000), Some(Backend::Software), Vec::new()) + .expect("start"); + + // Origin so close to the edge that a 320x240 read from it would overrun. + let frame = cropped_frame( + 400, + 300, + shim::CropRect { x: 380, y: 290, width: 20, height: 10 }, + shim::constants().video_format_bgrx, + ); + assert!(capture.crop_diverged(&frame), "20x10 must not look like the committed 320x240"); + + let staged = capture.stage(&frame); + assert!(staged.is_ok(), "the read must be clamped back inside the frame: {staged:?}"); + let _ = capture.finish(); + let _ = std::fs::remove_file(&output); + } + + /// A window whose size is odd is rounded down once, at encoder open. Judging + /// later frames against the raw rect would then report a resize on every + /// frame of a window that never moved. + #[test] + fn a_stable_odd_sized_window_is_not_reported_as_resized() { + let output = std::env::temp_dir().join("openscreen-capture-odd.mp4"); + // 321x241 rounds to the 320x240 the encoder is opened at. + let (mut capture, _) = + Capture::start(&output, 320, 240, 30, Some(1_000_000), Some(Backend::Software), Vec::new()) + .expect("start"); + + let frame = cropped_frame( + 1920, + 1080, + shim::CropRect { x: 0, y: 0, width: 321, height: 241 }, + shim::constants().video_format_bgrx, + ); + + assert!(!capture.crop_diverged(&frame), "an unchanged odd crop is not a resize"); + let _ = capture.finish(); + let _ = std::fs::remove_file(&output); + } + + #[test] + fn an_uncropped_frame_reports_no_divergence() { + let output = std::env::temp_dir().join("openscreen-capture-nocrop.mp4"); + let (mut capture, _) = + Capture::start(&output, 320, 240, 30, Some(1_000_000), Some(Backend::Software), Vec::new()) + .expect("start"); + + assert!(!capture.crop_diverged(&frame(320, 240, shim::constants().video_format_bgrx))); + let _ = capture.finish(); + let _ = std::fs::remove_file(&output); + } + #[test] fn paused_time_does_not_advance_the_timeline() { let output = std::env::temp_dir().join("openscreen-capture-pause.mp4"); diff --git a/electron/native/pipewire-capture/src/encoder.rs b/electron/native/pipewire-capture/src/encoder.rs index 03dd85067..6163efff7 100644 --- a/electron/native/pipewire-capture/src/encoder.rs +++ b/electron/native/pipewire-capture/src/encoder.rs @@ -23,6 +23,7 @@ use std::ffi::{CStr, CString}; use std::path::Path; use std::ptr; +use crate::capture::BYTES_PER_SOURCE_PIXEL; use crate::ffmpeg as ff; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -441,14 +442,22 @@ impl VideoEncoder { stride: usize, src_format: ff::AVPixelFormat, ) -> Result<(), String> { + // The LAST row needs only its own pixels, not a further stride's worth of + // padding. Demanding `stride * height` rejected exactly the frames a + // window crop produces: `pixels` there starts partway into the buffer, so + // the tail is short by the offset even though every row is complete. let needed = stride - .checked_mul(self.params.height as usize) + .checked_mul(self.params.height.saturating_sub(1) as usize) + .and_then(|rows| { + rows.checked_add(self.params.width as usize * BYTES_PER_SOURCE_PIXEL) + }) .ok_or_else(|| "frame size overflows".to_owned())?; if pixels.len() < needed { return Err(format!( - "captured frame is truncated: {} bytes for {} rows at stride {stride}", + "captured frame is truncated: {} bytes for {} rows of {} px at stride {stride}", pixels.len(), - self.params.height + self.params.height, + self.params.width )); } diff --git a/electron/native/pipewire-capture/src/events.rs b/electron/native/pipewire-capture/src/events.rs index ee2fb8127..085ea56aa 100644 --- a/electron/native/pipewire-capture/src/events.rs +++ b/electron/native/pipewire-capture/src/events.rs @@ -49,6 +49,22 @@ pub enum Event { /// Whether the portal advertises METADATA cursor mode at all. cursor_metadata_supported: bool, }, + /// The user has answered the compositor's picker and a source was granted. + /// + /// A DIFFERENT MOMENT from [`Self::StreamStarted`], and the distinction is + /// the point: this fires when the choice is made, before any pixel has moved. + /// It is what lets a caller run a countdown after the user has been asked + /// what to share rather than before — the picker's wait has no upper bound, + /// so a countdown started ahead of it just freezes on screen. + #[serde(rename_all = "camelCase")] + SourceSelected { + timestamp_ms: u64, + node_id: u32, + /// `"monitor"`, `"window"` or `"virtual"`, when the portal says. + source_kind: Option, + position_x: Option, + position_y: Option, + }, #[serde(rename_all = "camelCase")] StreamStarted { timestamp_ms: u64, @@ -59,7 +75,12 @@ pub enum Event { /// space, when the portal reports one (monitor streams only). position_x: Option, position_y: Option, - restore_token: Option, + /// `"monitor"`, `"window"` or `"virtual"` — what the compositor + /// actually handed over, straight from the portal's reply. Absent when + /// the backend omits it. This is the only honest answer to "what am I + /// recording?": the app cannot name a source when asking, so it can + /// only be told after the fact. + source_kind: Option, }, /// Cursor position in stream pixels. `width`/`height` repeat on every /// sample so a consumer never has to correlate with an earlier event to @@ -263,11 +284,12 @@ mod tests { height: 1440, position_x: Some(0), position_y: Some(-1080), - restore_token: None, + source_kind: Some("window".to_owned()), }); assert_eq!(value["event"], "stream-started"); assert_eq!(value["nodeId"], 55); assert_eq!(value["positionY"], -1080); + assert_eq!(value["sourceKind"], "window"); } #[test] diff --git a/electron/native/pipewire-capture/src/main.rs b/electron/native/pipewire-capture/src/main.rs index a87e088de..1576efced 100644 --- a/electron/native/pipewire-capture/src/main.rs +++ b/electron/native/pipewire-capture/src/main.rs @@ -87,6 +87,14 @@ struct MicrophoneRequest { } const DEFAULT_AUDIO_BITRATE: i64 = 128_000; +/// How many frames a window stream may deliver without a crop rectangle before +/// the helper gives up waiting and records the whole stream. +/// +/// mutter records one frame synchronously when the stream is enabled, which can +/// land before the picked window is mapped and therefore carry an empty rect. A +/// handful of frames is ~100 ms at 60 fps — long enough to skip that, short +/// enough that a compositor which never sends a crop still starts recording. +const MAX_FRAMES_AWAITING_CROP: u32 = 8; /// How much audio may queue before the oldest is discarded. Generous: the drain /// runs every loop tick, so reaching this means the encoder stopped entirely. const AUDIO_RING_SECONDS: usize = 2; @@ -95,9 +103,6 @@ const AUDIO_RING_SECONDS: usize = 2; #[serde(rename_all = "camelCase", default)] struct Request { sample_interval_ms: Option, - /// Token from a previous run's `stream-started`. Lets the portal skip the - /// picker for a source the user already approved. - restore_token: Option, /// Where to write the MP4. Absent means cursor-only: no pixels are mapped, /// no encoder is opened, and the helper behaves exactly as Stage 1 did. output_path: Option, @@ -107,6 +112,14 @@ struct Request { cursor_mode: Option, video: Option, audio: Option, + /// Negotiate the portal, report `source-selected`, then WAIT for `record` on + /// stdin before connecting to PipeWire. + /// + /// Splits "the user has chosen what to share" from "pixels are flowing", so a + /// caller can put a countdown between them instead of counting down before + /// the picker has even appeared. Defaults to false, which keeps the + /// single-shot behaviour every existing caller relies on. + defer_start: bool, /// Emit `ready` and exit, without ever calling the portal's `Start()`. /// /// Everything up to that point is non-interactive; `Start()` is the single @@ -195,6 +208,8 @@ struct AudioSourceConfig { enum Message { Portal(Box>), Stream(StreamEvent), + /// Arm a deferred session: connect to PipeWire and start encoding. + Record, Pause, Resume, Stop, @@ -267,7 +282,7 @@ fn main() { let (sender, receiver) = mpsc::channel::(); spawn_stdin_reader(sender.clone()); - spawn_portal(sender.clone(), request.restore_token.clone(), cursor_mode); + spawn_portal(sender.clone(), cursor_mode); let session = RunConfig { tick, @@ -278,6 +293,7 @@ fn main() { forced_encoder, cursor_mode, audio: request.audio_sources(), + defer_start: request.defer_start, }; let exit_code = run(&mut emitter, receiver, sender, session); std::process::exit(exit_code); @@ -296,6 +312,9 @@ struct RunConfig { forced_encoder: Option, cursor_mode: portal::CursorMode, audio: Vec, + /// Wait for `record` on stdin before connecting to PipeWire. See + /// [`Request::defer_start`]. + defer_start: bool, } /// Opens every requested audio stream, returning the live sessions (which must @@ -429,6 +448,14 @@ fn spawn_stdin_reader(sender: Sender) { let Ok(line) = line else { break }; match line.trim() { "stop" => break, + // Arms a `deferStart` session. Ignored by a session that was not + // deferred, and unknown verbs already fall through to `continue`, + // so an older helper receiving this degrades instead of dying. + "record" => { + if sender.send(Message::Record).is_err() { + return; + } + } "pause" => { if sender.send(Message::Pause).is_err() { return; @@ -449,13 +476,9 @@ fn spawn_stdin_reader(sender: Sender) { /// The portal runs on its own thread because `Start()` blocks on the user for /// an unbounded time, and a `stop` arriving during the picker must still be /// honoured. -fn spawn_portal( - sender: Sender, - restore_token: Option, - cursor_mode: portal::CursorMode, -) { +fn spawn_portal(sender: Sender, cursor_mode: portal::CursorMode) { std::thread::spawn(move || { - let result = pollster::block_on(portal::negotiate(restore_token.as_deref(), cursor_mode)); + let result = pollster::block_on(portal::negotiate(cursor_mode)); let _ = sender.send(Message::Portal(Box::new(result))); }); } @@ -468,12 +491,55 @@ struct CursorState { asset_id: Option, } +/// Connects the PipeWire consumer to a grant the portal already made. +/// +/// Split out because it runs from two places: straight off the portal reply in +/// the single-shot path, and from `record` in the deferred path where the grant +/// has been sitting in `pending_portal` while the caller ran its countdown. +#[allow(clippy::too_many_arguments)] +fn begin_stream( + emitter: &mut Emitter, + sender: &Sender, + frames: &Option>, + session: &mut Option, + portal_stream: &mut Option, + granted_kind: &mut Option, + stream: portal::PortalStream, +) -> Result<(), ()> { + // The fd is consumed by libpipewire; the rest is kept for the + // `stream-started` event, emitted once the format is negotiated. + let portal::PortalStream { fd, node_id, position, source_kind, .. } = stream; + let forward = sender.clone(); + match shim::Session::start( + fd, + node_id, + Box::new(move |event| { + let _ = forward.send(Message::Stream(event)); + }), + frames.clone(), + ) { + Ok(started) => { + *session = Some(started); + *granted_kind = source_kind; + *portal_stream = Some(StreamInfo { node_id, position, source_kind }); + Ok(()) + } + Err(message) => { + let _ = emitter.emit(&Event::Error { + code: "pipewire-connect-failed".to_owned(), + message, + }); + Err(()) + } + } +} + /// What survives from the portal reply after its fd has been handed to /// libpipewire — everything `stream-started` needs to report. struct StreamInfo { node_id: u32, position: Option<(i32, i32)>, - restore_token: Option, + source_kind: Option, } fn run( @@ -487,6 +553,18 @@ fn run( let mut session: Option = None; let mut portal_stream: Option = None; let mut size: Option<(i32, i32)> = None; + // What the portal granted, kept past the `StreamInfo` that is consumed at + // Format: only a window stream is expected to carry a crop. + let mut granted_kind: Option = None; + // Frames skipped while waiting for a window's first usable crop rectangle. + let mut frames_awaiting_crop: u32 = 0; + // A crop change is reported once, not once per frame. + let mut crop_change_reported = false; + // A `deferStart` grant held while the caller runs its countdown. + let mut pending_portal: Option = None; + // `record` has been received. Latched, because it can arrive before the + // picker has been answered. + let mut armed = false; let mut cursor: Option = None; let mut known_assets: HashSet = HashSet::new(); let mut pending_asset: Option = None; @@ -534,15 +612,142 @@ fn run( } Ok(Message::Stream(StreamEvent::FrameReady)) => { - let (Some(mailbox), Some(capture)) = (frames.as_ref(), capture.as_mut()) else { + let Some(mailbox) = frames.as_ref() else { continue; }; // `take` can legitimately return None: several FrameReady // notifications can arrive for frames that superseded each other // in the mailbox before the loop got here. if let Some(frame) = mailbox.take() { + // Opening the encoder is deferred to here because only a + // frame carries the crop, and the crop — not the negotiated + // format — is the size of a window. + if capture.is_none() { + if let Some(path) = config.output_path.as_ref() { + // A WINDOW WITHOUT A CROP IS NOT YET TRUSTWORTHY. + // mutter's rectangle intersection reports success + // even when it produced an empty rect, and it records + // one frame synchronously from `enable()` — before + // the picked window is necessarily mapped. Committing + // the encoder to that frame would pin a window + // recording at monitor size for its whole duration, + // which is the very bug being fixed, only intermittent. + let expecting_crop = + granted_kind == Some(portal::SourceKind::Window) && !frame.has_crop; + if expecting_crop && frames_awaiting_crop < MAX_FRAMES_AWAITING_CROP { + frames_awaiting_crop += 1; + mailbox.recycle(frame.pixels); + continue; + } + if expecting_crop { + let _ = emitter.emit(&Event::Warning { + code: "window-crop-missing".to_owned(), + message: format!( + "a window was granted but no crop rectangle arrived in \ + {frames_awaiting_crop} frames; recording the full \ + {}x{} stream instead", + frame.width, frame.height + ), + }); + } + + // H.264 chroma is subsampled 2x2, so odd dimensions + // are a per-encoder lottery. A window's rect is its + // own pixel size and is routinely odd. + let width = frame.crop.width & !1; + let height = frame.crop.height & !1; + if width <= 0 || height <= 0 { + let _ = emitter.emit(&Event::Error { + code: "encoder-unavailable".to_owned(), + message: format!( + "the compositor reported a {}x{} content rectangle, which \ + cannot be encoded", + frame.crop.width, frame.crop.height + ), + }); + exit_code = 1; + break; + } + let _ = emitter.emit(&Event::Debug { + code: "crop".to_owned(), + data: json_map([ + ("streamWidth", frame.width.into()), + ("streamHeight", frame.height.into()), + ("cropX", frame.crop.x.into()), + ("cropY", frame.crop.y.into()), + ("cropWidth", frame.crop.width.into()), + ("cropHeight", frame.crop.height.into()), + ("encodedWidth", width.into()), + ("encodedHeight", height.into()), + ("hasCrop", frame.has_crop.into()), + ("framesAwaited", frames_awaiting_crop.into()), + ]), + }); + + match Capture::start( + path, + width, + height, + config.fps, + config.bitrate, + config.forced_encoder, + std::mem::take(&mut audio_sources), + ) { + Ok((started, selection)) => { + let _ = emitter.emit(&Event::EncoderSelection { + video: selection.backend.as_str().to_owned(), + rejected: selection.rejected, + }); + capture = Some(started); + if paused { + // A `pause` that arrived while the portal + // picker was still up applies to the + // capture that picker was for. + if let Some(capture) = capture.as_mut() { + capture.pause(); + } + } + } + Err(message) => { + let _ = emitter.emit(&Event::Error { + code: "encoder-unavailable".to_owned(), + message, + }); + exit_code = 1; + break; + } + } + } + } + + let Some(capture) = capture.as_mut() else { + // Cursor-only session: no encoder, nothing to stage. The + // recycle below still has to run. + mailbox.recycle(frame.pixels); + continue; + }; + + // A window that was resized mid-recording. The file keeps its + // original dimensions — an MP4 track cannot change resolution + // — so this is reported rather than silently reframed. + if !crop_change_reported && capture.crop_diverged(&frame) { + crop_change_reported = true; + let _ = emitter.emit(&Event::Warning { + code: "crop-changed".to_owned(), + message: format!( + "the recorded window changed size to {}x{} mid-recording; the \ + file keeps the size it started at, so the framing may be cut \ + off or padded from here on", + frame.crop.width, frame.crop.height + ), + }); + } + let first = !capture.started(); - if let Err(message) = capture.stage(&frame) { + let staged = capture.stage(&frame); + let (width, height) = (frame.crop.width, frame.crop.height); + mailbox.recycle(frame.pixels); + if let Err(message) = staged { let _ = emitter.emit(&Event::Error { code: "encode-failed".to_owned(), message, @@ -558,34 +763,48 @@ fn run( .as_ref() .map(|path| path.display().to_string()) .unwrap_or_default(), - width: frame.width, - height: frame.height, + width, + height, fps: config.fps, }); } - mailbox.recycle(frame.pixels); } - if let Err(message) = capture.advance() { - let _ = emitter.emit(&Event::Error { - code: "encode-failed".to_owned(), - message, - }); - exit_code = 1; - break; + if let Some(capture) = capture.as_mut() { + if let Err(message) = capture.advance() { + let _ = emitter.emit(&Event::Error { + code: "encode-failed".to_owned(), + message, + }); + exit_code = 1; + break; + } + } + } + + Ok(Message::Record) => { + // Arming is idempotent and may arrive before OR after the picker + // is answered — the user can be slower than the countdown, or + // faster. Both orderings have to converge on the same state, or a + // slow picker produces a recording that never starts. + armed = true; + if let Some(stream) = pending_portal.take() { + if let Err(()) = begin_stream( + emitter, + &sender, + &frames, + &mut session, + &mut portal_stream, + &mut granted_kind, + stream, + ) { + exit_code = 1; + break; + } } } Ok(Message::Portal(result)) => match *result { Ok(stream) => { - // The fd is consumed by libpipewire; the rest is kept for the - // `stream-started` event, emitted once the format is negotiated. - let portal::PortalStream { - fd, - node_id, - position, - size: logical_size, - restore_token, - } = stream; // The portal's size is in the compositor's coordinate space // and can differ from the negotiated pixel size on a scaled // display. Logged rather than used: cursor positions arrive @@ -593,38 +812,51 @@ fn run( let _ = emitter.emit(&Event::Debug { code: "portal-stream".to_owned(), data: json_map([ - ("nodeId", node_id.into()), - ("logicalWidth", logical_size.map(|(w, _)| w).into()), - ("logicalHeight", logical_size.map(|(_, h)| h).into()), - ("positionX", position.map(|(x, _)| x).into()), - ("positionY", position.map(|(_, y)| y).into()), + ("nodeId", stream.node_id.into()), + ("logicalWidth", stream.size.map(|(w, _)| w).into()), + ("logicalHeight", stream.size.map(|(_, h)| h).into()), + ("positionX", stream.position.map(|(x, _)| x).into()), + ("positionY", stream.position.map(|(_, y)| y).into()), + ( + "sourceKind", + stream.source_kind.map(|kind| kind.as_str()).into(), + ), ]), }); - let forward = sender.clone(); - match shim::Session::start( - fd, - node_id, - Box::new(move |event| { - let _ = forward.send(Message::Stream(event)); - }), - frames.clone(), + // PROTOCOL, NOT DIAGNOSTICS. The picker has been answered — + // which is a different moment from "pixels are flowing", and + // the only one at which an app can start a countdown without + // it running before the user has been asked anything. + let _ = emitter.emit(&Event::SourceSelected { + timestamp_ms: timestamp_ms(), + node_id: stream.node_id, + source_kind: stream.source_kind.map(|kind| kind.as_str().to_owned()), + position_x: stream.position.map(|(x, _)| x), + position_y: stream.position.map(|(_, y)| y), + }); + granted_kind = stream.source_kind; + + if config.defer_start && !armed { + // Hold the grant WITHOUT connecting a pw_stream. mutter + // only enables its capture source on STREAMING, so an + // unconnected session costs the compositor nothing and + // produces no frames to throw away. Connecting it + // inactive instead — OBS's pattern — would expose it to + // WirePlumber's idle-node suspend, which tears down the + // negotiated format after a few seconds and would lose it + // during a countdown. + pending_portal = Some(stream); + } else if let Err(()) = begin_stream( + emitter, + &sender, + &frames, + &mut session, + &mut portal_stream, + &mut granted_kind, + stream, ) { - Ok(started) => { - session = Some(started); - portal_stream = Some(StreamInfo { - node_id, - position, - restore_token, - }); - } - Err(message) => { - let _ = emitter.emit(&Event::Error { - code: "pipewire-connect-failed".to_owned(), - message, - }); - exit_code = 1; - break; - } + exit_code = 1; + break; } } Err(error) => { @@ -655,52 +887,23 @@ fn run( height: format.height, position_x: stream.position.map(|(x, _)| x), position_y: stream.position.map(|(_, y)| y), - restore_token: stream.restore_token, + source_kind: stream + .source_kind + .map(|kind| kind.as_str().to_owned()), }); } - // The encoder cannot be opened until now: its dimensions are the - // negotiated ones, which the compositor picks. Renegotiation - // mid-stream would deliver a second Format; the encoder is left - // alone in that case, because an MP4 cannot change resolution - // mid-file and the alternative — silently starting a new one — - // would lose the recording. - if let Some(path) = config.output_path.as_ref() { - if capture.is_none() { - match Capture::start( - path, - format.width, - format.height, - config.fps, - config.bitrate, - config.forced_encoder, - std::mem::take(&mut audio_sources), - ) { - Ok((started, selection)) => { - let _ = emitter.emit(&Event::EncoderSelection { - video: selection.backend.as_str().to_owned(), - rejected: selection.rejected, - }); - capture = Some(started); - if paused { - // A `pause` that arrived while the portal - // picker was still up applies to the capture - // that picker was for. - if let Some(capture) = capture.as_mut() { - capture.pause(); - } - } - } - Err(message) => { - let _ = emitter.emit(&Event::Error { - code: "encoder-unavailable".to_owned(), - message, - }); - exit_code = 1; - break; - } - } - } else { + // THE ENCODER IS NOT OPENED HERE ANY MORE, and that is the whole + // window-capture fix. `format` is the size of the STREAM, which + // for a window is the size of its monitor: a PipeWire stream + // cannot be resized once negotiated but a window can, so mutter + // pins the stream to the monitor and reports the window's live + // rectangle as SPA_META_VideoCrop instead. Sizing the encoder + // from `format` is what produced window recordings padded out to + // screen size with black. The size now comes from the first frame + // carrying a usable crop — see the FrameReady arm. + if config.output_path.is_some() { + if capture.is_some() { let _ = emitter.emit(&Event::Warning { code: "format-renegotiated".to_owned(), message: format!( diff --git a/electron/native/pipewire-capture/src/portal.rs b/electron/native/pipewire-capture/src/portal.rs index a34160c3d..33ed9f1f0 100644 --- a/electron/native/pipewire-capture/src/portal.rs +++ b/electron/native/pipewire-capture/src/portal.rs @@ -57,13 +57,47 @@ impl CursorMode { } } +/// Which kind of source the compositor actually handed over. +/// +/// This is the ONLY way the app can learn whether it got a window or a whole +/// monitor. `SelectSources` has no parameter naming a source, so a request can +/// never be compared against its grant — the reply is the first and last word. +/// Reported upward so the HUD can name what is really being recorded instead of +/// echoing a choice made in a modal that never reached this process. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceKind { + Monitor, + Window, + Virtual, +} + +impl SourceKind { + fn from_portal(source_type: SourceType) -> Self { + match source_type { + SourceType::Monitor => Self::Monitor, + SourceType::Window => Self::Window, + SourceType::Virtual => Self::Virtual, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Monitor => "monitor", + Self::Window => "window", + Self::Virtual => "virtual", + } + } +} + /// Everything the PipeWire half needs, plus what the helper reports upward. pub struct PortalStream { pub fd: OwnedFd, pub node_id: u32, pub position: Option<(i32, i32)>, pub size: Option<(i32, i32)>, - pub restore_token: Option, + /// `None` when the portal omits it — older backends do, and the spec makes + /// it optional. Absent means "unknown", never "monitor". + pub source_kind: Option, } #[derive(Debug)] @@ -126,10 +160,7 @@ pub async fn cursor_metadata_supported() -> Result { /// ashpd has no `Drop` impl for sessions and holds its D-Bus connection in a /// process-global `OnceLock`, so the portal session stays open until this /// process exits — which is exactly the lifetime we want. -pub async fn negotiate( - restore_token: Option<&str>, - cursor_mode: CursorMode, -) -> Result { +pub async fn negotiate(cursor_mode: CursorMode) -> Result { let proxy = Screencast::new() .await .map_err(|error| failed("cannot reach org.freedesktop.portal.ScreenCast", error))?; @@ -165,11 +196,19 @@ pub async fn negotiate( cursor_mode.to_portal(), types, false, - restore_token, - // The picker is a per-recording interruption otherwise. Persisting - // until the user revokes it means the restore token from a previous - // run can skip it entirely. - PersistMode::ExplicitlyRevoked, + // NO RESTORE TOKEN, AND NOTHING TO PERSIST. This used to replay a + // token from the previous run so the picker would not reappear — + // and that is precisely how "record this window" produced a + // recording of the whole screen. 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 never raised the + // picker again; the app had no way to ask for anything else, + // because `SelectSources` cannot name a source. The picker IS the + // source chooser on Wayland, so suppressing it removed the only + // control the user had. Asking every time is the cost of letting + // them choose at all. + None, + PersistMode::DoNot, ) .await .map_err(|error| failed("SelectSources", error))? @@ -203,6 +242,6 @@ pub async fn negotiate( node_id: stream.pipe_wire_node_id(), position: stream.position(), size: stream.size(), - restore_token: streams.restore_token().map(str::to_owned), + source_kind: stream.source_type().map(SourceKind::from_portal), }) } diff --git a/electron/native/pipewire-capture/src/shim.rs b/electron/native/pipewire-capture/src/shim.rs index 0b30768c8..4e8fecdb8 100644 --- a/electron/native/pipewire-capture/src/shim.rs +++ b/electron/native/pipewire-capture/src/shim.rs @@ -41,6 +41,11 @@ pub struct RawFrame { pub height: i32, pub video_format: u32, pub pts_ns: i64, + pub crop_x: i32, + pub crop_y: i32, + pub crop_width: i32, + pub crop_height: i32, + pub has_crop: i32, } #[repr(C)] @@ -163,6 +168,22 @@ pub struct Frame { /// Compositor monotonic clock in nanoseconds, or -1 when the buffer carried /// no SPA_META_Header. pub pts_ns: i64, + /// The sub-rectangle of `pixels` holding content, from SPA_META_VideoCrop. + /// Defaults to the whole frame, so it is always safe to read. + pub crop: CropRect, + /// The crop is real and narrower than the frame. False covers "no meta", + /// "invalid meta" and "meta covering everything" alike — none of which is a + /// reason to crop, and none of which may be guessed apart. + pub has_crop: bool, +} + +/// A rectangle inside a captured frame, in stream pixels. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct CropRect { + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, } /// A one-slot mailbox between the PipeWire thread and the encoder. @@ -224,6 +245,13 @@ impl FrameMailbox { height: meta.height, video_format: meta.video_format, pts_ns: meta.pts_ns, + crop: CropRect { + x: meta.crop_x, + y: meta.crop_y, + width: meta.crop_width, + height: meta.crop_height, + }, + has_crop: meta.has_crop != 0, }); self.received.fetch_add(1, Ordering::Relaxed); } diff --git a/electron/preload.ts b/electron/preload.ts index 82dd2b409..04b2427ae 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -186,6 +186,12 @@ contextBridge.exposeInMainWorld("electronAPI", { isNativeLinuxCaptureAvailable: () => { return ipcRenderer.invoke("is-native-linux-capture-available"); }, + prepareNativeLinuxRecording: (request: NativeLinuxRecordingRequest) => { + return ipcRenderer.invoke("prepare-native-linux-recording", request); + }, + cancelNativeLinuxPrepare: () => { + return ipcRenderer.invoke("cancel-native-linux-prepare"); + }, startNativeLinuxRecording: (request: NativeLinuxRecordingRequest) => { return ipcRenderer.invoke("start-native-linux-recording", request); }, diff --git a/src/components/ai-edition/v4/RecStage.tsx b/src/components/ai-edition/v4/RecStage.tsx index 39a0dec1a..57a5185f1 100644 --- a/src/components/ai-edition/v4/RecStage.tsx +++ b/src/components/ai-edition/v4/RecStage.tsx @@ -17,6 +17,7 @@ import { useAudioLevelMeter } from "@/hooks/useAudioLevelMeter"; import { useCameraDevices } from "@/hooks/useCameraDevices"; import { useCameraPreviewStream } from "@/hooks/useCameraPreviewStream"; import { useMicrophoneDevices } from "@/hooks/useMicrophoneDevices"; +import { usePortalOwnsSource } from "@/hooks/usePortalOwnsSource"; import styles from "./EditorShellV4.module.css"; interface RecordingPrefsState { @@ -141,7 +142,13 @@ export function RecStage({ const visibleSources = sourceTab === "screen" ? screenSources : windowSources; const cursorHighlight = prefs.cursorCaptureMode === "editable-overlay"; - const sourceLabel = source?.name ?? t("rec.entireScreen"); + // Same answer as the HUD, from the same place. This stage used to decide for + // itself and always showed a picker, so the same build hid the choice on the + // HUD and demanded it here. + const portalOwnsSource = usePortalOwnsSource(); + const sourceLabel = portalOwnsSource + ? t("rec.systemPicker") + : (source?.name ?? t("rec.entireScreen")); return (
@@ -183,20 +190,40 @@ export function RecStage({
-
-
- - {t("rec.source")} + {/* No source row on Linux: `SelectSources` has no parameter naming + a source, so this picker could not steer the capture — it only + raised a second portal dialog, via `desktopCapturer.getSources()`, + whose grant was discarded. The compositor's own picker decides, + and it appears when recording starts. */} + {portalOwnsSource ? ( +
+
+ + {t("rec.source")} +
+ {/* Muted TEXT, not a styled-down button. Keeping the button's + class on a span left it looking pressable — border, pill, + hover state — which promises an interaction that cannot + exist here. This row states what will happen; it is not a + control. */} + {sourceLabel}
- -
+ ) : ( +
+
+ + {t("rec.source")} +
+ +
+ )}
diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 3e108ff17..12d177c14 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -10,6 +10,7 @@ type SelectedSourceChangedListener = Parameters< >[0]; const platformState = vi.hoisted(() => ({ value: "darwin" })); +const linuxHelperAvailable = vi.hoisted(() => ({ value: true })); const resizeCallbacks = vi.hoisted(() => [] as Array); class StubResizeObserver { @@ -132,6 +133,8 @@ vi.mock("@/contexts/I18nContext", () => ({ const translations: Record = { "sourceSelector.defaultSourceName": "Screen", "recording.selectSource": "Please select a source to record", + "recording.systemPicker": "Your system will ask what to share", + "recording.inProgress": "Recording", "tooltips.useVerticalTray": "Use vertical tray", "tooltips.useHorizontalTray": "Use horizontal tray", "audio.enableSystemAudio": "Enable system audio", @@ -193,7 +196,16 @@ function stubElectronAPI(getSelectedSource: Window["electronAPI"]["getSelectedSo granted: true, status: "granted", })), - getPlatform: vi.fn(() => "darwin"), + // Follows the platform under test. Pinned to "darwin" before, which was + // invisible while only `nativeBridgeClient` was consulted for it — and + // silently wrong the moment anything read the platform through here. + getPlatform: vi.fn(() => platformState.value), + // Only the Linux tests read this; the helper being present is what hands + // source selection to the portal. + isNativeLinuxCaptureAvailable: vi.fn(async () => ({ + success: true, + available: linuxHelperAvailable.value, + })), setHudOverlaySize: vi.fn(), setHudOverlayIgnoreMouseEvents: vi.fn(), beginHudOverlayDrag: vi.fn(), @@ -266,6 +278,7 @@ function resetLaunchMocks() { i18nState.value.acceptSystemLocaleSuggestion.mockClear(); i18nState.value.dismissSystemLocaleSuggestion.mockClear(); i18nState.value.resolveSystemLocaleSuggestion.mockClear(); + linuxHelperAvailable.value = true; stubElectronAPI(vi.fn(async () => null)); } @@ -404,6 +417,99 @@ describe("LaunchWindow record button", () => { expect(window.electronAPI.setHudOverlayIgnoreMouseEvents).toHaveBeenLastCalledWith(false); }); }); + + // The ScreenCast portal has no parameter naming a source, so nothing this + // picker returned could ever reach the capture — it only raised a second + // portal dialog whose grant was discarded. On Linux the compositor's own + // picker, shown when recording starts, is the only thing that decides. + it("hides the in-app source button on Linux", async () => { + platformState.value = "linux"; + + renderLaunchWindow(); + + // The helper-availability answer arrives asynchronously, so the button is + // still there on the first frame — wait for it to go rather than race it. + await waitFor(() => { + expect(screen.queryByTestId("launch-source-selector-button")).toBeNull(); + }); + }); + + it("records straight away on Linux instead of demanding a source that cannot be selected", async () => { + platformState.value = "linux"; + + renderLaunchWindow(); + + const recordButton = await screen.findByTestId("launch-record-button"); + expect(recordButton).toBeEnabled(); + await waitFor(() => { + expect(recordButton).toHaveAttribute("title", "Your system will ask what to share"); + }); + + fireEvent.click(recordButton); + + await waitFor(() => { + expect(recorderState.value.toggleRecording).toHaveBeenCalledTimes(1); + }); + expect(window.electronAPI.openSourceSelector).not.toHaveBeenCalled(); + }); + + // The portal reports a KIND, never a window title, so naming the source here + // could only ever be a guess — and guessing is what put a window's name on a + // recording of the whole screen. + /** + * Without the helper the recorder falls back to Chromium's capture, which + * DOES consume a source id. Hiding the picker there would leave no way to + * start a recording at all. + */ + it("keeps the in-app source button on Linux when the native helper is missing", async () => { + platformState.value = "linux"; + linuxHelperAvailable.value = false; + + renderLaunchWindow(); + + expect(await screen.findByTestId("launch-source-selector-button")).toBeInTheDocument(); + expect(screen.getByTestId("launch-record-button")).toHaveAttribute( + "title", + "Please select a source to record", + ); + }); + + /** + * `portalOwnsSource` is resolved over IPC, so for a moment after mount it + * still reads false on Linux. A Record click landing in that window opened a + * selector the main process refuses — and the click used to be swallowed, + * doing nothing at all. The refusal is authoritative and answers immediately, + * so it starts the recording instead. + */ + it("records when the picker refuses because the portal owns the choice", async () => { + platformState.value = "linux"; + // Forces the click down the open-the-selector path, as an unresolved + // portal check does. + linuxHelperAvailable.value = false; + window.electronAPI.openSourceSelector = vi.fn(async () => ({ + opened: false, + reason: "portal-owns-selection", + })) as unknown as Window["electronAPI"]["openSourceSelector"]; + + renderLaunchWindow(); + fireEvent.click(await screen.findByTestId("launch-record-button")); + + await waitFor(() => { + expect(recorderState.value.toggleRecording).toHaveBeenCalledTimes(1); + }); + }); + + it("does not name a source while recording on Linux", async () => { + platformState.value = "linux"; + recorderState.value.recording = true; + + renderLaunchWindow(); + + const recordButton = await screen.findByTestId("launch-record-button"); + await waitFor(() => { + expect(recordButton).toHaveAttribute("title", "Recording"); + }); + }); }); /** jsdom reports zero layout, so fake a rendered box for the elements we measure. */ diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index ce0c0c57f..71d9eda27 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -5,6 +5,7 @@ import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences" import { nativeBridgeClient } from "@/native"; import { type CameraDevice, useCameraDevices } from "../../hooks/useCameraDevices"; import { type MicrophoneDevice, useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; +import { usePortalOwnsSource } from "../../hooks/usePortalOwnsSource"; import { useScreenRecorder } from "../../hooks/useScreenRecorder"; import { requestCameraAccess } from "../../lib/requestCameraAccess"; import { @@ -117,6 +118,12 @@ export function LaunchWindow() { ); const [supportsCursorModeToggle, setSupportsCursorModeToggle] = useState(false); const [isLinuxHud, setIsLinuxHud] = useState(false); + /** + * Narrower than [`isLinuxHud`] on purpose: without the helper the recorder + * falls back to Chromium's capture, which DOES take a source id, so the + * in-app picker has to stay for that case. + */ + const portalOwnsSource = usePortalOwnsSource(); const isVertical = trayLayout === "vertical"; const isPopoverOpen = isLanguageMenuOpen || isDeviceSettingsOpen; @@ -499,13 +506,27 @@ export function LaunchWindow() { if (saving) { return; } - const sourceSelected = sourceSelectedOverride ?? hasSelectedSource; + // Linux never detours through the in-app picker: there is nothing for + // it to select, and waiting for a selection that can never arrive left + // the record button opening a modal instead of recording. + const sourceSelected = portalOwnsSource || (sourceSelectedOverride ?? hasSelectedSource); if (!sourceSelected && !recording) { recordAfterSourceSelectionRef.current = true; void openSourceSelector() .then((result) => { - if (!result.opened) { - recordAfterSourceSelectionRef.current = false; + if (result.opened) { + return; + } + recordAfterSourceSelectionRef.current = false; + // The main process is the authority on who owns the choice, + // and it answers synchronously. `portalOwnsSource` is resolved + // over IPC, so for a moment after mount it still reads false — + // and a Record click landing in that window used to open a + // selector that refused, leaving the click doing nothing at + // all. Honouring the refusal starts the recording instead, + // whatever the local state has caught up to. + if (result.reason === "portal-owns-selection" && !recording) { + toggleRecording(); } }) .catch(() => { @@ -516,7 +537,7 @@ export function LaunchWindow() { toggleRecording(); }, - [hasSelectedSource, openSourceSelector, recording, saving, toggleRecording], + [hasSelectedSource, portalOwnsSource, openSourceSelector, recording, saving, toggleRecording], ); const handleRecordClick = useCallback(() => handleRecordButtonClick(), [handleRecordButtonClick]); @@ -721,11 +742,20 @@ export function LaunchWindow() { dismissSoftwareEncoderFallbackNotice(); }, [dismissSoftwareEncoderFallbackNotice]); + // On Linux the ScreenCast portal owns the choice, so there is no in-app + // selection to name and none to demand: the idle label says what pressing + // record will do, and the recording label stays neutral because the portal + // reports a KIND, never a window title. Naming a source we were never told + // is what put a window's name on a full-screen recording. const recordLabel = saving ? t("recording.saving") - : hasSelectedSource || recording - ? selectedSource - : t("recording.selectSource"); + : portalOwnsSource + ? recording + ? t("recording.inProgress") + : t("recording.systemPicker") + : hasSelectedSource || recording + ? selectedSource + : t("recording.selectSource"); // Stable identity, or the panel's memo boundary would break on every parent // render — including the once-a-second one during a recording. @@ -794,12 +824,21 @@ export function LaunchWindow() { onClick={toggleTrayLayout} /> - + {/* No source button on Linux: `SelectSources` has no parameter + naming a source, so nothing this picker returned could reach + the capture. It raised a second portal dialog of its own — + via `desktopCapturer.getSources()` — whose grant was then + discarded, which is why picking a window here changed + nothing. The compositor's picker is the only one that + decides, and it appears when recording starts. */} + {!portalOwnsSource && ( + + )} diff --git a/src/hooks/usePortalOwnsSource.ts b/src/hooks/usePortalOwnsSource.ts new file mode 100644 index 000000000..8971d9075 --- /dev/null +++ b/src/hooks/usePortalOwnsSource.ts @@ -0,0 +1,41 @@ +import { useEffect, useState } from "react"; +import { portalOwnsSourceSelection } from "@/lib/nativeLinuxRecording"; + +/** + * Whether the ScreenCast portal — not the app — chooses what gets recorded. + * + * THE SINGLE SOURCE OF TRUTH FOR EVERY SOURCE-PICKER SURFACE. There is more than + * one way into a recording (the HUD, and the editor's Rec stage), and each one + * used to answer this question for itself. They disagreed: the HUD dropped its + * picker on Linux while the Rec stage kept opening one, so the same build both + * hid the choice and demanded it depending on where you started from. + * + * `false` until the answer arrives, so a surface renders its picker by default + * and only withdraws it once Linux + a working PipeWire helper are confirmed. + * The wrong way round would flash a picker on every platform. + * + * @see portalOwnsSourceSelection for why the answer cannot be `platform === "linux"`. + */ +export function usePortalOwnsSource(): boolean { + const [portalOwnsSource, setPortalOwnsSource] = useState(false); + + useEffect(() => { + let cancelled = false; + + void (async () => { + if (!window.electronAPI) { + return; + } + const owns = await portalOwnsSourceSelection(window.electronAPI); + if (!cancelled) { + setPortalOwnsSource(owns); + } + })(); + + return () => { + cancelled = true; + }; + }, []); + + return portalOwnsSource; +} diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 1da548b14..f5ec47faa 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -3,7 +3,10 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { useScopedT } from "@/contexts/I18nContext"; import { MIC_GAIN_BOOST, mixAudioTracks } from "@/lib/audioMix"; -import type { NativeLinuxRecordingRequest } from "@/lib/nativeLinuxRecording"; +import { + type NativeLinuxRecordingRequest, + portalOwnsSourceSelection, +} from "@/lib/nativeLinuxRecording"; import { type NativeMacRecordingRequest, parseMacDisplayIdFromSourceId, @@ -1239,7 +1242,45 @@ export function useScreenRecorder(): UseScreenRecorderReturn { * which has no upper bound. That is also what makes the returned instant * a trustworthy t=0 for the webcam offset below. */ - const startNativeLinuxRecordingIfAvailable = async (countdownRunToken?: number) => { + /** + * The helper request, built in ONE place because it is now sent TWICE: once + * to negotiate the portal before the countdown, once to start recording after + * it. The two must describe the same capture — the session armed at the end + * is the one negotiated at the start, so a divergence in audio or cursor + * settings would record something the second call never asked for. + */ + const buildNativeLinuxRequest = (recordingId?: number): NativeLinuxRecordingRequest => ({ + ...(recordingId === undefined ? {} : { recordingId }), + video: { + // No bitrate on purpose. TARGET_WIDTH/HEIGHT are the app's 4K ceiling, + // not the capture size — on Wayland nobody knows that until the portal + // has negotiated it, and the user may well have picked a single window. + // Sending computeBitrate() of the ceiling asked for 76.5 Mbit/s for a + // 1080p capture. The helper derives it from the size it actually got. + fps: TARGET_FRAME_RATE, + }, + audio: { + system: { enabled: systemAudioEnabled }, + microphone: { + enabled: microphoneEnabled, + // The device LABEL, not the id. Chromium's deviceId is an opaque + // per-origin hash that means nothing to PipeWire, whereas on a + // PipeWire system the label IS the node's `node.description` — which + // is what the helper matches against the graph it enumerates. + // Sending nothing here is what made a user who picked their built-in + // microphone get the empty headphone jack recorded, because the + // helper then fell back to the session default source. + ...(microphoneDeviceName ? { deviceName: microphoneDeviceName } : {}), + gain: MIC_GAIN_BOOST, + }, + }, + cursor: { mode: cursorCaptureMode }, + }); + + const startNativeLinuxRecordingIfAvailable = async ( + countdownRunToken?: number, + preparedRecordingId?: number | null, + ) => { try { const platform = window.electronAPI.getPlatform(); if (platform !== "linux") { @@ -1265,7 +1306,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return true; } - const activeRecordingId = Date.now(); + // Reuse the prepared recording's id, or the main process cannot match + // the session it is holding to the recording being started and would + // discard it — negotiating a second portal session, and raising a + // second picker, for a grant it already had. + const activeRecordingId = preparedRecordingId ?? Date.now(); let nativeWebcamRecorder: RecorderHandle | null = null; let nativeWebcamRecorderStartedAtMs: number | null = null; if (webcamEnabled) { @@ -1285,36 +1330,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } } - const request: NativeLinuxRecordingRequest = { - recordingId: activeRecordingId, - video: { - // No bitrate on purpose. TARGET_WIDTH/HEIGHT are the app's 4K - // ceiling, not the capture size — on Wayland nobody knows that - // until the portal has negotiated it, and the user may well - // have picked a single window. Sending computeBitrate() of the - // ceiling asked for 76.5 Mbit/s for a 1080p capture. The helper - // derives it from the size it actually got. - fps: TARGET_FRAME_RATE, - }, - audio: { - system: { enabled: systemAudioEnabled }, - microphone: { - enabled: microphoneEnabled, - // The device LABEL, not the id. Chromium's deviceId is an - // opaque per-origin hash that means nothing to PipeWire, - // whereas on a PipeWire system the label IS the node's - // `node.description` — which is what the helper matches - // against the graph it enumerates. Sending nothing here is - // what made a user who picked their built-in microphone - // get the empty headphone jack recorded, because the - // helper then fell back to the session default source. - ...(microphoneDeviceName ? { deviceName: microphoneDeviceName } : {}), - gain: MIC_GAIN_BOOST, - }, - }, - cursor: { mode: cursorCaptureMode }, - }; - const result = await window.electronAPI.startNativeLinuxRecording(request); + const result = await window.electronAPI.startNativeLinuxRecording( + buildNativeLinuxRequest(activeRecordingId), + ); if (!result.success || !result.recordingId) { throw new Error(result.error ?? "Native Linux capture failed."); } @@ -1366,11 +1384,19 @@ export function useScreenRecorder(): UseScreenRecorderReturn { console.warn("Failed to read selected source before countdown:", error); } + // Resolved before the liveness check below so every await stays ahead of it. + const portalOwnsSource = await portalOwnsSourceSelection(window.electronAPI); + if (!isCountdownRunActive(runId)) { return; } - if (!selectedSource) { + // The countdown's OWN source gate, distinct from the one in + // `startRecording`. On Linux the portal has not been asked anything yet — + // its picker is raised when capture starts, several steps after this — so + // there is nothing to have selected, and refusing here blocked recording + // outright once the in-app picker was removed. + if (!selectedSource && !portalOwnsSource) { if (countdownRunId.current === runId) { setCountdownActive(false); } @@ -1393,6 +1419,38 @@ export function useScreenRecorder(): UseScreenRecorderReturn { console.warn("Failed to preflight macOS cursor accessibility before countdown:", error); } + // THE PICKER GOES BEFORE THE COUNTDOWN. On Wayland the compositor's dialog + // is the source chooser, and it only appears once the portal session is + // started — so counting down first meant counting down before the user had + // been asked anything, then freezing the overlay while they read a dialog + // that has no time limit. Kooha does the same in the same order: session, + // then timer, then play. + // + // Best-effort on purpose. A failure here is not a failure to record: the + // start below still negotiates the portal itself, which is exactly the + // behaviour that shipped before this existed. + let preparedRecordingId: number | null = null; + if (portalOwnsSource) { + try { + const prepared = await window.electronAPI.prepareNativeLinuxRecording( + buildNativeLinuxRequest(), + ); + if (prepared.success && typeof prepared.recordingId === "number") { + preparedRecordingId = prepared.recordingId; + } else if (prepared.reason) { + console.info(`Native Linux capture was not prepared: ${prepared.reason}`); + } + } catch (error) { + console.warn("Failed to prepare the native Linux capture:", error); + } + // The user can dismiss the picker, or answer it slower than they change + // their mind about recording at all. + if (!isCountdownRunActive(runId)) { + void window.electronAPI.cancelNativeLinuxPrepare?.(); + return; + } + } + if (!isCountdownRunActive(runId)) { return; } @@ -1436,37 +1494,54 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return; } - await startRecording(runId); + await startRecording(runId, preparedRecordingId); } finally { if (!overlayHiddenBeforeStart && countdownRunId.current === runId) { setCountdownActive(false); await safeHideCountdownOverlay(runId); } + // Unconditional, and safe: a start that used the prepared session + // already claimed it, so this is a no-op there. Every OTHER way out of + // this block — cancelled countdown, an overlay that threw, a source + // that vanished — would otherwise leave a live ScreenCast session and + // the compositor's sharing indicator up with nothing recording. + if (portalOwnsSource) { + void window.electronAPI.cancelNativeLinuxPrepare?.(); + } } }; - const startRecording = async (countdownRunToken?: number) => { + const startRecording = async ( + countdownRunToken?: number, + preparedRecordingId?: number | null, + ) => { try { - const selectedSource = await window.electronAPI.getSelectedSource(); - if (!selectedSource) { - alert(t("recording.selectSource")); + if (!isCountdownRunActive(countdownRunToken)) { + teardownMedia(); return; } - if (!isCountdownRunActive(countdownRunToken)) { - teardownMedia(); + // BEFORE THE SOURCE GATE, on purpose. On Wayland the portal raises its + // own picker and is the only thing that can choose a source, so there + // is nothing for the app to have selected — and the helper needs no + // `selectedSource` to run. Gating here demanded an answer to a + // question this platform never asks the app. It returns false when the + // native helper is missing, and the browser fallback below does need a + // source, so the gate still guards the path that uses one. + if (await startNativeLinuxRecordingIfAvailable(countdownRunToken, preparedRecordingId)) { return; } - if (await startNativeWindowsRecordingIfAvailable(selectedSource, countdownRunToken)) { + const selectedSource = await window.electronAPI.getSelectedSource(); + if (!selectedSource) { + alert(t("recording.selectSource")); return; } - if (await startNativeMacRecordingIfAvailable(selectedSource, countdownRunToken)) { + + if (await startNativeWindowsRecordingIfAvailable(selectedSource, countdownRunToken)) { return; } - // No `selectedSource`: on Wayland the portal picker is the source of - // truth and Electron's entry is a placeholder. - if (await startNativeLinuxRecordingIfAvailable(countdownRunToken)) { + if (await startNativeMacRecordingIfAvailable(selectedSource, countdownRunToken)) { return; } diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index 16b826989..aead94336 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -50,5 +50,11 @@ "locale": { "name": "عربي", "short": "AR" + }, + "recordingSource": { + "window": "نافذة", + "screen": "شاشة", + "virtual": "شاشة افتراضية", + "unknown": "تسجيل الشاشة" } } diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index 81f5fe6ca..fb3d6875a 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -43,7 +43,8 @@ "cameraDisconnected": "تم فصل كاميرا الويب.", "cameraNotFound": "لم يتم العثور على كاميرا.", "permissionDenied": "تم رفض إذن التسجيل. يرجى السماح بتسجيل الشاشة.", - "accessibilityAllowAndRetry": "اسمح بوصول تسهيلات الاستخدام لـ OpenScreen، ثم اضغط على التسجيل مرة أخرى لبدء العد التنازلي." + "accessibilityAllowAndRetry": "اسمح بوصول تسهيلات الاستخدام لـ OpenScreen، ثم اضغط على التسجيل مرة أخرى لبدء العد التنازلي.", + "selectSource": "يرجى تحديد مصدر للتسجيل" }, "emptyState": { "title": "لا يوجد مشروع مفتوح", @@ -98,6 +99,7 @@ }, "rec": { "source": "المصدر", + "systemPicker": "سيسألك النظام عمّا تريد مشاركته", "systemAudio": "صوت النظام", "microphone": "الميكروفون", "camera": "الكاميرا", diff --git a/src/i18n/locales/ar/launch.json b/src/i18n/locales/ar/launch.json index 08cb8a4e5..ccfbe60e1 100644 --- a/src/i18n/locales/ar/launch.json +++ b/src/i18n/locales/ar/launch.json @@ -65,6 +65,8 @@ }, "recording": { "selectSource": "يرجى تحديد مصدر للتسجيل", + "systemPicker": "سيسألك النظام عمّا تريد مشاركته", + "inProgress": "جارٍ التسجيل", "saving": "جاري الحفظ..." }, "language": "اللغة", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 6d77a0121..8dd5e6c86 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -50,5 +50,11 @@ "locale": { "name": "English", "short": "EN" + }, + "recordingSource": { + "window": "Window", + "screen": "Screen", + "virtual": "Virtual display", + "unknown": "Screen recording" } } diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index ae85f0954..fe7fe12f9 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -43,7 +43,8 @@ "cameraDisconnected": "Webcam disconnected.", "cameraNotFound": "Camera not found.", "permissionDenied": "Recording permission denied. Please allow screen recording.", - "accessibilityAllowAndRetry": "Allow Accessibility access for OpenScreen, then press record again to start the countdown." + "accessibilityAllowAndRetry": "Allow Accessibility access for OpenScreen, then press record again to start the countdown.", + "selectSource": "Please select a source to record" }, "emptyState": { "title": "No project open", @@ -98,6 +99,7 @@ }, "rec": { "source": "Source", + "systemPicker": "Your system will ask what to share", "systemAudio": "System audio", "microphone": "Microphone", "camera": "Camera", diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index 02b8ca626..8f7e8b7f5 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -69,6 +69,8 @@ }, "recording": { "selectSource": "Please select a source to record", + "systemPicker": "Your system will ask what to share", + "inProgress": "Recording", "saving": "Saving..." }, "language": "Language", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 82da2527a..0260dae04 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -50,5 +50,11 @@ "locale": { "name": "Español", "short": "ES" + }, + "recordingSource": { + "window": "Ventana", + "screen": "Pantalla", + "virtual": "Pantalla virtual", + "unknown": "Grabación de pantalla" } } diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index a5773c4b7..efc550fa7 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -35,7 +35,8 @@ "cameraDisconnected": "Cámara web desconectada.", "cameraNotFound": "Cámara no encontrada.", "permissionDenied": "Permiso de grabación denegado. Por favor permite la grabación de pantalla.", - "accessibilityAllowAndRetry": "Permite el acceso de accesibilidad para OpenScreen y luego pulsa grabar de nuevo para iniciar la cuenta atrás." + "accessibilityAllowAndRetry": "Permite el acceso de accesibilidad para OpenScreen y luego pulsa grabar de nuevo para iniciar la cuenta atrás.", + "selectSource": "Por favor selecciona una fuente para grabar" }, "loadingVideo": "Cargando video...", "loadingEditor": "Cargando editor...", @@ -98,6 +99,7 @@ }, "rec": { "source": "Fuente", + "systemPicker": "El sistema te preguntará qué compartir", "systemAudio": "Audio del sistema", "microphone": "Micrófono", "camera": "Cámara", diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index c5fb8d10e..ac0aa3cd1 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -69,6 +69,8 @@ }, "recording": { "selectSource": "Por favor selecciona una fuente para grabar", + "systemPicker": "El sistema te preguntará qué compartir", + "inProgress": "Grabando", "saving": "Guardando..." }, "language": "Idioma", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 47955acd2..14a6ef89a 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -50,5 +50,11 @@ "locale": { "name": "Français", "short": "FR" + }, + "recordingSource": { + "window": "Fenêtre", + "screen": "Écran", + "virtual": "Écran virtuel", + "unknown": "Enregistrement d'écran" } } diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index ba0afda62..152b139d9 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -41,7 +41,8 @@ "cameraDisconnected": "Webcam déconnectée.", "cameraNotFound": "Caméra introuvable.", "permissionDenied": "Permission d'enregistrement refusée. Veuillez autoriser l'enregistrement d'écran.", - "accessibilityAllowAndRetry": "Autorisez l'accès Accessibilité pour OpenScreen, puis appuyez de nouveau sur enregistrer pour lancer le compte à rebours." + "accessibilityAllowAndRetry": "Autorisez l'accès Accessibilité pour OpenScreen, puis appuyez de nouveau sur enregistrer pour lancer le compte à rebours.", + "selectSource": "Veuillez sélectionner une source à enregistrer" }, "loadingVideo": "Chargement de la vidéo...", "loadingEditor": "Chargement de l'éditeur...", @@ -98,6 +99,7 @@ }, "rec": { "source": "Source", + "systemPicker": "Le système vous demandera quoi partager", "systemAudio": "Audio système", "microphone": "Microphone", "camera": "Caméra", diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json index 5c49eafd1..331e54c5a 100644 --- a/src/i18n/locales/fr/launch.json +++ b/src/i18n/locales/fr/launch.json @@ -69,6 +69,8 @@ }, "recording": { "selectSource": "Veuillez sélectionner une source à enregistrer", + "systemPicker": "Le système vous demandera quoi partager", + "inProgress": "Enregistrement en cours", "saving": "Sauvegarde..." }, "language": "Langue", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 728b04e4f..a7e52d874 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -50,5 +50,11 @@ "locale": { "name": "Italiano", "short": "IT" + }, + "recordingSource": { + "window": "Finestra", + "screen": "Schermo", + "virtual": "Schermo virtuale", + "unknown": "Registrazione schermo" } } diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index 926b5638c..8462d90a4 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -43,7 +43,8 @@ "cameraDisconnected": "Webcam disconnessa.", "cameraNotFound": "Fotocamera non trovata.", "permissionDenied": "Autorizzazione di registrazione negata. Consenti la registrazione dello schermo.", - "accessibilityAllowAndRetry": "Consenti l'accesso all'accessibilità per OpenScreen, poi premi di nuovo registra per avviare il conto alla rovescia." + "accessibilityAllowAndRetry": "Consenti l'accesso all'accessibilità per OpenScreen, poi premi di nuovo registra per avviare il conto alla rovescia.", + "selectSource": "Seleziona una sorgente da registrare" }, "emptyState": { "title": "Nessun progetto aperto", @@ -98,6 +99,7 @@ }, "rec": { "source": "Sorgente", + "systemPicker": "Il sistema ti chiederà cosa condividere", "systemAudio": "Audio di sistema", "microphone": "Microfono", "camera": "Fotocamera", diff --git a/src/i18n/locales/it/launch.json b/src/i18n/locales/it/launch.json index 7136316a1..118b4bdd0 100644 --- a/src/i18n/locales/it/launch.json +++ b/src/i18n/locales/it/launch.json @@ -69,6 +69,8 @@ }, "recording": { "selectSource": "Seleziona una sorgente da registrare", + "systemPicker": "Il sistema ti chiederà cosa condividere", + "inProgress": "Registrazione in corso", "saving": "Salvataggio..." }, "language": "Lingua", diff --git a/src/i18n/locales/ja-JP/common.json b/src/i18n/locales/ja-JP/common.json index 53b684bb1..f2a063f51 100644 --- a/src/i18n/locales/ja-JP/common.json +++ b/src/i18n/locales/ja-JP/common.json @@ -50,5 +50,11 @@ "locale": { "name": "日本語", "short": "JA" + }, + "recordingSource": { + "window": "ウィンドウ", + "screen": "画面", + "virtual": "仮想ディスプレイ", + "unknown": "画面録画" } } diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index 7f83fcc59..0ec709cce 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -43,7 +43,8 @@ "permissionDenied": "録画の権限が拒否されました。画面録画を許可してください。", "cameraDisconnected": "ウェブカメラが切断されました。", "cameraNotFound": "カメラが見つかりません。", - "accessibilityAllowAndRetry": "OpenScreenにアクセシビリティアクセスを許可してから、もう一度録画を押してカウントダウンを開始してください。" + "accessibilityAllowAndRetry": "OpenScreenにアクセシビリティアクセスを許可してから、もう一度録画を押してカウントダウンを開始してください。", + "selectSource": "録画するソースを選択してください" }, "emptyState": { "title": "プロジェクトが開かれていません", @@ -98,6 +99,7 @@ }, "rec": { "source": "ソース", + "systemPicker": "共有する対象はシステムが確認します", "systemAudio": "システム音声", "microphone": "マイク", "camera": "カメラ", diff --git a/src/i18n/locales/ja-JP/launch.json b/src/i18n/locales/ja-JP/launch.json index bb564e44b..6c69b0f16 100644 --- a/src/i18n/locales/ja-JP/launch.json +++ b/src/i18n/locales/ja-JP/launch.json @@ -69,6 +69,8 @@ }, "recording": { "selectSource": "録画するソースを選択してください", + "systemPicker": "共有する対象はシステムが確認します", + "inProgress": "録画中", "saving": "保存中..." }, "language": "言語", diff --git a/src/i18n/locales/ko-KR/common.json b/src/i18n/locales/ko-KR/common.json index 612f08bef..7c1e5e8c3 100644 --- a/src/i18n/locales/ko-KR/common.json +++ b/src/i18n/locales/ko-KR/common.json @@ -50,5 +50,11 @@ "locale": { "name": "한국어", "short": "KO" + }, + "recordingSource": { + "window": "창", + "screen": "화면", + "virtual": "가상 디스플레이", + "unknown": "화면 녹화" } } diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index 6e2bc4659..f6abeea22 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -43,7 +43,8 @@ "permissionDenied": "녹화 권한이 거부되었습니다. 화면 녹화를 허용해 주세요.", "cameraDisconnected": "웹캠 연결이 끊어졌습니다.", "cameraNotFound": "카메라를 찾을 수 없습니다.", - "accessibilityAllowAndRetry": "OpenScreen의 손쉬운 사용 접근을 허용한 다음, 카운트다운을 시작하려면 다시 녹화를 누르세요." + "accessibilityAllowAndRetry": "OpenScreen의 손쉬운 사용 접근을 허용한 다음, 카운트다운을 시작하려면 다시 녹화를 누르세요.", + "selectSource": "녹화할 소스를 선택해 주세요" }, "emptyState": { "title": "열린 프로젝트 없음", @@ -98,6 +99,7 @@ }, "rec": { "source": "소스", + "systemPicker": "공유할 대상을 시스템이 묻습니다", "systemAudio": "시스템 오디오", "microphone": "마이크", "camera": "카메라", diff --git a/src/i18n/locales/ko-KR/launch.json b/src/i18n/locales/ko-KR/launch.json index bd297c0c7..62bfc1e04 100644 --- a/src/i18n/locales/ko-KR/launch.json +++ b/src/i18n/locales/ko-KR/launch.json @@ -69,6 +69,8 @@ }, "recording": { "selectSource": "녹화할 소스를 선택해 주세요", + "systemPicker": "공유할 대상을 시스템이 묻습니다", + "inProgress": "녹화 중", "saving": "저장 중..." }, "language": "언어", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index ee06d2391..477b7699e 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -50,5 +50,11 @@ "locale": { "name": "Português Brasileiro", "short": "PT-BR" + }, + "recordingSource": { + "window": "Janela", + "screen": "Tela", + "virtual": "Tela virtual", + "unknown": "Gravação de tela" } } diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index bf26c473c..71af170d5 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -43,7 +43,8 @@ "cameraDisconnected": "Webcam desconectada.", "cameraNotFound": "Câmera não encontrada.", "permissionDenied": "Permissão de gravação negada. Por favor, permita a gravação de tela.", - "accessibilityAllowAndRetry": "Permita o acesso de Acessibilidade para o OpenScreen e pressione gravar novamente para iniciar a contagem regressiva." + "accessibilityAllowAndRetry": "Permita o acesso de Acessibilidade para o OpenScreen e pressione gravar novamente para iniciar a contagem regressiva.", + "selectSource": "Por favor, selecione uma fonte para gravar" }, "emptyState": { "title": "Nenhum projeto aberto", @@ -98,6 +99,7 @@ }, "rec": { "source": "Fonte", + "systemPicker": "O sistema perguntará o que compartilhar", "systemAudio": "Áudio do sistema", "microphone": "Microfone", "camera": "Câmera", diff --git a/src/i18n/locales/pt-BR/launch.json b/src/i18n/locales/pt-BR/launch.json index 1470d4cce..8dd833584 100644 --- a/src/i18n/locales/pt-BR/launch.json +++ b/src/i18n/locales/pt-BR/launch.json @@ -69,6 +69,8 @@ }, "recording": { "selectSource": "Por favor, selecione uma fonte para gravar", + "systemPicker": "O sistema perguntará o que compartilhar", + "inProgress": "Gravando", "saving": "Salvando..." }, "language": "Idioma", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 6e2d97186..4838c0989 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -50,5 +50,11 @@ "locale": { "name": "Русский", "short": "RU" + }, + "recordingSource": { + "window": "Окно", + "screen": "Экран", + "virtual": "Виртуальный экран", + "unknown": "Запись экрана" } } diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index ccd84010e..451c4d062 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -43,7 +43,8 @@ "cameraDisconnected": "Веб-камера отключена.", "cameraNotFound": "Камера не найдена.", "permissionDenied": "Разрешение на запись запрещено. Пожалуйста, разрешите запись экрана.", - "accessibilityAllowAndRetry": "Разрешите OpenScreen доступ к Универсальному доступу, затем снова нажмите запись, чтобы начать обратный отсчет." + "accessibilityAllowAndRetry": "Разрешите OpenScreen доступ к Универсальному доступу, затем снова нажмите запись, чтобы начать обратный отсчет.", + "selectSource": "Пожалуйста, выберите источник для записи" }, "emptyState": { "title": "Нет открытых проектов", @@ -98,6 +99,7 @@ }, "rec": { "source": "Источник", + "systemPicker": "Система спросит, чем поделиться", "systemAudio": "Системный звук", "microphone": "Микрофон", "camera": "Камера", diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json index 7836e4b87..72d96c22d 100644 --- a/src/i18n/locales/ru/launch.json +++ b/src/i18n/locales/ru/launch.json @@ -65,6 +65,8 @@ }, "recording": { "selectSource": "Пожалуйста, выберите источник для записи", + "systemPicker": "Система спросит, чем поделиться", + "inProgress": "Идёт запись", "saving": "Сохранение..." }, "language": "Язык", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index cdac32a81..0282bf0e5 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -50,5 +50,11 @@ "locale": { "name": "Türkçe", "short": "TR" + }, + "recordingSource": { + "window": "Pencere", + "screen": "Ekran", + "virtual": "Sanal ekran", + "unknown": "Ekran kaydı" } } diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index cc11d3c00..becba505e 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -35,7 +35,8 @@ "permissionDenied": "Kayıt izni reddedildi. Lütfen ekran kaydına izin verin.", "cameraDisconnected": "Webcam bağlantısı kesildi.", "cameraNotFound": "Kamera bulunamadı.", - "accessibilityAllowAndRetry": "OpenScreen için Erişilebilirlik erişimine izin verin, ardından geri sayımı başlatmak için tekrar kayda basın." + "accessibilityAllowAndRetry": "OpenScreen için Erişilebilirlik erişimine izin verin, ardından geri sayımı başlatmak için tekrar kayda basın.", + "selectSource": "Lütfen kayıt için bir kaynak seçin" }, "loadingVideo": "Video yükleniyor...", "loadingEditor": "Editör yükleniyor...", @@ -98,6 +99,7 @@ }, "rec": { "source": "Kaynak", + "systemPicker": "Sisteminiz neyi paylaşacağınızı soracak", "systemAudio": "Sistem sesi", "microphone": "Mikrofon", "camera": "Kamera", diff --git a/src/i18n/locales/tr/launch.json b/src/i18n/locales/tr/launch.json index 05e415afb..025370d22 100644 --- a/src/i18n/locales/tr/launch.json +++ b/src/i18n/locales/tr/launch.json @@ -69,6 +69,8 @@ }, "recording": { "selectSource": "Lütfen kayıt için bir kaynak seçin", + "systemPicker": "Sisteminiz neyi paylaşacağınızı soracak", + "inProgress": "Kaydediliyor", "saving": "Kaydediliyor..." }, "language": "Dil", diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index f9f9fd067..2ea8e3258 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -50,5 +50,11 @@ "locale": { "name": "Tiếng Việt", "short": "VI" + }, + "recordingSource": { + "window": "Cửa sổ", + "screen": "Màn hình", + "virtual": "Màn hình ảo", + "unknown": "Ghi màn hình" } } diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index ba7acfc61..8fa562492 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -43,7 +43,8 @@ "cameraDisconnected": "Webcam bị ngắt kết nối.", "cameraNotFound": "Không tìm thấy máy ảnh.", "permissionDenied": "Quyền ghi hình bị từ chối. Vui lòng cho phép ghi màn hình.", - "accessibilityAllowAndRetry": "Cho phép OpenScreen truy cập Trợ năng, sau đó nhấn ghi lại để bắt đầu đếm ngược." + "accessibilityAllowAndRetry": "Cho phép OpenScreen truy cập Trợ năng, sau đó nhấn ghi lại để bắt đầu đếm ngược.", + "selectSource": "Vui lòng chọn một nguồn để ghi" }, "emptyState": { "title": "Không có dự án nào được mở", @@ -98,6 +99,7 @@ }, "rec": { "source": "Nguồn", + "systemPicker": "Hệ thống sẽ hỏi bạn muốn chia sẻ gì", "systemAudio": "Âm thanh hệ thống", "microphone": "Micro", "camera": "Máy ảnh", diff --git a/src/i18n/locales/vi/launch.json b/src/i18n/locales/vi/launch.json index e1488e425..c76651888 100644 --- a/src/i18n/locales/vi/launch.json +++ b/src/i18n/locales/vi/launch.json @@ -65,6 +65,8 @@ }, "recording": { "selectSource": "Vui lòng chọn một nguồn để ghi", + "systemPicker": "Hệ thống sẽ hỏi bạn muốn chia sẻ gì", + "inProgress": "Đang ghi", "saving": "Đang lưu..." }, "language": "Ngôn ngữ", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 1d72f32be..4bf91ce33 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -50,5 +50,11 @@ "locale": { "name": "简体中文", "short": "简中" + }, + "recordingSource": { + "window": "窗口", + "screen": "屏幕", + "virtual": "虚拟显示器", + "unknown": "屏幕录制" } } diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index d4dfe1792..dd629f57f 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -43,7 +43,8 @@ "cameraDisconnected": "摄像头已断开连接。", "cameraNotFound": "未找到摄像头。", "permissionDenied": "录屏权限被拒绝。请允许屏幕录制。", - "accessibilityAllowAndRetry": "允许 OpenScreen 使用辅助功能权限,然后再次按录制以开始倒计时。" + "accessibilityAllowAndRetry": "允许 OpenScreen 使用辅助功能权限,然后再次按录制以开始倒计时。", + "selectSource": "请选择要录制的源" }, "emptyState": { "title": "未打开任何项目", @@ -98,6 +99,7 @@ }, "rec": { "source": "来源", + "systemPicker": "系统将询问要共享的内容", "systemAudio": "系统音频", "microphone": "麦克风", "camera": "摄像头", diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index f67815fce..c8c8aa348 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -69,6 +69,8 @@ }, "recording": { "selectSource": "请选择要录制的源", + "systemPicker": "系统将询问要共享的内容", + "inProgress": "正在录制", "saving": "正在保存..." }, "language": "语言", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 92401c80a..6d8f827f6 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -50,5 +50,11 @@ "locale": { "name": "繁體中文", "short": "繁中" + }, + "recordingSource": { + "window": "視窗", + "screen": "螢幕", + "virtual": "虛擬顯示器", + "unknown": "螢幕錄製" } } diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 46017c8ab..865096d0f 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -43,7 +43,8 @@ "permissionDenied": "錄影權限被拒絕。請允許螢幕錄製。", "cameraDisconnected": "網路攝影機已中斷連線。", "cameraNotFound": "找不到攝影機。", - "accessibilityAllowAndRetry": "允許 OpenScreen 使用輔助使用權限,然後再次按下錄製以開始倒數。" + "accessibilityAllowAndRetry": "允許 OpenScreen 使用輔助使用權限,然後再次按下錄製以開始倒數。", + "selectSource": "請選擇要錄製的來源" }, "emptyState": { "title": "未開啟任何專案", @@ -98,6 +99,7 @@ }, "rec": { "source": "來源", + "systemPicker": "系統將詢問要分享的內容", "systemAudio": "系統音訊", "microphone": "麥克風", "camera": "攝影機", diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json index 5e8fbf10d..0fa9f6720 100644 --- a/src/i18n/locales/zh-TW/launch.json +++ b/src/i18n/locales/zh-TW/launch.json @@ -69,6 +69,8 @@ }, "recording": { "selectSource": "請選擇要錄製的來源", + "systemPicker": "系統將詢問要分享的內容", + "inProgress": "正在錄製", "saving": "正在儲存..." }, "language": "語言", diff --git a/src/lib/nativeLinuxRecording.test.ts b/src/lib/nativeLinuxRecording.test.ts new file mode 100644 index 000000000..ef9342657 --- /dev/null +++ b/src/lib/nativeLinuxRecording.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { portalOwnsSourceSelection } from "./nativeLinuxRecording"; + +function probe(platform: string, available: boolean | Error) { + return { + getPlatform: () => platform, + isNativeLinuxCaptureAvailable: vi.fn(async () => { + if (available instanceof Error) { + throw available; + } + return { success: true, available }; + }), + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("portalOwnsSourceSelection", () => { + /** + * The regression. The recorder asks this question in two places — before the + * countdown and before capture — and the countdown's gate was missed, so the + * HUD offered no way to pick a source and then refused to record without one. + */ + it("is true on Linux when the native helper is available", async () => { + await expect(portalOwnsSourceSelection(probe("linux", true))).resolves.toBe(true); + }); + + it("is false on Linux without the helper, where the browser fallback needs a source id", async () => { + await expect(portalOwnsSourceSelection(probe("linux", false))).resolves.toBe(false); + }); + + it.each([ + "darwin", + "win32", + ])("is false on %s, which targets a window directly", async (platform) => { + const api = probe(platform, true); + + await expect(portalOwnsSourceSelection(api)).resolves.toBe(false); + // Not merely false — the question is never asked off Linux. + expect(api.isNativeLinuxCaptureAvailable).not.toHaveBeenCalled(); + }); + + it("keeps the gate when the availability check fails", async () => { + vi.spyOn(console, "warn").mockImplementation(() => { + // Expected: the failure is reported, not swallowed silently. + }); + + await expect(portalOwnsSourceSelection(probe("linux", new Error("IPC is gone")))).resolves.toBe( + false, + ); + }); + + it("keeps the gate when the helper reports failure rather than availability", async () => { + await expect( + portalOwnsSourceSelection({ + getPlatform: () => "linux", + isNativeLinuxCaptureAvailable: async () => ({ success: false, available: true }), + }), + ).resolves.toBe(false); + }); +}); diff --git a/src/lib/nativeLinuxRecording.ts b/src/lib/nativeLinuxRecording.ts index af46a397a..3beee7c52 100644 --- a/src/lib/nativeLinuxRecording.ts +++ b/src/lib/nativeLinuxRecording.ts @@ -11,10 +11,11 @@ * * There is no `webcam`. Like macOS, the camera stays with the renderer's * MediaRecorder — V4L2 in the helper would buy nothing and cost a second * exclusive claim on the device. - * - * `restoreToken` is the one field with no equivalent elsewhere: the portal hands - * one back after a successful session, and passing it next time is what stops - * the picker from appearing on every single recording. + * * There is no `restoreToken`. One used to be replayed here so the portal + * would stop raising its picker, and that is exactly how picking a window + * produced a recording of the whole screen: a token is bound to the source + * it was minted for, so an approved monitor came back forever and the picker + * — the only source chooser Wayland offers — never reappeared. */ export type NativeLinuxRecordingRequest = { recordingId?: number; @@ -41,10 +42,44 @@ export type NativeLinuxRecordingRequest = { cursor: { mode: import("./recordingSession").CursorCaptureMode; }; - /** From a previous run's `stream-started`. Lets the portal skip its picker. */ - restoreToken?: string; }; +/** The slice of `window.electronAPI` the check below needs. */ +type SourceSelectionProbe = { + getPlatform: () => string; + isNativeLinuxCaptureAvailable: () => Promise<{ success: boolean; available: boolean }>; +}; + +/** + * Whether the ScreenCast portal — not the app — chooses what gets recorded. + * + * True only on Linux WITH the PipeWire helper. `SelectSources` has no parameter + * naming a source, so there is nothing for the app to have selected and every + * `selectedSource` gate must stand down. Everywhere else those gates are + * load-bearing: both other native paths, and Linux's own browser fallback, + * genuinely consume a source id. + * + * Lives here rather than inline in `useScreenRecorder` because the recorder asks + * this question in TWO places — once before the countdown and once before + * capture — and fixing only the second left the countdown refusing to start at + * all, with an alert about a source the HUD no longer offers any way to pick. + */ +export async function portalOwnsSourceSelection(api: SourceSelectionProbe): Promise { + if (api.getPlatform() !== "linux") { + return false; + } + try { + const availability = await api.isNativeLinuxCaptureAvailable(); + return Boolean(availability?.success && availability.available); + } catch (error) { + // Keep the gate rather than drop it: without the helper a source id is + // still needed, and a missing source is a better failure than a capture + // that cannot start. + console.warn("Failed to check native Linux capture availability:", error); + return false; + } +} + export type NativeLinuxRecordingStartResult = { success: boolean; recordingId?: number; diff --git a/technical-documentation/architecture/recording.md b/technical-documentation/architecture/recording.md index ba58726ea..96e99824a 100644 --- a/technical-documentation/architecture/recording.md +++ b/technical-documentation/architecture/recording.md @@ -1,6 +1,6 @@ # Recording architecture -Recording is a cross-platform session coordinated by Electron and rendered by a platform capture path. The recorder and HUD live in `src/components/launch/` and `src/hooks/useScreenRecorder.ts`; native capture helpers live in `electron/native/`, while Linux uses Chromium's display-media APIs. +Recording is a cross-platform session coordinated by Electron and rendered by a platform capture path. The recorder and HUD live in `src/components/launch/` and `src/hooks/useScreenRecorder.ts`; native capture helpers for all three platforms live in `electron/native/`. Linux falls back to Chromium's display-media APIs only when its helper binary is missing. ## Lifecycle @@ -14,7 +14,7 @@ flowchart LR F --> E[Editor opens] ``` -The HUD starts and controls a recording. The source selector chooses a display or window and the countdown gives the user time to prepare. Electron resolves the source and output paths, starts the selected capture backend, and records cursor telemetry alongside media. Stop finalizes the media and session files; the resulting paths are passed to the editor as recording assets. +The HUD starts and controls a recording. The source selector chooses a display or window and the countdown gives the user time to prepare. On Linux there is no in-app source selector: the compositor's portal picker takes its place. It is raised before the countdown, not during it — the helper negotiates the portal, reports `source-selected`, and then waits for `record` on stdin while the app counts down. Electron resolves the source and output paths, starts the selected capture backend, and records cursor telemetry alongside media. Stop finalizes the media and session files; the resulting paths are passed to the editor as recording assets. ## The HUD @@ -28,23 +28,32 @@ Electron applies `setContentProtection(true)` to the HUD window (`electron/windo | --- | --- | --- | --- | | Windows | Windows Graphics Capture (WGC) helper (C++/Win32), with WASAPI and Media Foundation support | `electron/native/wgc-capture/` and `electron/windows.ts` | H.264 MP4 screen/window video; system/microphone AAC when enabled; webcam is muxed into the primary MP4 unless a separate webcam path is requested | | macOS | ScreenCaptureKit helper (Swift), with AVFoundation/VideoToolbox encoding | `electron/native/screencapturekit/` and `electron/native/README.md` | H.264 MP4 screen/window video and ScreenCaptureKit system audio; microphone may be native where supported; webcam currently remains a separate Electron sidecar | -| Linux | Electron `getDisplayMedia` path | `src/hooks/useScreenRecorder.ts` and Electron recording IPC | Browser-recorded display/window media, with the session's separate media and telemetry files | +| Linux | PipeWire capture helper (Rust + C shim) driving the xdg-desktop-portal ScreenCast interface | `electron/native/pipewire-capture/` and `electron/native-bridge/capture/linuxNativeCaptureSession.ts` | H.264 MP4 screen/window video, PipeWire system/microphone audio, and cursor telemetry from one portal session; webcam stays an Electron sidecar | -The division is an invariant: the native helper owns capture, timing, and encoding; Electron owns session orchestration, output-path selection, persistence, and editor handoff. Linux keeps those media responsibilities in Electron because it has no native helper path here. +The division is an invariant: the native helper owns capture, timing, and encoding; Electron owns session orchestration, output-path selection, persistence, and editor handoff. When the Linux helper binary is absent, the recorder falls back to the Electron `getDisplayMedia` path, which is the one case where Electron still owns the media. ## Helper contract A native session is a child process boundary. Electron starts the platform helper with one structured JSON request and sends runtime commands on stdin; `stop` finalizes the output. The helper emits newline-delimited JSON events on stdout. The shared shape contains `schemaVersion`, `recordingId`, a `source` (display or window and its bounds), `video`, `audio`, optional `webcam`, optional cursor mode, and `outputs` paths. The helper reports `ready`, `recording-started`, warnings, errors, and `recording-stopped` events. Windows accepts legacy textual start/stop messages during compatibility handling; the structured events are the reference contract. -| Contract field or behavior | Windows | macOS | -| --- | --- | --- | -| Schema | `schemaVersion: 2` | `schemaVersion: 1` | -| Source identity | `sourceId`, `displayId`, optional `windowHandle` | `sourceId`, `displayId`, optional `windowId` | -| Video | FPS, dimensions, bitrate | FPS, dimensions, bitrate, and `hideSystemCursor` | -| Audio | System loopback and selected microphone flags/device metadata | System audio and microphone flags/device metadata; microphone support is runtime-gated | -| Webcam | Native Media Foundation first, exact Electron-resolved DirectShow fallback; muxed into primary MP4 by default | Electron sidecar attached to the session | -| Output | `screenPath`, session manifest, and optional `webcamPath` | `screenPath` and session manifest | -| Runtime control | stdin pause/resume/stop/cancel; JSON events plus legacy text compatibility | Process events and the same lifecycle commands as the process boundary evolves | +| Contract field or behavior | Windows | macOS | Linux | +| --- | --- | --- | --- | +| Schema | `schemaVersion: 2` | `schemaVersion: 1` | `schemaVersion: 1` | +| Source identity | `sourceId`, `displayId`, optional `windowHandle` | `sourceId`, `displayId`, optional `windowId` | **None, and none is possible.** See below | +| Video | FPS, dimensions, bitrate | FPS, dimensions, bitrate, and `hideSystemCursor` | FPS and optional bitrate; dimensions come from what the compositor negotiates | +| Audio | System loopback and selected microphone flags/device metadata | System audio and microphone flags/device metadata; microphone support is runtime-gated | System and microphone flags; the microphone is matched by PipeWire `node.description`, not by Chromium device id | +| Webcam | Native Media Foundation first, exact Electron-resolved DirectShow fallback; muxed into primary MP4 by default | Electron sidecar attached to the session | Electron sidecar attached to the session | +| Output | `screenPath`, session manifest, and optional `webcamPath` | `screenPath` and session manifest | `screenPath`, session manifest, and a `.cursor.json` sidecar | +| Runtime control | stdin pause/resume/stop/cancel; JSON events plus legacy text compatibility | Process events and the same lifecycle commands as the process boundary evolves | stdin pause/resume/stop; NDJSON events on stdout | + +### Why Linux sends no source identity + +`org.freedesktop.portal.ScreenCast.SelectSources` takes exactly `(session, cursor_mode, types, multiple, restore_token, persist_mode)`. There is no window id, monitor id, or node id a caller may supply, so the compositor's own picker is the only thing that can choose a source — the app cannot ask for one and cannot override the answer. The helper reports what it was given back on `stream-started` as `sourceKind` (`"monitor"`, `"window"` or `"virtual"`); that reply is the only knowledge the app ever has about what is being recorded, and an absent `sourceKind` means unknown, not "monitor". + +Two consequences follow, and both were once bugs: + +- **The HUD shows no source button on Linux.** An in-app picker cannot steer the portal, and the one that existed raised a *second* portal dialog of its own through `desktopCapturer.getSources()` whose grant was then discarded — which is why choosing a window there changed nothing. +- **No portal restore token is persisted.** Replaying one used to suppress the picker on later runs. Because a token is bound to the source it was minted for, an approved monitor came back on every subsequent recording and the picker — the only source chooser Wayland offers — never reappeared, so "record this window" recorded the whole screen. Answering the picker each time is the cost of being able to choose at all. Electron resolves selected sources, devices, and paths before launching the helper. The helper does not guess a DirectShow camera: Windows receives the resolved selection. A helper error is reported explicitly rather than silently switching a Windows native feature to browser capture. @@ -58,5 +67,7 @@ Cursor samples are persisted as cursor telemetry rather than baked into editable - A window with odd client dimensions can produce black video: H.264 encoding requires even dimensions (`electron/native/wgc-capture/src/wgc_session.cpp:38`). - Stopping a recording can hang on the software encoder path (`electron/native/wgc-capture/src/main.cpp:755`). -- Linux/Wayland can produce no usable frames because Chromium initializes Vulkan against the Ozone Wayland backend. +- Linux/Wayland can produce no usable frames on the `getDisplayMedia` fallback because Chromium initializes Vulkan against the Ozone Wayland backend. The PipeWire helper path is unaffected. +- On Linux the compositor's source picker appears on every recording. That is deliberate — see "Why Linux sends no source identity" — but it is an interruption, and there is currently no way to reuse a previous choice without also making it impossible to change. +- Holding a portal session across the countdown means the compositor's "screen is being shared" indicator is up before recording begins. That is honest — access really has been granted — but the user can click it to revoke, or close the window they picked. The helper's exit surfaces as a rejected `waitUntilSourceSelected`; the session is not yet subscribed to the portal's `Session::Closed` signal, so a revocation is reported as a failed start rather than a specific message. - `preferSoftwareEncoder` is read when recording starts. The recorder has no UI for setting it; Windows also accepts `OPENSCREEN_WGC_PREFER_SOFTWARE_ENCODER=true` in the helper request path. diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index f0cce7369..f1375ff73 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -387,7 +387,13 @@ The agent may only call the fixed tool set in [ai-agent.md](../architecture/ai-a - [ ] Run the complete editor-to-export flow on real Linux with the supported packaged or development build. - [ ] Confirm the HUD remains interactive on the supported Linux window manager. -- [ ] Select a screen source and confirm the resulting recording is not black. +- [ ] Select a screen source in the compositor's portal picker and confirm the resulting recording is not black. +- [ ] **Select a single WINDOW in the portal picker and confirm the recording contains only that window, at the window's dimensions — not the whole screen.** Check the pixel size, not just the look of it: `ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 ` should report the window's size, never the monitor's. This is the case that shipped broken in 1.8.0. +- [ ] Record twice in a row and confirm the portal picker appears BOTH times, and that choosing a different source the second time actually changes what is recorded. +- [ ] Confirm the HUD shows no in-app source button on Linux, and that the record button starts a recording directly instead of opening a picker. +- [ ] Confirm the portal picker appears BEFORE the 3-2-1 countdown, not during or after it. +- [ ] Start the same flow from the editor's Rec stage ("Start recording") and confirm it behaves identically to the HUD — no source row, picker first, then countdown. +- [ ] Cancel the countdown after answering the picker and confirm the compositor's "screen is being shared" indicator goes away rather than lingering. - [ ] Confirm the system tray or supported desktop indicator can refocus the HUD when it is hidden. - [ ] Confirm microphone capture works with a physical device and the chosen device is audible in playback. - [ ] Confirm the webcam toggle reflects the available physical camera or clearly reports that no camera is available.