Skip to content

fix(recording): record the window Linux users actually pick - #250

Merged
EtienneLescot merged 3 commits into
mainfrom
claude/linux-window-recording-bug-0951c1
Aug 4, 2026
Merged

fix(recording): record the window Linux users actually pick#250
EtienneLescot merged 3 commits into
mainfrom
claude/linux-window-recording-bug-0951c1

Conversation

@EtienneLescot

@EtienneLescot EtienneLescot commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

Choosing a window on Wayland produced a recording of the whole screen. Two independent defects, both proven on a real GNOME 46 / mutter session.

The portal restore token pinned the first grant forever. A token is bound to the source it was minted for, so once any monitor had been approved the portal restored that monitor on every later run and stopped raising its picker — and SelectSources has no parameter naming a source, so the app had no way to ask for anything else. GNOME's permission store held ours decoded as source_type=1 (MONITOR, CMN:0x1468 — the built-in panel), created 1.7 s into one full-screen recording and replayed 93 ms into a later one. The token is gone, and PersistMode is now DoNot so we stop accumulating entries in that store.

SPA_META_VideoCrop was never requested. mutter pins a window stream to its monitor's size — "we cannot set the stream size to the exact size of the window, because windows can be resized, whereas streams cannot" — and reports the window's live rectangle in that meta, but only writes it if the consumer declared it. We never did, so a window arrived as a monitor-sized buffer: measured 1920×982 of content at (0,0) inside a 1920×1080 frame, 98 px of black below. The declaration and per-frame read follow OBS and WebRTC; validation follows WebRTC's stricter posture, because the pointer goes straight to swscale.

Neither reference handles a mid-recording resize — both let the delivered frame size vary per frame, which an MP4 track cannot. The crop is latched at encoder open, the live origin is followed but clamped inside the buffer, and the first divergence is reported once as crop-changed. The encoder is opened from the first frame carrying a usable crop rather than the first frame at all: mutter's rectangle intersection reports success on an empty result and records a frame synchronously from enable(), so committing to frame zero could pin a window recording at monitor size again.

The in-app picker is gone on Linux. It could not steer the capture, and it raised a second portal dialog — via desktopCapturer.getSources() — whose grant was discarded. Both entry points (the HUD and the editor's Rec stage) now ask one shared hook, and the tray reports what the portal actually granted instead of echoing a selection the capture never heard of.

The picker now comes before the countdown. The helper negotiates the portal, reports source-selected, and waits for record on stdin; the PipeWire stream is not connected until then, so nothing is captured and WirePlumber cannot suspend an idle node out from under us during the count. Preparing is optional and best-effort — start still negotiates on its own — so other platforms and any future caller are unaffected.

Related issue

Refs #

Type of change

  • Bug fix

Release impact

  • Patch

Desktop impact

  • Linux

Screenshots / video

Before: a maximised window recorded as 1920×1080 with the window in the top 982 px and black beneath — indistinguishable from a broken full-screen capture.
After: the file is the window's own size.

Testing

  • npm run test — 1604 passing (electron/media/audioPeaks.test.ts fails locally only, on a machine where electron/native/bin/linux-x64/ffmpeg is a directory of shared libraries; fixed separately in fix/ffmpeg-resolve-executable).
  • cargo test in electron/native/pipewire-capture — 54 passing, including new coverage for the crop, an out-of-bounds shrunken window, and the prepare/arm protocol.
  • npm run lint, npx tsc --noEmit, npx tsc -p tsconfig.test.json --noEmit, npm run docs:check, npm run i18n:check, npx vite build — all clean.
  • Verified by hand on Ubuntu 24.04 / GNOME 46 / Wayland: window recordings now match the window's dimensions, the picker appears on every recording and before the countdown, and choosing a different source actually changes what is recorded.

Note

CI does not build or test electron/native/pipewire-capture — the rust-linux-compositor-check job covers the compositor crate, not this helper. The C and Rust changes here are verified locally only.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Linux recording now opens the system screen-sharing picker before countdown.
    • Supports monitor, window, and virtual-source selection with cancellation and fallback behavior.
    • Hides the in-app source picker when system selection is active.
    • Adds clearer recording status and source-selection guidance.
  • Bug Fixes

    • Improved cropped-window capture, frame sizing, cursor handling, and recording startup reliability.
  • Documentation

    • Updated Linux recording architecture and manual testing guidance.
  • Localization

    • Added recording prompts, statuses, and source labels across supported languages.

Choosing a window on Wayland produced a recording of the whole screen.
Two independent defects, both proven on a real GNOME 46 session.

The portal restore token pinned the first grant forever. A token is bound
to the source it was minted for, so once any monitor had been approved the
portal restored that monitor on every later run and stopped raising its
picker — and SelectSources has no parameter naming a source, so the app had
no way to ask for anything else. GNOME's permission store held ours as
source_type=1 (MONITOR, CMN:0x1468), created 1.7s into one full-screen
recording and replayed 93ms into a later one. The token is gone, and
PersistMode is DoNot so we stop littering that store.

SPA_META_VideoCrop was never requested. mutter pins a window stream to its
monitor's size — "we cannot set the stream size to the exact size of the
window, because windows can be resized, whereas streams cannot" — and
reports the window's live rectangle in that meta, but only writes it if the
consumer asked. We never asked, so a window arrived as a monitor-sized
buffer: measured 1920x982 of content at (0,0) inside a 1920x1080 frame,
98px of black below. The declaration and per-frame read follow OBS and
WebRTC; the validation follows WebRTC's stricter posture because the
pointer goes straight to swscale.

Neither reference handles a mid-recording resize — both let the frame size
vary per frame, which an MP4 track cannot. The crop is latched at encoder
open, the live origin is followed but clamped inside the buffer, and the
first divergence is reported once as `crop-changed`. The encoder is opened
from the first frame carrying a usable crop rather than the first frame at
all: mutter's rectangle intersection reports success on an empty result and
records a frame synchronously from enable(), so committing to frame zero
could pin a window recording at monitor size again.

Since the portal owns the choice, the in-app picker is gone on Linux: it
could not steer the capture and only raised a second portal dialog whose
grant was discarded. Both entry points — the HUD and the editor's Rec stage
— now ask one shared hook, and the tray names what the portal granted
instead of echoing a selection the capture never heard of.

The picker also now comes before the countdown. The helper negotiates the
portal, reports `source-selected`, and waits for `record` on stdin; the
stream is not connected until then, so nothing is captured and WirePlumber
cannot suspend an idle node out from under us. Preparing is optional and
best-effort — start still negotiates on its own — so every other platform
and any future caller is unaffected.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 27bb3473-4854-4418-a3f2-cf15d8a51f67

📥 Commits

Reviewing files that changed from the base of the PR and between f8262f8 and 8a6123e.

📒 Files selected for processing (18)
  • electron/ipc/handlers.ts
  • electron/native/pipewire-capture/src/capture.rs
  • src/components/launch/LaunchWindow.test.tsx
  • src/components/launch/LaunchWindow.tsx
  • src/i18n/locales/ar/common.json
  • src/i18n/locales/en/common.json
  • src/i18n/locales/es/common.json
  • src/i18n/locales/fr/common.json
  • src/i18n/locales/it/common.json
  • src/i18n/locales/ja-JP/common.json
  • src/i18n/locales/ko-KR/common.json
  • src/i18n/locales/pt-BR/common.json
  • src/i18n/locales/ru/common.json
  • src/i18n/locales/tr/common.json
  • src/i18n/locales/vi/common.json
  • src/i18n/locales/zh-CN/common.json
  • src/i18n/locales/zh-TW/common.json
  • technical-documentation/testing/manual-e2e-checklist.md

📝 Walkthrough

Walkthrough

Linux native recording now opens the portal picker before countdown, supports prepared and cancellable sessions, reports portal-selected source kinds, and handles window crops through PipeWire. The renderer, Electron bridge, UI, tests, translations, and documentation reflect this flow.

Changes

Linux portal recording flow

Layer / File(s) Summary
Portal preparation and recording orchestration
electron/electron-env.d.ts, electron/preload.ts, electron/ipc/handlers.ts, src/lib/nativeLinuxRecording.ts, src/hooks/usePortalOwnsSource.ts, src/hooks/useScreenRecorder.ts
The renderer can prepare and cancel Linux native sessions. Electron waits for portal selection, reuses matching sessions, arms them, and removes restore-token handling.
Deferred helper and portal protocol
electron/native-bridge/capture/..., electron/native-bridge/cursor/recording/..., electron/native/pipewire-capture/src/{main,portal,events}.rs
The helper separates portal negotiation from recording, accepts the record command, emits source-selected, and reports portal source kinds.
Crop metadata and frame staging
electron/native/pipewire-capture/csrc/*, electron/native/pipewire-capture/src/{shim,capture,encoder}.rs
PipeWire crop metadata is validated and passed through to Rust. Capture staging uses crop origins and dimensions with checked buffer calculations.
Portal-aware recording UI and validation
src/components/launch/*, src/components/ai-edition/v4/RecStage.tsx, src/i18n/locales/*, technical-documentation/*
Linux hides the in-app source selector when the native helper is available. Labels, localized strings, automated tests, architecture notes, and the manual checklist describe portal-owned selection.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Recorder as useScreenRecorder
  participant Electron as Electron IPC
  participant Helper as Linux native helper
  participant Portal as ScreenCast portal
  participant PipeWire as PipeWire capture
  Recorder->>Electron: prepare Linux recording
  Electron->>Helper: start with deferStart
  Helper->>Portal: open source picker
  Portal-->>Helper: selected source and source kind
  Helper-->>Electron: source-selected
  Recorder->>Electron: start prepared recording
  Electron->>Helper: record
  Helper->>PipeWire: connect stream and process cropped frames
  PipeWire-->>Electron: stream-started with source kind
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states the primary Linux recording fix: recording the window selected by the user.
Description check ✅ Passed The description covers the change, impact, testing, manual verification, and known CI limitation in sufficient detail.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/linux-window-recording-bug-0951c1

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
electron/ipc/handlers.ts (1)

1870-1912: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Track the in-flight prepare, or a superseded or cancelled negotiation leaks a live portal session.

preparedLinuxCapture is assigned only after waitUntilSourceSelected() resolves, and that await has no upper bound. While a prepare is negotiating, the slot is still null, so two things fail:

  1. A second prepare-native-linux-recording finds null at Line 1873, discards nothing, and spawns a second helper. Both pickers appear, and the later assignment at Line 1912 overwrites the earlier one. The overwritten session is never discarded, so a live ScreenCast session and its child process stay alive with the compositor sharing indicator on. Two renderer surfaces can prepare (the HUD and the editor Rec stage both drive this flow).
  2. cancel-native-linux-prepare is a no-op while the picker is up, because discardPreparedLinuxCapture returns early on null. The session then assigns and persists. useScreenRecorder.ts awaits the prepare before cancelling, so the countdown path is covered, but the hook unmount path is not.

Record the negotiation itself, and discard the session if that negotiation was superseded or cancelled before it completed.

🛠️ Proposed fix: claim the slot before the unbounded await
 let preparedLinuxCapture: { session: LinuxNativeCaptureSession; outputPath: string } | null = null;
+/** Identifies the prepare that is still negotiating with the portal. */
+let preparingLinuxCaptureToken: symbol | null = null;
 
 /** Tears down a prepared-but-unarmed session, e.g. an abandoned countdown. */
 function discardPreparedLinuxCapture(reason: string) {
+	// Invalidate a negotiation that has not assigned its session yet, so the
+	// session it is about to produce is discarded instead of stranded.
+	preparingLinuxCaptureToken = null;
 	if (!preparedLinuxCapture) {
 		return;
 	}
 			discardPreparedLinuxCapture("superseded by a new prepare");
+			const token = Symbol("prepare-native-linux-recording");
+			preparingLinuxCaptureToken = token;
 
 			try {
 				await session.start();
 				// The picker is up now. No timeout: a human is reading a dialog.
 				await session.waitUntilSourceSelected();
 
+				// Superseded or cancelled while the picker was up.
+				if (preparingLinuxCaptureToken !== token) {
+					session.discard();
+					return { success: false, reason: "cancelled" };
+				}
+				preparingLinuxCaptureToken = null;
 				preparedLinuxCapture = { session, outputPath };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ipc/handlers.ts` around lines 1870 - 1912, Track the in-flight Linux
capture negotiation in preparedLinuxCapture before awaiting
waitUntilSourceSelected(), so subsequent prepare requests and
cancel-native-linux-prepare can discard that session instead of creating or
retaining overlapping portal sessions. Update the post-selection assignment to
preserve the same tracked session and ensure superseded or cancelled
negotiations cleanly discard the session without allowing a later completion to
overwrite newer state.
electron/native/pipewire-capture/src/main.rs (1)

746-770: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

capture-started reports the unmasked crop size, not the encoded size.

Line 748 re-derives (width, height) from frame.crop. The encoder was opened at line 657-658 with those values masked to even. The comment at line 654-656 states that a window rectangle is routinely odd, so the two differ in the common case. capture-started then reports a size the MP4 does not have. linuxNativeCaptureSession.ts logs these values at capture-started, and the app treats that event as "recording started", so the one line that answers "what size is my recording" is off by a pixel.

Apply the same mask where the event is built.

🐛 Report the encoded dimensions
                     let first = !capture.started();
                     let staged = capture.stage(&frame);
-                    let (width, height) = (frame.crop.width, frame.crop.height);
+                    // The masked values, matching what the encoder was opened
+                    // with: an odd window rect is rounded down for H.264 chroma.
+                    let (width, height) = (frame.crop.width & !1, frame.crop.height & !1);
                     mailbox.recycle(frame.pixels);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/native/pipewire-capture/src/main.rs` around lines 746 - 770, Update
the CaptureStarted event construction in the capture loop to report the encoded
dimensions by applying the same even-dimension mask used when opening the
encoder, instead of using the raw frame.crop width and height. Keep the existing
event flow and encoder configuration unchanged.
🧹 Nitpick comments (3)
electron/native/pipewire-capture/src/events.rs (1)

52-67: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a serialization test for SourceSelected.

SourceSelected is a new wire event and the TypeScript side matches on the exact literal "source-selected" plus the camelCase field names. This module already pins StreamStarted with stream_started_is_kebab_case_with_camel_case_fields. A matching test for the new variant would catch a rename or a missing rename_all before it reaches the helper protocol.

♻️ Proposed test
#[test]
fn source_selected_names_the_moment_the_picker_was_answered() {
    let value = parse_one(&Event::SourceSelected {
        timestamp_ms: 3,
        node_id: 42,
        source_kind: Some("window".to_owned()),
        position_x: Some(10),
        position_y: None,
    });
    assert_eq!(value["event"], "source-selected");
    assert_eq!(value["nodeId"], 42);
    assert_eq!(value["sourceKind"], "window");
    assert_eq!(value["positionX"], 10);
    // Absent, not omitted: the TypeScript union accepts null and must keep doing so.
    assert!(value["positionY"].is_null());
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/native/pipewire-capture/src/events.rs` around lines 52 - 67, Add a
serialization test alongside the existing StreamStarted serialization test for
Event::SourceSelected, using representative populated and absent optional
fields. Assert the serialized event is "source-selected", uses camelCase keys
such as nodeId, sourceKind, and positionX, and serializes a None position_y as
null.
electron/native-bridge/capture/linuxNativeCaptureSession.test.ts (1)

246-255: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the error-event rejection of a pending source-selection wait.

This test covers the process-exit path. handleEvent also rejects sourceSelectedReject on an error event (linuxNativeCaptureSession.ts lines 471-473), and that branch has no direct test. A cancelled picker reaches the parent as portal-cancelled on that path, which is the most likely real failure during a prepared session.

As per coding guidelines: "Add a test for every new behavior in the same package as the code under test."

♻️ Proposed test
	/**
	 * A dismissed picker arrives as an `error` event, not as an exit: the helper
	 * reports `portal-cancelled` and only then goes away. Whoever is waiting on
	 * the picker must learn the cause, not just the exit code.
	 */
	it("rejects a pending source selection when the portal reports an error", async () => {
		const session = newSession(true);
		await startReady(session);

		const selecting = session.waitUntilSourceSelected();
		helper.emitEvent({
			event: "error",
			code: "portal-cancelled",
			message: "The screen capture request was cancelled.",
		});

		await expect(selecting).rejects.toThrow("The screen capture request was cancelled.");
	});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/native-bridge/capture/linuxNativeCaptureSession.test.ts` around
lines 246 - 255, Add a test beside the existing pending source-selection
rejection test that starts a ready session, waits via waitUntilSourceSelected(),
emits an error event through helper.emitEvent with portal-cancelled and a
descriptive message, and verifies the pending promise rejects with that message.

Source: Coding guidelines

electron/native/pipewire-capture/src/main.rs (1)

635-641: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Treat an unknown source kind like a window while waiting for the crop.

source_kind is documented as unknown when absent, so the current expecting_crop check skips the crop wait on older backends. If the first frame is the window before crop metadata arrives, the encoder commits to the full-monitor-size stream for the whole recording. On a monitor stream, a missing crop wait only costs MAX_FRAMES_AWAITING_CROP frames before the full stream is used.

♻️ Treat an unknown kind the same as a window while waiting for the crop
-                            let expecting_crop =
-                                granted_kind == Some(portal::SourceKind::Window) && !frame.has_crop;
+                            // An absent kind is unknown, not "monitor": the same
+                            // wait applies, and a real monitor stream only pays
+                            // MAX_FRAMES_AWAITING_CROP frames before falling back.
+                            let expecting_crop = !frame.has_crop
+                                && !matches!(
+                                    granted_kind,
+                                    Some(portal::SourceKind::Monitor)
+                                        | Some(portal::SourceKind::Virtual)
+                                );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/native/pipewire-capture/src/main.rs` around lines 635 - 641, Update
the expecting_crop condition in the frame-processing loop to treat an absent or
unknown source kind the same as portal::SourceKind::Window when frame.has_crop
is false. Preserve the existing MAX_FRAMES_AWAITING_CROP limit, frame recycling,
and continue behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@electron/ipc/handlers.ts`:
- Around line 1961-2002: Update the prepared-session path around
LinuxNativeCaptureSession so a START request cannot silently apply audio or
cursor settings that were not used when the session was prepared. Either reject
the prepared session and execute the full start flow when its configuration
differs, or retain and use the prepared configuration for the session and
linuxNativeCaptureCursorMode; also ensure countdown controls cannot change those
affected settings if required by the chosen approach.
- Around line 596-607: Update linuxSourceLabel to resolve all four source labels
through the existing mainT localization function instead of returning hardcoded
English strings. Add the corresponding keys to the appropriate locale namespace,
preserving the current mappings for window, monitor, virtual, and the default
recording case; ensure the localized result continues flowing through
onRecordingStateChange.

In `@electron/native/pipewire-capture/src/capture.rs`:
- Around line 293-300: Update crop_diverged to normalize the live frame crop
width and height to the encoder’s even dimensions before comparing them with
committed_width and committed_height. Preserve resize detection for genuinely
different normalized dimensions, and add a regression test covering an unchanged
odd-sized crop such as 321x241.
- Around line 308-337: Keep the live portal crop as the recording boundary:
update capture.rs:308-337 and the stage/read_origin flow to encode only pixels
within a validated crop, safely scale, pad, drop, or stop when the committed
rectangle exceeds it. Track crop-metadata validity separately from crop
dimensions, marking it valid only after metadata arrival and bounds validation;
update pw_shim.c:617-650, pw_shim.h:88-96, and shim.rs:44-48, 171-177, 248-254
to propagate that state. Update capture.rs:609-634 and coverage handling at
capture.rs:663-681 so missing or shrunken crops cannot report coverage that
permits out-of-window pixels.

In `@src/components/launch/LaunchWindow.tsx`:
- Around line 509-512: The usePortalOwnsSource hook returns false before its
asynchronous check completes, and the current code treats this unresolved
pending state identically to a resolved false result. Update the sourceSelected
logic in LaunchWindow.tsx (line 509-512) to expose an unresolved state from the
portal ownership check, then suppress the openSourceSelector call when ownership
is still pending. Similarly update RecStage.tsx (line 145-151) to not treat an
unresolved pending state as a locally selected source, and update RecStage.tsx
(line 193-226) to conditionally hide the interactive source selector UI when
ownership is unresolved. Finally, add a test case in LaunchWindow.test.tsx (line
425-454) that verifies openSourceSelector is not invoked when Record is clicked
before the portal ownership check resolves, ensuring the deferred behavior works
correctly.

In `@src/i18n/locales/it/editor.json`:
- Line 46: Update the Italian translation value for accessibilityAllowAndRetry
to replace the unnatural “accesso all'accessibilità” wording with clear, natural
language asking the user to grant OpenScreen access to accessibility features,
while preserving the rest of the instruction.

---

Outside diff comments:
In `@electron/ipc/handlers.ts`:
- Around line 1870-1912: Track the in-flight Linux capture negotiation in
preparedLinuxCapture before awaiting waitUntilSourceSelected(), so subsequent
prepare requests and cancel-native-linux-prepare can discard that session
instead of creating or retaining overlapping portal sessions. Update the
post-selection assignment to preserve the same tracked session and ensure
superseded or cancelled negotiations cleanly discard the session without
allowing a later completion to overwrite newer state.

In `@electron/native/pipewire-capture/src/main.rs`:
- Around line 746-770: Update the CaptureStarted event construction in the
capture loop to report the encoded dimensions by applying the same
even-dimension mask used when opening the encoder, instead of using the raw
frame.crop width and height. Keep the existing event flow and encoder
configuration unchanged.

---

Nitpick comments:
In `@electron/native-bridge/capture/linuxNativeCaptureSession.test.ts`:
- Around line 246-255: Add a test beside the existing pending source-selection
rejection test that starts a ready session, waits via waitUntilSourceSelected(),
emits an error event through helper.emitEvent with portal-cancelled and a
descriptive message, and verifies the pending promise rejects with that message.

In `@electron/native/pipewire-capture/src/events.rs`:
- Around line 52-67: Add a serialization test alongside the existing
StreamStarted serialization test for Event::SourceSelected, using representative
populated and absent optional fields. Assert the serialized event is
"source-selected", uses camelCase keys such as nodeId, sourceKind, and
positionX, and serializes a None position_y as null.

In `@electron/native/pipewire-capture/src/main.rs`:
- Around line 635-641: Update the expecting_crop condition in the
frame-processing loop to treat an absent or unknown source kind the same as
portal::SourceKind::Window when frame.has_crop is false. Preserve the existing
MAX_FRAMES_AWAITING_CROP limit, frame recycling, and continue behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e6986443-e7c6-4134-b7d0-0575ea44bd00

📥 Commits

Reviewing files that changed from the base of the PR and between c186236 and f8262f8.

📒 Files selected for processing (49)
  • electron/electron-env.d.ts
  • electron/ipc/handlers.ts
  • electron/native-bridge/capture/linuxNativeCaptureSession.test.ts
  • electron/native-bridge/capture/linuxNativeCaptureSession.ts
  • electron/native-bridge/cursor/recording/pipeWireCursorAccumulator.ts
  • electron/native/pipewire-capture/csrc/pw_shim.c
  • electron/native/pipewire-capture/csrc/pw_shim.h
  • electron/native/pipewire-capture/src/capture.rs
  • electron/native/pipewire-capture/src/encoder.rs
  • electron/native/pipewire-capture/src/events.rs
  • electron/native/pipewire-capture/src/main.rs
  • electron/native/pipewire-capture/src/portal.rs
  • electron/native/pipewire-capture/src/shim.rs
  • electron/preload.ts
  • src/components/ai-edition/v4/RecStage.tsx
  • src/components/launch/LaunchWindow.test.tsx
  • src/components/launch/LaunchWindow.tsx
  • src/hooks/usePortalOwnsSource.ts
  • src/hooks/useScreenRecorder.ts
  • src/i18n/locales/ar/editor.json
  • src/i18n/locales/ar/launch.json
  • src/i18n/locales/en/editor.json
  • src/i18n/locales/en/launch.json
  • src/i18n/locales/es/editor.json
  • src/i18n/locales/es/launch.json
  • src/i18n/locales/fr/editor.json
  • src/i18n/locales/fr/launch.json
  • src/i18n/locales/it/editor.json
  • src/i18n/locales/it/launch.json
  • src/i18n/locales/ja-JP/editor.json
  • src/i18n/locales/ja-JP/launch.json
  • src/i18n/locales/ko-KR/editor.json
  • src/i18n/locales/ko-KR/launch.json
  • src/i18n/locales/pt-BR/editor.json
  • src/i18n/locales/pt-BR/launch.json
  • src/i18n/locales/ru/editor.json
  • src/i18n/locales/ru/launch.json
  • src/i18n/locales/tr/editor.json
  • src/i18n/locales/tr/launch.json
  • src/i18n/locales/vi/editor.json
  • src/i18n/locales/vi/launch.json
  • src/i18n/locales/zh-CN/editor.json
  • src/i18n/locales/zh-CN/launch.json
  • src/i18n/locales/zh-TW/editor.json
  • src/i18n/locales/zh-TW/launch.json
  • src/lib/nativeLinuxRecording.test.ts
  • src/lib/nativeLinuxRecording.ts
  • technical-documentation/architecture/recording.md
  • technical-documentation/testing/manual-e2e-checklist.md

Comment thread electron/ipc/handlers.ts
Comment thread electron/ipc/handlers.ts Outdated
Comment thread electron/native/pipewire-capture/src/capture.rs
Comment on lines +308 to +337
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)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate files =="
fd -p 'electron/native/pipewire-capture/src/(capture|shim).rs|electron/native/pipewire-capture/csrc/pw_shim(c|h)' .

echo "== git status short =="
git status --short

echo "== capture.rs relevant lines =="
sed -n '1,120p;260,360p;540,690p' electron/native/pipewire-capture/src/capture.rs | cat -n

echo "== shim.rs relevant lines =="
sed -n '1,90p;130,190p;220,270p' electron/native/pipewire-capture/src/shim.rs | cat -n

echo "== pw_shim.c relevant lines =="
sed -n '560,670p' electron/native/pipewire-capture/csrc/pw_shim.c | cat -n

echo "== pw_shim.h relevant lines =="
sed -n '1,130p' electron/native/pipewire-capture/csrc/pw_shim.h | cat -n

echo "== references to crop/has_crop/committed =="
rg -n "crop|has_crop|committed_width|committed_height|read_origin|stage\\(" electron/native/pipewire-capture/src electron/native/pipewire-capture/csrc

Repository: getopenscreen/openscreen

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== encoder/staging references =="
fd -p 'electron/native/pipewire-capture/src/.*encoder.*|electron/native/pipewire-capture/src/pix.*|electron/native/pipewire-capture/src/ffmpeg.*' .

echo "== files =='
git ls-files electron/native/pipewire-capture/src | rg 'encoder|pix|ffmpeg|swscale|scaler' || true

echo "== likely lines =="
for f in $(git ls-files electron/native/pipewire-capture/src | rg 'encoder|pix|ffmpeg|swscale|scaler'); do
  echo "--- $f ($(wc -l < "$f") lines) ---"
  rg -n 'struct|class|fn .*stage|sws_scale|AvFrame|stage|width|height' "$f" | head -200 || true
done

echo "== C capture loop/calling capture from C =="
rg -n "osc_pw_start|on_frame|PipeWire|start_video|video|stage\\(|frame\\.crop|has_crop|committed" electron/native/pipewire-capture/csrc electron/native/pipewire-capture/src/capture.rs

echo "== read-only behavioral probe for read_origin/stage crop size clamp =="
python3 - <<'PY'
def read_origin(frame, committed_width, committed_height):
    max_x = max(frame["width"] - committed_width, 0)
    max_y = max(frame["height"] - committed_height, 0)
    return (max(0, min(frame["crop"]["x"], max_x)), max(0, min(frame["crop"]["y"], max_y)))

committed = (320, 240)
cases = [
    {
        "name": "window shrinks from 320x240 to 20x10, origin near buffer edge",
        "frame": {"width": 400, "height": 300, "stride": 400*4, "crop": {"x": 380, "y": 290, "width": 20, "height": 10}},
    },
    {
        "name": "valid full-frame crop",
        "frame": {"width": 1920, "height": 1080, "stride": 1920*4, "crop": {"x": 0, "y": 0, "width": 1920, "height": 1080}},
    },
    {
        "name": "window inside large frame stays same crop",
        "frame": {"width": 1920, "height": 1080, "stride": 1920*4, "crop": {"x": 100, "y": 50, "width": 320, "height": 240}},
    },
]
for c in cases:
    frame = c["frame"]
    (x,y) = read_origin(frame, *committed)
    print(f"{c['name']}")
    print(f"  committed {committed} frame {frame['width']}x{frame['height']} crop {frame['crop']}")
    print(f"  read_origin = ({x}, {y})")
    offset = y * frame["stride"] + x * 4
    print(f"  encoder will receive pixels.slice(offset) with stride {frame['stride']} width {committed[0]} height {committed[1]}")
    print(f"  offset = {offset} (row {y}, column {x})")
PA

Repository: getopenscreen/openscreen

Length of output: 348


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== encoder.rs outline/relevant =="
rg -n 'struct VideoEncoder|fn stage|swscale|sws_scale|frame|width|height|stride' electron/native/pipewire-capture/src/encoder.rs
sed -n '1,260p' electron/native/pipewire-capture/src/encoder.rs | cat -n

echo "== ffmpeg.rs relevant =="
rg -n 'struct VideoEncoder|VideoEncoder|FFmpeg|AVFrame|sws_scale|encoder|width|height|stride' electron/native/pipewire-capture/src/ffmpeg.rs
sed -n '1,260p' electron/native/pipewire-capture/src/ffmpeg.rs | cat -n

echo "== deterministic staging geometry probe =="
python3 - <<'PY'
def read_origin(frame, committed_width, committed_height):
    max_x = max(frame["width"] - committed_width, 0)
    max_y = max(frame["height"] - committed_height, 0)
    return (max(0, min(frame["crop"]["x"], max_x)), max(0, min(frame["crop"]["y"], max_y)))

committed = (320, 240)
cases = [
    {"name": "window shrinks to 20x10 near buffer edge",
     "frame": {"width": 400, "height": 300, "stride": 400*4, "crop": {"x": 380, "y": 290, "width": 20, "height": 10}}},
    {"name": "valid full-frame crop",
     "frame": {"width": 1920, "height": 1080, "stride": 1920*4, "crop": {"x": 0, "y": 0, "width": 1920, "height": 1080}}},
    {"name": "valid original window crop",
     "frame": {"width": 1920, "height": 1080, "stride": 1920*4, "crop": {"x": 100, "y": 50, "width": 320, "height": 240}}},
]
for c in cases:
    frame = c["frame"]
    x, y = read_origin(frame, *committed)
    offset = y * frame["stride"] + x * 4
    encoded_source_size = (x + committed[0], y + committed[1])
    print(f"{c['name']}")
    print(f"  crop_width x height = {frame['crop']['width']}x{frame['crop']['height']}")
    print(f"  read_origin = ({x}, {y})")
    print(f"  encoded output width x height = {committed[0]}x{committed[1]}")
    print(f"  source rectangle from crop origin is x:{x}:w{committed[0]} y:{y}:h{committed[1]}")
    print(f"  source rectangle bottom-right = {encoded_source_size}")
    print(f"  outside live crop? {encoded_source_size[0] > field_x + field_w for field_x, field_y, field_w, field_w in [0,0,20,10] if c['name']==cases[0]['name']}")
PY

Repository: getopenscreen/openscreen

Length of output: 29077


Sensitive Data Exposure (CWE-200): Exposure of Sensitive Information to an Unauthorized Actor

Reachability: External

Reachability path
● Entry
  electron/native/pipewire-capture/src/encoder.rs
│
▼
● Sink
  electron/native/pipewire-capture/src/capture.rs

Keep the live portal crop as the recording boundary.

A PipeWire window stream is monitor-sized, and SPA_META_VideoCrop is the portal-granted rectangle. Valid full-frame crop metadata is currently indistinguishable from missing or invalid metadata, and a smaller later crop is still encoded at the committed size from its origin. The current offset check only keeps swscale from reading past the buffer; it does not stop adjacent desktop pixels from being staged into the recording.

  • electron/native/pipewire-capture/src/capture.rs#L308-L337: Reject encoding a committed-size rectangle that extends beyond a valid live crop. If dimensions diverge, scale only crop pixels, add masked/pad pixels, drop the frame, or stop safely.
  • Set a crop-metadata-valid flag only when the crop actually arrived and passed bounds validation, and keep full-frame valid metadata separate from absence/invalid metadata.
  • electron/native/pipewire-capture/src/capture.rs#L663-L681: Update coverage to check that a shrunken or missing crop cannot encode pixels outside the window.
📍 Affects 4 files
  • electron/native/pipewire-capture/src/capture.rs#L308-L337 (this comment)
  • electron/native/pipewire-capture/csrc/pw_shim.c#L617-L650
  • electron/native/pipewire-capture/csrc/pw_shim.h#L88-L96
  • electron/native/pipewire-capture/src/shim.rs#L44-L48
  • electron/native/pipewire-capture/src/shim.rs#L171-L177
  • electron/native/pipewire-capture/src/shim.rs#L248-L254
  • electron/native/pipewire-capture/src/capture.rs#L609-L634
  • electron/native/pipewire-capture/src/capture.rs#L663-L681
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/native/pipewire-capture/src/capture.rs` around lines 308 - 337, Keep
the live portal crop as the recording boundary: update capture.rs:308-337 and
the stage/read_origin flow to encode only pixels within a validated crop, safely
scale, pad, drop, or stop when the committed rectangle exceeds it. Track
crop-metadata validity separately from crop dimensions, marking it valid only
after metadata arrival and bounds validation; update pw_shim.c:617-650,
pw_shim.h:88-96, and shim.rs:44-48, 171-177, 248-254 to propagate that state.
Update capture.rs:609-634 and coverage handling at capture.rs:663-681 so missing
or shrunken crops cannot report coverage that permits out-of-window pixels.

Comment on lines +509 to +512
// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Do not treat a pending portal check as Chromium fallback.

usePortalOwnsSource() returns false before its asynchronous check completes. A Linux user can click the in-app selector during that interval. The auto-start path can also open the selector when its source override is false before the portal result arrives.

Expose an unresolved state. Suppress the local selector until ownership is known. Defer or queue recording start until the check resolves.

  • src/components/launch/LaunchWindow.tsx#L509-L512: do not call openSourceSelector() while portal ownership is unresolved.
  • src/components/ai-edition/v4/RecStage.tsx#L145-L151: do not label unresolved portal ownership as a locally selected source.
  • src/components/ai-edition/v4/RecStage.tsx#L193-L226: do not render an interactive source selector while ownership is unresolved.
  • src/components/launch/LaunchWindow.test.tsx#L425-L454: add a deferred helper-availability test that clicks Record before resolution and verifies that openSourceSelector() is not called.

As per coding guidelines, add a test for every new behavior in the same package.

📍 Affects 3 files
  • src/components/launch/LaunchWindow.tsx#L509-L512 (this comment)
  • src/components/ai-edition/v4/RecStage.tsx#L145-L151
  • src/components/ai-edition/v4/RecStage.tsx#L193-L226
  • src/components/launch/LaunchWindow.test.tsx#L425-L454
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/launch/LaunchWindow.tsx` around lines 509 - 512, The
usePortalOwnsSource hook returns false before its asynchronous check completes,
and the current code treats this unresolved pending state identically to a
resolved false result. Update the sourceSelected logic in LaunchWindow.tsx (line
509-512) to expose an unresolved state from the portal ownership check, then
suppress the openSourceSelector call when ownership is still pending. Similarly
update RecStage.tsx (line 145-151) to not treat an unresolved pending state as a
locally selected source, and update RecStage.tsx (line 193-226) to conditionally
hide the interactive source selector UI when ownership is unresolved. Finally,
add a test case in LaunchWindow.test.tsx (line 425-454) that verifies
openSourceSelector is not invoked when Record is clicked before the portal
ownership check resolves, ensuring the deferred behavior works correctly.

Source: Coding guidelines

"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.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clarify the Italian accessibility instruction.

Line 46 uses the unnatural phrase accesso all'accessibilità. Replace it with wording that clearly asks the user to grant OpenScreen access to accessibility features.

Proposed wording
-		"accessibilityAllowAndRetry": "Consenti l'accesso all'accessibilità per OpenScreen, poi premi di nuovo registra per avviare il conto alla rovescia.",
+		"accessibilityAllowAndRetry": "Consenti a OpenScreen l'accesso alle funzioni di accessibilità, quindi premi di nuovo il pulsante Registra per avviare il conto alla rovescia.",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"accessibilityAllowAndRetry": "Consenti l'accesso all'accessibilità per OpenScreen, poi premi di nuovo registra per avviare il conto alla rovescia.",
"accessibilityAllowAndRetry": "Consenti a OpenScreen l'accesso alle funzioni di accessibilità, quindi premi di nuovo il pulsante Registra per avviare il conto alla rovescia.",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/i18n/locales/it/editor.json` at line 46, Update the Italian translation
value for accessibilityAllowAndRetry to replace the unnatural “accesso
all'accessibilità” wording with clear, natural language asking the user to grant
OpenScreen access to accessibility features, while preserving the rest of the
instruction.

…path

A prepared portal session could be stranded. The slot is only filled after
waitUntilSourceSelected(), which waits on a human reading a dialog, so for
that whole window it was null — a cancel found nothing to cancel and a
second prepare found nothing to supersede. The first session then assigned
itself afterwards and stayed alive, holding a ScreenCast grant and the
compositor's sharing indicator with nothing recording behind it. The
negotiation now carries a token, and a session whose token was invalidated
is discarded on arrival.

A prepared session ignored the start request. Every capture setting is
baked into the helper's spawn arguments and arm() only writes `record`, so
a microphone, system-audio or cursor-mode change made during the countdown
— the HUD does not lock those controls — was silently dropped. Worse, the
cursor mode recorded for the editor came from the start request, so the app
could believe one mode while the helper used another, and that flag decides
whether the editor draws its own pointer. The prepared session is now
rejected when its settings differ, falling through to a full start.

crop_diverged compared the raw crop against the rounded committed size, so
a window sitting stably at an odd size reported a resize on every frame.

The tray label was English in a localized UI. It now resolves through
mainT, in the `common` namespace the main process already loads.

Also: a Record click landing before the portal-ownership check resolves
opened a selector the main process refuses, and was swallowed. The refusal
is authoritative and immediate, so it starts the recording instead.
@EtienneLescot

Copy link
Copy Markdown
Collaborator Author

Four of the six were real. Addressed in 8a6123e9.

Accepted — a prepared portal session could be stranded. The most serious of the six, and correctly diagnosed. preparedLinuxCapture is only filled after waitUntilSourceSelected(), which waits on a human reading a dialog, so for that entire window the slot is null: a cancel found nothing to cancel, and a second prepare found nothing to supersede. The session then assigned itself afterwards and stayed alive, holding a ScreenCast grant with the compositor's sharing indicator on and nothing recording behind it — the exact leak the cancel path was written to prevent. The negotiation now carries a token; a session whose token was invalidated is discarded on arrival rather than assigned.

Accepted — a prepared session ignored the start request. Every capture setting is baked into the helper's spawn arguments and arm() only writes record, so a microphone, system-audio or cursor-mode change made during the countdown was silently dropped. The HUD genuinely does not lock those controls, so it is reachable. The sharper half is the one about linuxNativeCaptureCursorMode: it came from the start request, so the app could believe one cursor mode while the helper used another — and that flag decides whether the editor draws its own pointer, so the failure would surface as a double cursor in the export, far from its cause. The prepared session is now rejected when its capture settings differ, falling through to a full start.

Accepted — crop_diverged compared raw against rounded. A window sitting stably at an odd size commits 320x240 from 321x241 and then reported a resize on every frame. Comparison is now at encoded parity, with a regression test for an unchanged odd-sized crop.

Accepted — the tray label was English in a localized UI. mainT only loads common and dialogs in the main process, so the four strings went into common as recordingSource.* across all 13 locales rather than extending the namespace list.

Partly accepted — pending portal check treated as Chromium fallback. The premise needs one correction: open-source-selector already refuses on Linux when the helper is present, so no stray portal dialog can open during that interval — the main process is authoritative and answers synchronously. The real defect is narrower and worse: the click was swallowed. openSourceSelector() refused, the .then cleared the record-after-selection intent, and nothing happened at all. Rather than add an unresolved tri-state and have every surface race the same IPC, the refusal is now honoured — reason: "portal-owns-selection" starts the recording. That is correct regardless of when the local state catches up, and it is tested.

Declined — Italian accessibility wording. The wording is right, but it is not this PR's string: accessibilityAllowAndRetry comes from de2cc654 i18n: Added italian and appears in the diff only because my new key added a trailing comma to the line above it. Rewording translations I did not author, in a PR about Linux capture, is the kind of drive-by that makes a revert hard to scope. Worth a separate i18n pass.

Full suite on the rebased base: 135 files, 1609 passing, 0 failures — the previously-noted audioPeaks failure is gone now that #251 has landed. cargo test 55 passing.

@EtienneLescot
EtienneLescot merged commit 599617a into main Aug 4, 2026
14 of 15 checks passed
@EtienneLescot
EtienneLescot deleted the claude/linux-window-recording-bug-0951c1 branch August 4, 2026 13:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant