fix(linux): fallback to desktop capture when getDisplayMedia fails#370
Conversation
📝 WalkthroughWalkthroughThe screen capture flow in Changes
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
src/hooks/useScreenRecorder.ts (1)
1159-1170: Drop theas anycast here.
mediaDevicesis already typed asDesktopCaptureMediaDevices, so this cast is just suppressing the Line 1170 Biome error and hiding constraint-shape mistakes. UsemediaDevices.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
📒 Files selected for processing (1)
src/hooks/useScreenRecorder.ts
| 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); | ||
| } | ||
| }; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Only users with a collaborator, contributor, member, or owner role can interact with CodeRabbit.
| } 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/hooks/useScreenRecorder.ts (1)
1168-1179: Avoidas anyby using the already-typedmediaDevicesvariable.The
mediaDevicesvariable (line 1117) is already cast toDesktopCaptureMediaDeviceswhich acceptsunknownconstraints. Using it here eliminates theas anycast 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
📒 Files selected for processing (1)
src/hooks/useScreenRecorder.ts
|
Update:
Known limitation:
Planned follow-up:
Keeping this PR focused on fixing the recording failure. |
|
This issue may be resolved in v1.2.1, if it isn't let me know and I'll merge :) |
|
@webadderall Tested on v1.2.1 and latest main, issue still persists. This PR still resolves it. lmk if you want any changes :) |
Description
Adds a fallback for Linux when
getDisplayMediafails, ensuring screen recording starts reliably instead of failing after the countdown.Motivation
On Linux,
getDisplayMediacan 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 (
getUserMediawithchromeMediaSource).Notes
getDisplayMediasucceedsType of Change
Related Issue(s)
N/A
Screenshots / Video
N/A
Testing Guide
getDisplayMediafails (portal issue), the app should fall back automaticallyExpected:
Checklist
Summary by CodeRabbit