Skip to content

fix(linux): fallback to desktop capture when getDisplayMedia fails#370

Merged
webadderall merged 2 commits intowebadderallorg:mainfrom
dexisback:fix/linux-capture-fallback
May 1, 2026
Merged

fix(linux): fallback to desktop capture when getDisplayMedia fails#370
webadderall merged 2 commits intowebadderallorg:mainfrom
dexisback:fix/linux-capture-fallback

Conversation

@dexisback
Copy link
Copy Markdown
Contributor

@dexisback dexisback commented Apr 27, 2026

Description

Adds a fallback for Linux when getDisplayMedia fails, ensuring screen recording starts reliably instead of failing after the countdown.

Motivation

On Linux, getDisplayMedia can fail due to portal (PipeWire) issues. When this happens, recording does not start after the countdown.

This change ensures recording still starts by falling back to Electron's desktop capture (getUserMedia with chromeMediaSource).

Notes

  • Audio is intentionally disabled in the fallback due to platform limitations on Linux
  • Existing behavior remains unchanged when getDisplayMedia succeeds

Type of Change

  • New Feature
  • Bug Fix
  • Refactor / Code Cleanup
  • Documentation Update
  • Other (please specify)

Related Issue(s)

N/A

Screenshots / Video

N/A

Testing Guide

  1. Run the app on Linux
  2. Start a screen recording
  3. If getDisplayMedia fails (portal issue), the app should fall back automatically
  4. Recording should start and video should be saved successfully

Expected:

  • Recording starts reliably
  • Video capture works
  • No crash when portal fails

Checklist

  • I have performed a self-review of my code.
  • I have added any necessary screenshots or videos.
  • I have linked related issue(s) and updated the changelog if applicable.

Summary by CodeRabbit

  • Bug Fixes
    • Improved screen recording reliability on Linux by strengthening capture acquisition with clearer handling when audio is requested.
    • Added safer fallbacks and more informative warnings/alerts when initial capture attempts fail, reducing failed or silent recording attempts.
    • Simplified capture flow so non-audio sessions more consistently select an available screen source.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 27, 2026

📝 Walkthrough

Walkthrough

The screen capture flow in useScreenRecorder.ts was refactored: it now tries navigator.mediaDevices.getDisplayMedia (with requested audio), and on failure logs/warns, optionally alerts when audio was requested, calls window.electronAPI.getSources({ types: ["screen"] }), and falls back to navigator.mediaDevices.getUserMedia with Chrome desktop-capture constraints (audio disabled).

Changes

Cohort / File(s) Summary
Linux Display Media Capture Flow
src/hooks/useScreenRecorder.ts
Refactored Linux/portal acquisition into an async helper: attempt getDisplayMedia with audio flag; on throw, log/warn (and alert when audio was requested), fetch window.electronAPI.getSources({ types: ["screen"] }), error if none, then fallback to navigator.mediaDevices.getUserMedia using video constraints with chromeMediaSource: "desktop" and audio: false. Removed the previous direct non-audio getDisplayMedia branch so remaining logic is guarded by wantsAudioCapture.

Sequence Diagram(s)

sequenceDiagram
    participant Hook as useScreenRecorder Hook
    participant DM as navigator.mediaDevices.getDisplayMedia
    participant EA as window.electronAPI.getSources
    participant UM as navigator.mediaDevices.getUserMedia

    Hook->>DM: Attempt getDisplayMedia({ audio: wantsAudio, video: { displaySurface: "monitor", ... } })
    alt Success
        DM-->>Hook: Return stream
    else Failure
        DM-->>Hook: Throw/Error
        Hook->>Hook: log.warn / optional alert if wantsAudio
        Hook->>EA: getSources({ types: ["screen"] })
        EA-->>Hook: Return sources[]
        alt sources.length > 0
            Hook->>UM: getUserMedia({ audio: false, video: { chromeMediaSource: "desktop", chromeMediaSourceId: selectedId } })
            UM-->>Hook: Return fallback stream
        else No sources
            Hook-->>Hook: Throw error (no screen sources)
        end
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 I hopped through portals, caught a glare,

Tried display media, then fetched sources with care,
If audio trembles, I warn and then mend,
Fallback to desktop so capture won't end,
A rabbit's soft cheer for a streaming amend 🥕

🚥 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 and concisely summarizes the main change: adding a fallback to desktop capture when getDisplayMedia fails on Linux.
Description check ✅ Passed The description is comprehensive and follows the template structure with all key sections completed: purpose, motivation, type of change, testing guide, and checklist.
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.

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

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

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

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: 2

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

1159-1170: Drop the as any cast here.

mediaDevices is already typed as DesktopCaptureMediaDevices, so this cast is just suppressing the Line 1170 Biome error and hiding constraint-shape mistakes. Use mediaDevices.getUserMedia(...) directly, or add a small local constraint type for the desktop-capture payload.

Small cleanup
- return await navigator.mediaDevices.getUserMedia({
+ return await mediaDevices.getUserMedia({
    audio: false,
    video: {
      mandatory: {
-       chromeMediaSource: "desktop",
+       chromeMediaSource: CHROME_MEDIA_SOURCE,
        chromeMediaSourceId: source.id,
        maxWidth: TARGET_WIDTH,
        maxHeight: TARGET_HEIGHT,
        maxFrameRate: TARGET_FRAME_RATE,
      },
    },
- } as any);
+ });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useScreenRecorder.ts` around lines 1159 - 1170, Remove the "as any"
cast on the navigator.mediaDevices.getUserMedia call in useScreenRecorder.ts;
instead construct the constraints as the proper desktop-capture type (or a small
local type alias) and pass that object directly to
navigator.mediaDevices.getUserMedia so the compiler can validate the shape—use
the existing fields (video.mandatory with chromeMediaSource,
chromeMediaSourceId: source.id, maxWidth: TARGET_WIDTH, maxHeight:
TARGET_HEIGHT, maxFrameRate: TARGET_FRAME_RATE and audio: false) and ensure the
call matches the DesktopCaptureMediaDevices.getUserMedia signature.
🤖 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/hooks/useScreenRecorder.ts`:
- Around line 1148-1160: In the catch block that falls back to desktop capture
inside useScreenRecorder (after window.electronAPI.getSources and before
returning navigator.mediaDevices.getUserMedia), detect if systemAudioEnabled is
true and surface the downgrade by emitting the same warning/notification used
elsewhere (e.g., call the existing audio error handler or invoke the component's
notify/log path) so users are informed that system audio was dropped; reference
the systemAudioEnabled flag and the fallback flow (window.electronAPI.getSources
/ navigator.mediaDevices.getUserMedia) and ensure you call the same handler used
by the outer catch (audioError) or dispatch a specific "system-audio-downgraded"
event so behavior is consistent.
- Around line 1131-1172: The Linux portal fallback logic is defined only inside
the wantsAudioCapture branch so when selectedSource.id === "screen:linux-portal"
but wantsAudioCapture is false the code still uses the old getDisplayMedia path
and hits the PipeWire/portal error; move the acquireLinuxPortalStream function
(and its fallback to navigator.mediaDevices.getUserMedia +
electronAPI.getSources) out of the if (wantsAudioCapture) block so it is defined
at the same scope as screenMediaStream and then call
acquireLinuxPortalStream(withAudio) whenever selectedSource.id ===
"screen:linux-portal" regardless of wantsAudioCapture (use withAudio =
wantsAudioCapture) — update any references in this file
(acquireLinuxPortalStream, selectedSource.id, wantsAudioCapture,
screenMediaStream) so both audio-on and audio-off Linux screen captures go
through the portal fallback path.

---

Nitpick comments:
In `@src/hooks/useScreenRecorder.ts`:
- Around line 1159-1170: Remove the "as any" cast on the
navigator.mediaDevices.getUserMedia call in useScreenRecorder.ts; instead
construct the constraints as the proper desktop-capture type (or a small local
type alias) and pass that object directly to navigator.mediaDevices.getUserMedia
so the compiler can validate the shape—use the existing fields (video.mandatory
with chromeMediaSource, chromeMediaSourceId: source.id, maxWidth: TARGET_WIDTH,
maxHeight: TARGET_HEIGHT, maxFrameRate: TARGET_FRAME_RATE and audio: false) and
ensure the call matches the DesktopCaptureMediaDevices.getUserMedia signature.
🪄 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 Plus

Run ID: 79bc18dd-f6a1-42ce-abf9-696f4270d109

📥 Commits

Reviewing files that changed from the base of the PR and between a4f4752 and 39673f1.

📒 Files selected for processing (1)
  • src/hooks/useScreenRecorder.ts

Comment thread src/hooks/useScreenRecorder.ts Outdated
Comment on lines +1131 to +1172
if (wantsAudioCapture) {
let screenMediaStream: MediaStream;
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",
});
const acquireLinuxPortalStream = async (withAudio: boolean): Promise<MediaStream> => {
try {
return await 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",
});
} catch (err) {
console.warn("Linux portal failed, falling back to desktop capture:", err);

const sources = await window.electronAPI.getSources({ types: ["screen"] });

if (!sources.length) {
throw new Error("No screen sources available");
}

const source = sources[0];

return await navigator.mediaDevices.getUserMedia({
audio: false, //intentional
video: {
mandatory: {
chromeMediaSource: "desktop",
chromeMediaSourceId: source.id,
maxWidth: TARGET_WIDTH,
maxHeight: TARGET_HEIGHT,
maxFrameRate: TARGET_FRAME_RATE,
},
},
} as any);
}
};
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Apr 27, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Move the Linux fallback out of the audio-only branch.

acquireLinuxPortalStream only exists inside if (wantsAudioCapture), so a Linux recording with both audio toggles off still goes through the old plain getDisplayMedia path and will fail on the same portal/PipeWire error this PR is trying to cover.

Suggested shape
- if (wantsAudioCapture) {
-   let screenMediaStream: MediaStream;
-   const useLinuxPortal = selectedSource.id === "screen:linux-portal";
-   const acquireLinuxPortalStream = async (withAudio: boolean): Promise<MediaStream> => {
+ const useLinuxPortal = selectedSource.id === "screen:linux-portal";
+ const acquireLinuxPortalStream = async (withAudio: boolean): Promise<MediaStream> => {
      ...
-   };
+ };
+
+ if (wantsAudioCapture) {
+   let screenMediaStream: MediaStream;
    ...
  } else {
-   const mediaStream = await mediaDevices.getDisplayMedia({
-     audio: false,
-     video: { ... },
-   });
+   const mediaStream = useLinuxPortal
+     ? await acquireLinuxPortalStream(false)
+     : await mediaDevices.getDisplayMedia({
+         audio: false,
+         video: { ... },
+       });

    stream.current = mediaStream;
    videoTrack = mediaStream.getVideoTracks()[0];
  }
🧰 Tools
🪛 Biome (2.4.13)

[error] 1170-1170: Unexpected any. Specify a different type.

(lint/suspicious/noExplicitAny)

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

In `@src/hooks/useScreenRecorder.ts` around lines 1131 - 1172, The Linux portal
fallback logic is defined only inside the wantsAudioCapture branch so when
selectedSource.id === "screen:linux-portal" but wantsAudioCapture is false the
code still uses the old getDisplayMedia path and hits the PipeWire/portal error;
move the acquireLinuxPortalStream function (and its fallback to
navigator.mediaDevices.getUserMedia + electronAPI.getSources) out of the if
(wantsAudioCapture) block so it is defined at the same scope as
screenMediaStream and then call acquireLinuxPortalStream(withAudio) whenever
selectedSource.id === "screen:linux-portal" regardless of wantsAudioCapture (use
withAudio = wantsAudioCapture) — update any references in this file
(acquireLinuxPortalStream, selectedSource.id, wantsAudioCapture,
screenMediaStream) so both audio-on and audio-off Linux screen captures go
through the portal fallback path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

on it

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.

Only users with a collaborator, contributor, member, or owner role can interact with CodeRabbit.

Comment thread src/hooks/useScreenRecorder.ts Outdated
Comment on lines +1148 to +1160
} catch (err) {
console.warn("Linux portal failed, falling back to desktop capture:", err);

const sources = await window.electronAPI.getSources({ types: ["screen"] });

if (!sources.length) {
throw new Error("No screen sources available");
}

const source = sources[0];

return await navigator.mediaDevices.getUserMedia({
audio: false, //intentional
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 | 🟠 Major

Surface the system-audio downgrade in the fallback path.

If systemAudioEnabled is on, this catch block returns a fallback stream with audio: false. That means the outer catch (audioError) never runs, so the user gets no warning that system audio was dropped for this recording.

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

In `@src/hooks/useScreenRecorder.ts` around lines 1148 - 1160, In the catch block
that falls back to desktop capture inside useScreenRecorder (after
window.electronAPI.getSources and before returning
navigator.mediaDevices.getUserMedia), detect if systemAudioEnabled is true and
surface the downgrade by emitting the same warning/notification used elsewhere
(e.g., call the existing audio error handler or invoke the component's
notify/log path) so users are informed that system audio was dropped; reference
the systemAudioEnabled flag and the fallback flow (window.electronAPI.getSources
/ navigator.mediaDevices.getUserMedia) and ensure you call the same handler used
by the outer catch (audioError) or dispatch a specific "system-audio-downgraded"
event so behavior is consistent.

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 (1)
src/hooks/useScreenRecorder.ts (1)

1168-1179: Avoid as any by using the already-typed mediaDevices variable.

The mediaDevices variable (line 1117) is already cast to DesktopCaptureMediaDevices which accepts unknown constraints. Using it here eliminates the as any cast flagged by static analysis.

♻️ Proposed fix
-				return await navigator.mediaDevices.getUserMedia({
+				return await mediaDevices.getUserMedia({
 					audio: false, //intentional
 					video: {
 						mandatory: {
 							chromeMediaSource: "desktop",
 							chromeMediaSourceId: source.id,
 							maxWidth: TARGET_WIDTH,
 							maxHeight: TARGET_HEIGHT,
 							maxFrameRate: TARGET_FRAME_RATE,
 						},
 					},
-				} as any);
+				});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useScreenRecorder.ts` around lines 1168 - 1179, Replace the
cast-to-any by calling the already-typed mediaDevices.getUserMedia (mediaDevices
is cast to DesktopCaptureMediaDevices) instead of
navigator.mediaDevices.getUserMedia, and pass the same video constraint object
(using chromeMediaSource, chromeMediaSourceId: source.id, maxWidth:
TARGET_WIDTH, maxHeight: TARGET_HEIGHT, maxFrameRate: TARGET_FRAME_RATE) as the
unknown-typed constraints so the DesktopCaptureMediaDevices typing is used and
the `as any` can be removed.
🤖 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 1168-1179: Replace the cast-to-any by calling the already-typed
mediaDevices.getUserMedia (mediaDevices is cast to DesktopCaptureMediaDevices)
instead of navigator.mediaDevices.getUserMedia, and pass the same video
constraint object (using chromeMediaSource, chromeMediaSourceId: source.id,
maxWidth: TARGET_WIDTH, maxHeight: TARGET_HEIGHT, maxFrameRate:
TARGET_FRAME_RATE) as the unknown-typed constraints so the
DesktopCaptureMediaDevices typing is used and the `as any` can be removed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 44a161de-2048-4588-bbae-6ad7eae7de57

📥 Commits

Reviewing files that changed from the base of the PR and between 39673f1 and ba89539.

📒 Files selected for processing (1)
  • src/hooks/useScreenRecorder.ts

@dexisback
Copy link
Copy Markdown
Contributor Author

Update:

  • Linux fallback now reliably starts recording (video-only when portal fails)
  • System audio is intentionally disabled in fallback (platform limitation)

Known limitation:

  • Microphone capture is inconsistent in fallback mode

Planned follow-up:

  • Investigate proper mic + system audio handling for Linux (likely PipeWire / portal constraints)

Keeping this PR focused on fixing the recording failure.

@webadderall
Copy link
Copy Markdown
Collaborator

This issue may be resolved in v1.2.1, if it isn't let me know and I'll merge :)

@dexisback
Copy link
Copy Markdown
Contributor Author

@webadderall Tested on v1.2.1 and latest main, issue still persists. This PR still resolves it. lmk if you want any changes :)

@webadderall webadderall merged commit 92dbe7b into webadderallorg:main May 1, 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.

2 participants