Skip to content

fix(linux/wayland): collapse 3-step capture flow into a single portal dialog (closes #268)#269

Merged
webadderall merged 2 commits intowebadderallorg:mainfrom
uriva:fix/linux-wayland-single-portal
Apr 18, 2026
Merged

fix(linux/wayland): collapse 3-step capture flow into a single portal dialog (closes #268)#269
webadderall merged 2 commits intowebadderallorg:mainfrom
uriva:fix/linux-wayland-single-portal

Conversation

@uriva
Copy link
Copy Markdown
Contributor

@uriva uriva commented Apr 18, 2026

Summary

On Linux/Wayland, starting a fullscreen recording previously required three separate picker interactions:

  1. The in-app source dropdown
  2. An xdg-desktop-portal dialog (from desktopCapturer.getSources() in the renderer)
  3. A second xdg-desktop-portal dialog (from Chromium re-prompting because pre-enumerated source IDs are stale on Wayland)

After this PR, Linux users go straight from the record button to a single OS portal dialog, matching the streamlined flow on macOS/Windows.

Fixes #268. Builds on top of #267.

Root cause

Wayland's xdg-desktop-portal model is fundamentally different from X11/macOS/Windows:

  • desktopCapturer.getSources() itself triggers a portal dialog on Wayland.
  • Source IDs returned from getSources are stale by the time Chromium tries to start the actual capture, so Chromium opens the portal again to resolve a real source.
  • Electron's useSystemPicker option (which bypasses the handler) is currently macOS-only.

Changes

  • src/components/launch/LaunchWindow.tsx: Skip the in-app source dropdown on Linux — the OS portal is the source picker.
  • src/hooks/useScreenRecorder.ts:
    • Introduce a screen:linux-portal sentinel source.
    • Route capture through navigator.mediaDevices.getDisplayMedia() (instead of getUserMedia with chromeMediaSource: 'desktop') when the sentinel is active.
    • Short-circuit resolveBrowserCaptureSource for the sentinel to avoid an extra getSources() portal trigger.
  • electron/main.ts (setDisplayMediaRequestHandler): When the sentinel is set, skip desktopCapturer.getSources() entirely and return a synthetic source — Chromium then opens the portal exactly once, for the actual capture.
  • electron/windows.ts (createEditorWindow): Also call win.show() from did-finish-load as a fallback. On some Wayland sessions ready-to-show does not fire reliably, leaving the editor window hidden after a recording finishes.

Testing

Tested locally on Linux/Wayland (Electron 39):

  • Click Record → countdown → single portal dialog → recording starts.
  • Stop recording → editor window appears with the recording loaded.
  • Verified both with and without system audio.

No changes to macOS/Windows code paths — all sentinel logic is gated on process.platform === "linux" or selectedSource.id === "screen:linux-portal".

Summary by CodeRabbit

  • Bug Fixes
    • Better screen recording on Linux/Wayland to avoid duplicate prompts and ensure capture starts reliably.
    • Ensure editor windows become visible on platforms where ready-to-show may not trigger.
    • Improved source selection and recording flow on Linux so recording can start without extra manual steps.
    • More reliable system-audio capture on Linux when using the platform capture flow.

… dialog

Previously on Linux/Wayland, starting a fullscreen recording required three
separate picker interactions: an in-app source dropdown plus two xdg-desktop-portal
dialogs. The duplicate portal dialogs were caused by:

1. resolveBrowserCaptureSource() calling desktopCapturer.getSources() in the
   renderer, which itself triggers the portal on Wayland.
2. setDisplayMediaRequestHandler() in main also calling getSources(), which
   triggers another portal.
3. Returning a pre-enumerated source id to Chromium, which on Wayland is stale
   and forces Chromium to re-prompt via the portal during MediaStream creation.

Additionally, the editor window failed to appear after recording on some
Wayland sessions because 'ready-to-show' did not fire reliably.

Changes:
- LaunchWindow: skip the in-app source dropdown on Linux and start recording
  directly; the OS portal becomes the source picker.
- useScreenRecorder: introduce a 'screen:linux-portal' sentinel source. When
  set, route capture through navigator.mediaDevices.getDisplayMedia() so the
  portal handles selection in a single dialog. Skip resolveBrowserCaptureSource
  for the sentinel to avoid an extra getSources() call.
- electron/main: in setDisplayMediaRequestHandler, when the sentinel is set,
  skip desktopCapturer.getSources() entirely and return a synthetic source so
  Chromium opens the portal exactly once for the actual capture.
- electron/windows: in createEditorWindow, also call win.show() from
  did-finish-load as a fallback for Linux/Wayland where ready-to-show may
  not fire.
@github-actions
Copy link
Copy Markdown
Contributor

⚠️ This pull request has been flagged by Anti-Slop.
Our automated checks detected patterns commonly associated with
low-quality or automated/AI submissions (failure count reached).
No automatic closure — a maintainer will review it.
If this is legitimate work, please add more context, link issues, or ping us.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 18, 2026

📝 Walkthrough

Walkthrough

Introduces a Linux/Wayland "portal sentinel" flow: auto-selects a synthetic source on Linux, bypasses desktopCapturer enumeration and in-app picker, routes capture via getDisplayMedia for portal-compatible grants, and forces editor window visibility post-load on problematic platforms.

Changes

Cohort / File(s) Summary
Electron main (portal sentinel)
electron/main.ts
Added Linux/Wayland branch in session.defaultSession.setDisplayMediaRequestHandler that detects screen:linux-portal (or falsy selected id) and returns a synthetic capture source instead of calling desktopCapturer.getSources.
Window visibility fallback
electron/windows.ts
After did-finish-load, added a fallback to log and call win.show() if the editor window is not destroyed but still hidden, addressing cases where ready-to-show may not fire.
Launch UI behavior
src/components/launch/LaunchWindow.tsx
Hides idle source selector on Linux and changes record-button handler to call toggleRecording when on Linux or when a source is already selected, avoiding opening the in-app sources dropdown on Linux.
Recorder logic & capture paths
src/hooks/useScreenRecorder.ts
Added sentinel handling in resolveBrowserCaptureSource, auto-selects/persists a synthetic screen:linux-portal on Linux, and switches Linux sentinel flows to use navigator.mediaDevices.getDisplayMedia (with audio handling) instead of the previous getUserMedia desktop-capture constraints.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant UI as LaunchWindow
    participant Recorder as useScreenRecorder
    participant IPC as Electron Main
    participant Browser as Browser API
    participant OS as OS Portal

    rect rgba(200,100,100,0.5)
    Note over User,OS: Old Linux/Wayland Flow (multiple picker steps)
    User->>UI: Click Record (no source)
    UI->>Recorder: openSourcesDropdown()
    Recorder->>IPC: getSources()
    IPC->>OS: portal dialog (xdg-desktop-portal)
    OS->>IPC: user selects screen
    IPC->>UI: sources list
    UI->>User: show in-app dropdown
    User->>UI: pick source
    UI->>Recorder: startRecording(selected)
    Recorder->>Browser: getDisplayMedia() / getUserMedia()
    Browser->>OS: portal dialog (second)
    OS->>Browser: grant
    Browser->>Recorder: stream
    end

    rect rgba(100,200,100,0.5)
    Note over User,OS: New Linux/Wayland Flow (single portal)
    User->>UI: Click Record (no source)
    UI->>Recorder: startRecording()
    Recorder->>Recorder: auto-select sentinel (screen:linux-portal)
    Recorder->>Browser: getDisplayMedia(constraints)
    Browser->>OS: single portal dialog
    OS->>Browser: grant
    Browser->>Recorder: stream
    Recorder->>Recorder: start recording
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • webadderall/Recordly#267 — Related Linux screen-source resolution and Wayland handling; overlaps portal/sentinel reasoning.
  • webadderall/Recordly#264 — Touches electron/windows.ts fallback for window visibility on Linux/Wayland.
  • webadderall/Recordly#259 — Modifies src/hooks/useScreenRecorder.ts capture logic and audio handling (closely related to this PR's changes).

Suggested labels

Checked

Poem

🐰 A little hop for code and glee,
I found a sentinel under Wayland's tree.
One click, one portal, no triple chore—
The screen says "capture!" and off we soar. 🎬

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately captures the main change: collapsing the 3-step capture flow into a single portal dialog on Linux/Wayland and references the fixed issue #268.
Description check ✅ Passed The description includes key sections (Summary, Root cause, Changes, Testing), clearly explains the problem, solution, and testing approach. While missing some template elements (Type of Change checklist), the core required information is present.
Linked Issues check ✅ Passed The PR addresses issue #268's requirement to collapse the 3-step recording flow into a single portal dialog by introducing a Linux portal sentinel, hiding the in-app dropdown on Linux, routing through getDisplayMedia(), and skipping getSources() in the handler.
Out of Scope Changes check ✅ Passed All changes are scoped to addressing the Linux/Wayland portal flow issue [#268]. The editor window visibility fix in windows.ts is a related Wayland platform issue and not out-of-scope.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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 and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

⚠️ This pull request has been flagged by Anti-Slop.
Our automated checks detected patterns commonly associated with
low-quality or automated/AI submissions (failure count reached).
No automatic closure — a maintainer will review it.
If this is legitimate work, please add more context, link issues, or ping us.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (3)
src/components/launch/LaunchWindow.tsx (1)

1094-1110: ⚠️ Potential issue | 🟠 Major

The source-selector button is still rendered on Linux and can re-introduce an extra portal dialog.

The record-button branch correctly delegates to toggleRecording on Linux, but the screen/source selector button (lines 1096–1110) is still visible on Linux. If a user clicks it, toggleDropdown("sources") calls fetchSources() (line 858) → window.electronAPI.getSources(...), which on Wayland triggers an xdg-desktop-portal dialog purely to populate the in-app dropdown. That directly reintroduces the extra portal the PR is meant to eliminate (and the entries it surfaces are the stale IDs called out in #268).

Per the PR objectives ("Eliminate the in-app source picker on Wayland"), consider hiding this control on Linux, e.g.:

♻️ Proposed fix
-			<button
-				type="button"
-				className={`${styles.screenSel} ${styles.electronNoDrag}`}
-				onClick={() => toggleDropdown("sources")}
-				title={selectedSource}
-			>
-				<Monitor size={16} />
-				<ContentClamp className={styles.sourceLabel} truncateLength={36}>
-					{selectedSource}
-				</ContentClamp>
-				<ChevronUp
-					size={10}
-					className={`text-[`#6b6b78`] ml-0.5 transition-transform duration-200 ${activeDropdown === "sources" ? "" : "rotate-180"}`}
-				/>
-			</button>
-
-			<Separator />
+			{platform !== "linux" && (
+				<>
+					<button
+						type="button"
+						className={`${styles.screenSel} ${styles.electronNoDrag}`}
+						onClick={() => toggleDropdown("sources")}
+						title={selectedSource}
+					>
+						<Monitor size={16} />
+						<ContentClamp className={styles.sourceLabel} truncateLength={36}>
+							{selectedSource}
+						</ContentClamp>
+						<ChevronUp
+							size={10}
+							className={`text-[`#6b6b78`] ml-0.5 transition-transform duration-200 ${activeDropdown === "sources" ? "" : "rotate-180"}`}
+						/>
+					</button>
+					<Separator />
+				</>
+			)}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/launch/LaunchWindow.tsx` around lines 1094 - 1110, The
source-selector button inside idleControls (the JSX block rendering the button
that calls onClick={() => toggleDropdown("sources")}) must be hidden on Linux to
avoid triggering fetchSources() → window.electronAPI.getSources(...) (which
spawns xdg-desktop-portal on Wayland); update the conditional rendering so this
button is only rendered when the platform is not Linux (use your existing
platform helper or process.platform !== 'linux') and leave the toggleRecording
logic for Linux unchanged; ensure you reference the same symbols (idleControls,
toggleDropdown("sources"), fetchSources, window.electronAPI.getSources,
selectedSource, activeDropdown) so the button is excluded on Linux while keeping
behavior identical on other OSes.
src/hooks/useScreenRecorder.ts (1)

838-848: ⚠️ Potential issue | 🟠 Major

Renderer-only sentinel synthesis — may not be visible to the main process's display-media handler.

This synthesizes { id: "screen:linux-portal", name: "Linux Portal" } locally but doesn't call window.electronAPI.selectSource(selectedSource) to tell the main process. The setDisplayMediaRequestHandler in electron/main.ts (lines 892–924) gates the single-portal bypass on getSelectedSourceId() === "screen:linux-portal", so unless that state is populated elsewhere, the bypass won't engage on a fresh session. See the related comment on electron/main.ts — please either persist the sentinel here before getDisplayMedia is invoked, or make the main-side default kick in when process.platform === "linux" and no source has been selected.

Also: "Linux Portal" is hardcoded English. If this name can ever surface in the UI (e.g. via the selectedSource label in LaunchWindow.tsx), it should be routed through the i18n scope the rest of recording labels use.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useScreenRecorder.ts` around lines 838 - 848, The code in
useScreenRecorder.ts creates a local sentinel selectedSource ({ id:
"screen:linux-portal", name: "Linux Portal" }) but never informs the main
process, so electron/main.ts's setDisplayMediaRequestHandler (which checks
getSelectedSourceId() === "screen:linux-portal") won't see it; before calling
getDisplayMedia (or when assigning selectedSource), call
window.electronAPI.selectSource(selectedSource) to persist the sentinel in the
main process, and ensure the label uses the app i18n string rather than the
hardcoded "Linux Portal" (route through the same i18n scope used by other
recording labels). Also consider the alternative fix noted in the review: update
the main-side handler to default to the portal on linux when no selected source
exists by checking process.platform === "linux" and getSelectedSourceId() is
null.
electron/main.ts (1)

892-924: ⚠️ Potential issue | 🔴 Critical

Verify the sentinel ever reaches getSelectedSourceId() on Linux — the fix is ineffective on a fresh session.

This handler only triggers the single-portal path when getSelectedSourceId() === "screen:linux-portal". However, selectedSource initializes to null (electron/ipc/state.ts:16) and is only set via the "select-source" IPC handler when handleSourceSelect is called.

The problem: on Linux, the dropdown is always skipped (LaunchWindow.tsx:1148: hasSelectedSource || platform === "linux"), so handleSourceSelect never runs and selectSource is never invoked. Meanwhile, in useScreenRecorder.ts:843, the sentinel is synthesized locally inside startRecording but is never persisted to main:

const selectedSource =
    existingSource ??
    (platform === "linux"
        ? { id: "screen:linux-portal", name: "Linux Portal" }
        : null);

There is no corresponding window.electronAPI.selectSource(selectedSource) call.

On a fresh session, getSelectedSourceId() returns null (not "screen:linux-portal"), isLinuxPortalSentinel evaluates to false, the handler falls through to desktopCapturer.getSources() — the exact call that triggers the xdg-desktop-portal dialog and produces stale IDs — and you'll hit the double-portal behavior #268 is trying to prevent.

The fix requires either:

  1. Send the sentinel to main via window.electronAPI.selectSource(selectedSource) before invoking getDisplayMedia on Linux, or
  2. Default to the sentinel in the main handler when process.platform === "linux" and sourceId is null.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@electron/main.ts` around lines 892 - 924, The Linux sentinel
("screen:linux-portal") never reaches getSelectedSourceId() on fresh sessions,
so setDisplayMediaRequestHandler falls back to desktopCapturer.getSources() and
triggers a double portal; fix by ensuring the sentinel is persisted to main
before calling getDisplayMedia on Linux: in useScreenRecorder.startRecording (or
the code path that builds selectedSource) call
window.electronAPI.selectSource(selectedSource) when platform === "linux" and
selectedSource.id === "screen:linux-portal" so the main IPC selectSource handler
(handleSourceSelect) updates selectedSource used by getSelectedSourceId();
alternatively, if you prefer a main-side change, modify
session.defaultSession.setDisplayMediaRequestHandler to treat null sourceId on
process.platform === "linux" as the sentinel (i.e., if process.platform ===
"linux" && !sourceId then act like sourceId === "screen:linux-portal").
🧹 Nitpick comments (1)
src/hooks/useScreenRecorder.ts (1)

1031-1103: Heavy duplication across the three Linux-portal getDisplayMedia branches.

The three getDisplayMedia({ ..., video: { displaySurface: "monitor", width, height, frameRate, cursor: "never" }, selfBrowserSurface: "exclude", surfaceSwitching: "exclude" }) calls at lines 1037–1049, 1067–1079, and 1086–1098 differ only in the audio boolean. Extracting a small helper removes ~30 lines and makes the constraint set easier to keep in sync with the non-audio fallback at lines 1172–1185 (which currently also duplicates the same shape).

♻️ Proposed refactor
+			const acquireLinuxPortalStream = (withAudio: boolean) =>
+				mediaDevices.getDisplayMedia({
+					audio: withAudio,
+					video: {
+						displaySurface: "monitor",
+						width: { ideal: TARGET_WIDTH, max: TARGET_WIDTH },
+						height: { ideal: TARGET_HEIGHT, max: TARGET_HEIGHT },
+						frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE },
+						cursor: "never",
+					},
+					selfBrowserSurface: "exclude",
+					surfaceSwitching: "exclude",
+				});
+
 			if (wantsAudioCapture) {
 				let screenMediaStream: MediaStream;
 				const useLinuxPortal = selectedSource.id === "screen:linux-portal";

 				if (systemAudioEnabled) {
 					try {
 						screenMediaStream = useLinuxPortal
-							? await mediaDevices.getDisplayMedia({ /* audio: true ... */ })
+							? await acquireLinuxPortalStream(true)
 							: await mediaDevices.getUserMedia({ /* ... */ });
 					} catch (audioError) {
 						...
 						screenMediaStream = useLinuxPortal
-							? await mediaDevices.getDisplayMedia({ /* audio: false ... */ })
+							? await acquireLinuxPortalStream(false)
 							: await mediaDevices.getUserMedia({ /* ... */ });
 					}
 				} else {
 					screenMediaStream = useLinuxPortal
-						? await mediaDevices.getDisplayMedia({ /* audio: false ... */ })
+						? await acquireLinuxPortalStream(false)
 						: await mediaDevices.getUserMedia({ /* ... */ });
 				}

Separately — worth a thought but not a blocker: on Wayland, the audio: true portal request will frequently be rejected (many portal backends still don't implement system audio), and your catch falls through to a second getDisplayMedia({ audio: false }), which means the user sees two portal dialogs on failure. If you have a way to probe capability (or a setting for "try system audio on Wayland"), wiring that in would keep the "exactly one portal" promise even in the failure path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useScreenRecorder.ts` around lines 1031 - 1103, Duplicate
getDisplayMedia constraint objects for the Linux portal are used in
useScreenRecorder (inside the wantsAudioCapture branch where useLinuxPortal is
computed and in the systemAudioEnabled catch/fallback and the else branch);
extract a small helper function (e.g., buildLinuxDisplayMediaConstraints or
getLinuxDisplayMedia) that accepts an audio boolean and returns the shared {
audio, video: { displaySurface: "monitor", width: { ideal: TARGET_WIDTH, max:
TARGET_WIDTH }, height: { ideal: TARGET_HEIGHT, max: TARGET_HEIGHT }, frameRate:
{ ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE }, cursor: "never" },
selfBrowserSurface: "exclude", surfaceSwitching: "exclude" } shape, then replace
the three direct mediaDevices.getDisplayMedia({...}) calls with
mediaDevices.getDisplayMedia(getLinuxDisplayMedia(true/false)); keep references
to systemAudioEnabled, useLinuxPortal,
TARGET_WIDTH/TARGET_HEIGHT/TARGET_FRAME_RATE and browserScreenVideoConstraints
intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/launch/LaunchWindow.tsx`:
- Around line 1147-1151: The onClick currently branches on hasSelectedSource ||
platform === "linux" but platform is initialized as null and populated async, so
add an explicit check for platform === null to force the non-Linux path until
platform is known; update the handler that references toggleRecording and
toggleDropdown so it first returns () => toggleDropdown("sources") when platform
=== null (or otherwise defer action) to avoid the race where a click before
platform resolution takes the Linux branch.

---

Outside diff comments:
In `@electron/main.ts`:
- Around line 892-924: The Linux sentinel ("screen:linux-portal") never reaches
getSelectedSourceId() on fresh sessions, so setDisplayMediaRequestHandler falls
back to desktopCapturer.getSources() and triggers a double portal; fix by
ensuring the sentinel is persisted to main before calling getDisplayMedia on
Linux: in useScreenRecorder.startRecording (or the code path that builds
selectedSource) call window.electronAPI.selectSource(selectedSource) when
platform === "linux" and selectedSource.id === "screen:linux-portal" so the main
IPC selectSource handler (handleSourceSelect) updates selectedSource used by
getSelectedSourceId(); alternatively, if you prefer a main-side change, modify
session.defaultSession.setDisplayMediaRequestHandler to treat null sourceId on
process.platform === "linux" as the sentinel (i.e., if process.platform ===
"linux" && !sourceId then act like sourceId === "screen:linux-portal").

In `@src/components/launch/LaunchWindow.tsx`:
- Around line 1094-1110: The source-selector button inside idleControls (the JSX
block rendering the button that calls onClick={() => toggleDropdown("sources")})
must be hidden on Linux to avoid triggering fetchSources() →
window.electronAPI.getSources(...) (which spawns xdg-desktop-portal on Wayland);
update the conditional rendering so this button is only rendered when the
platform is not Linux (use your existing platform helper or process.platform !==
'linux') and leave the toggleRecording logic for Linux unchanged; ensure you
reference the same symbols (idleControls, toggleDropdown("sources"),
fetchSources, window.electronAPI.getSources, selectedSource, activeDropdown) so
the button is excluded on Linux while keeping behavior identical on other OSes.

In `@src/hooks/useScreenRecorder.ts`:
- Around line 838-848: The code in useScreenRecorder.ts creates a local sentinel
selectedSource ({ id: "screen:linux-portal", name: "Linux Portal" }) but never
informs the main process, so electron/main.ts's setDisplayMediaRequestHandler
(which checks getSelectedSourceId() === "screen:linux-portal") won't see it;
before calling getDisplayMedia (or when assigning selectedSource), call
window.electronAPI.selectSource(selectedSource) to persist the sentinel in the
main process, and ensure the label uses the app i18n string rather than the
hardcoded "Linux Portal" (route through the same i18n scope used by other
recording labels). Also consider the alternative fix noted in the review: update
the main-side handler to default to the portal on linux when no selected source
exists by checking process.platform === "linux" and getSelectedSourceId() is
null.

---

Nitpick comments:
In `@src/hooks/useScreenRecorder.ts`:
- Around line 1031-1103: Duplicate getDisplayMedia constraint objects for the
Linux portal are used in useScreenRecorder (inside the wantsAudioCapture branch
where useLinuxPortal is computed and in the systemAudioEnabled catch/fallback
and the else branch); extract a small helper function (e.g.,
buildLinuxDisplayMediaConstraints or getLinuxDisplayMedia) that accepts an audio
boolean and returns the shared { audio, video: { displaySurface: "monitor",
width: { ideal: TARGET_WIDTH, max: TARGET_WIDTH }, height: { ideal:
TARGET_HEIGHT, max: TARGET_HEIGHT }, frameRate: { ideal: TARGET_FRAME_RATE, max:
TARGET_FRAME_RATE }, cursor: "never" }, selfBrowserSurface: "exclude",
surfaceSwitching: "exclude" } shape, then replace the three direct
mediaDevices.getDisplayMedia({...}) calls with
mediaDevices.getDisplayMedia(getLinuxDisplayMedia(true/false)); keep references
to systemAudioEnabled, useLinuxPortal,
TARGET_WIDTH/TARGET_HEIGHT/TARGET_FRAME_RATE and browserScreenVideoConstraints
intact.
🪄 Autofix (Beta)

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ba1e6910-8491-412e-9515-0dc5266b2a54

📥 Commits

Reviewing files that changed from the base of the PR and between 8cefcea and 571bbb9.

📒 Files selected for processing (4)
  • electron/main.ts
  • electron/windows.ts
  • src/components/launch/LaunchWindow.tsx
  • src/hooks/useScreenRecorder.ts

Comment on lines +1147 to +1151
onClick={
hasSelectedSource || platform === "linux"
? toggleRecording
: () => toggleDropdown("sources")
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Minor: the platform === "linux" branch fires before platform is known.

platform starts as null and is populated asynchronously by the effect at lines 644–658. That's fine here (clicking record with null platform just opens the dropdown, and after platform resolves the Linux branch takes over), but if you want this deterministic you could also early-return when platform === null to avoid a subtle window where a Linux user who clicks immediately on launch gets the non-Linux branch. Low priority.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/launch/LaunchWindow.tsx` around lines 1147 - 1151, The onClick
currently branches on hasSelectedSource || platform === "linux" but platform is
initialized as null and populated async, so add an explicit check for platform
=== null to force the non-Linux path until platform is known; update the handler
that references toggleRecording and toggleDropdown so it first returns () =>
toggleDropdown("sources") when platform === null (or otherwise defer action) to
avoid the race where a click before platform resolution takes the Linux branch.

@webadderall webadderall removed the Slop label Apr 18, 2026
@webadderall
Copy link
Copy Markdown
Collaborator

please address coderabbit review findings

- electron/main.ts: treat missing sourceId on linux as portal sentinel
  so the request handler never calls getSources() (which itself opens
  an extra portal dialog) on fresh sessions.
- useScreenRecorder.ts: persist the synthesized portal sentinel via
  selectSource() so main has the source set before getDisplayMedia.
  Extract acquireLinuxPortalStream() helper to dedupe the three
  duplicated getDisplayMedia constraint blocks.
- LaunchWindow.tsx: hide the screen-source selector button (and its
  separator) on Linux so users cannot trigger an extra portal dialog
  via the dropdown.
@uriva
Copy link
Copy Markdown
Contributor Author

uriva commented Apr 18, 2026

Addressed review feedback in b200ded:

  • electron/main.ts: setDisplayMediaRequestHandler now also treats a missing sourceId on Linux as the portal sentinel, so the short-circuit triggers on fresh sessions where the renderer hasn't (yet) persisted a selection.
  • useScreenRecorder.ts: After synthesizing the Linux portal sentinel, the renderer now calls electronAPI.selectSource(selectedSource) so main is consistent with the renderer state. Also extracted acquireLinuxPortalStream(withAudio) helper to dedupe the three identical getDisplayMedia constraint blocks.
  • LaunchWindow.tsx: Hidden the screen-source selector button (and its separator) on Linux so users cannot trigger an extra portal dialog through the dropdown.

@github-actions github-actions Bot added the Slop label Apr 18, 2026
@github-actions
Copy link
Copy Markdown
Contributor

⚠️ This pull request has been flagged by Anti-Slop.
Our automated checks detected patterns commonly associated with
low-quality or automated/AI submissions (failure count reached).
No automatic closure — a maintainer will review it.
If this is legitimate work, please add more context, link issues, or ping us.

1 similar comment
@github-actions
Copy link
Copy Markdown
Contributor

⚠️ This pull request has been flagged by Anti-Slop.
Our automated checks detected patterns commonly associated with
low-quality or automated/AI submissions (failure count reached).
No automatic closure — a maintainer will review it.
If this is legitimate work, please add more context, link issues, or ping us.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
src/hooks/useScreenRecorder.ts (2)

1043-1092: Reuse acquireLinuxPortalStream in the no-audio branch to avoid constraint drift.

acquireLinuxPortalStream is defined inside the wantsAudioCapture branch and only used there. The no-audio branch (lines 1161-1175) independently builds an almost-identical getDisplayMedia call. For the sentinel today both paths behave the same (displaySurface resolves to "monitor" for "screen:linux-portal"), but keeping two copies means future tweaks to portal-capture constraints (framerate, cursor, selfBrowserSurface, etc.) must be applied twice or they'll silently diverge.

Consider hoisting the helper above the wantsAudioCapture check and routing the no-audio sentinel path through it as well.

♻️ Sketch
+            const useLinuxPortal = selectedSource.id === "screen:linux-portal";
+            const acquireLinuxPortalStream = (withAudio: boolean) =>
+                mediaDevices.getDisplayMedia({
+                    audio: withAudio,
+                    video: {
+                        displaySurface: "monitor",
+                        width: { ideal: TARGET_WIDTH, max: TARGET_WIDTH },
+                        height: { ideal: TARGET_HEIGHT, max: TARGET_HEIGHT },
+                        frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE },
+                        cursor: "never",
+                    },
+                    selfBrowserSurface: "exclude",
+                    surfaceSwitching: "exclude",
+                });
+
             if (wantsAudioCapture) {
                 let screenMediaStream: MediaStream;
-                const useLinuxPortal = selectedSource.id === "screen:linux-portal";
-                const acquireLinuxPortalStream = (withAudio: boolean) => ...
                 ...
             } else {
-                const mediaStream = await mediaDevices.getDisplayMedia({ ... });
+                const mediaStream = useLinuxPortal
+                    ? await acquireLinuxPortalStream(false)
+                    : await mediaDevices.getDisplayMedia({ ... existing non-sentinel constraints ... });
                 stream.current = mediaStream;
                 videoTrack = mediaStream.getVideoTracks()[0];
             }

Also applies to: 1161-1179

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useScreenRecorder.ts` around lines 1043 - 1092, The no-audio branch
duplicates the getDisplayMedia constraints instead of reusing the helper; hoist
the acquireLinuxPortalStream function (currently defined near useLinuxPortal)
above the systemAudioEnabled/wantsAudioCapture check and replace both places
that build the Linux portal getDisplayMedia call with calls to
acquireLinuxPortalStream(withAudio: boolean) (use useLinuxPortal to choose
between acquireLinuxPortalStream(...) and mediaDevices.getUserMedia(...)),
removing the duplicated getDisplayMedia block so all portal-specific constraints
(displaySurface, width/height/frameRate, cursor, selfBrowserSurface,
surfaceSwitching) are maintained in one place for both audio and no-audio flows.

838-858: Consider improving the user-facing name for the Linux portal sentinel.

The synthetic sentinel's name of "Linux Portal" will display in the tray tooltip as "Recording: Linux Portal". Something like "Screen (via portal)" would be clearer to end users. The { id, name } literal structure is already type-safe — ProcessedDesktopSource contains only these two fields, so no undefined values are passed through the IPC boundary.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useScreenRecorder.ts` around lines 838 - 858, The synthetic Linux
portal sentinel uses a user-facing name "Linux Portal" which can be unclear;
update the created ProcessedDesktopSource literal in useScreenRecorder (where
selectedSource is set when existingSource is null and platform === "linux") to
use a clearer name such as "Screen (via portal)" while keeping the id
"screen:linux-portal", and ensure the same name is passed when persisting via
window.electronAPI.selectSource so the tray tooltip shows the improved label.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/hooks/useScreenRecorder.ts`:
- Around line 1043-1092: The no-audio branch duplicates the getDisplayMedia
constraints instead of reusing the helper; hoist the acquireLinuxPortalStream
function (currently defined near useLinuxPortal) above the
systemAudioEnabled/wantsAudioCapture check and replace both places that build
the Linux portal getDisplayMedia call with calls to
acquireLinuxPortalStream(withAudio: boolean) (use useLinuxPortal to choose
between acquireLinuxPortalStream(...) and mediaDevices.getUserMedia(...)),
removing the duplicated getDisplayMedia block so all portal-specific constraints
(displaySurface, width/height/frameRate, cursor, selfBrowserSurface,
surfaceSwitching) are maintained in one place for both audio and no-audio flows.
- Around line 838-858: The synthetic Linux portal sentinel uses a user-facing
name "Linux Portal" which can be unclear; update the created
ProcessedDesktopSource literal in useScreenRecorder (where selectedSource is set
when existingSource is null and platform === "linux") to use a clearer name such
as "Screen (via portal)" while keeping the id "screen:linux-portal", and ensure
the same name is passed when persisting via window.electronAPI.selectSource so
the tray tooltip shows the improved label.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fb707694-3d73-468f-8571-c2e529fc8ad3

📥 Commits

Reviewing files that changed from the base of the PR and between 571bbb9 and b200ded.

📒 Files selected for processing (3)
  • electron/main.ts
  • src/components/launch/LaunchWindow.tsx
  • src/hooks/useScreenRecorder.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/components/launch/LaunchWindow.tsx

@webadderall webadderall merged commit e4a780a into webadderallorg:main Apr 18, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Linux/Wayland: fullscreen recording requires 3 picker steps (2 OS dialogs + 1 in-app dropdown) before it starts

2 participants