diff --git a/electron/main.ts b/electron/main.ts
index 4adcc99c..636c29ff 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -891,8 +891,27 @@ app.whenReady().then(async () => {
// ignored by the native capture pipeline.
session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => {
try {
- const sources = await desktopCapturer.getSources({ types: ["screen", "window"] });
const sourceId = getSelectedSourceId();
+ // On Linux/Wayland, calling desktopCapturer.getSources() itself
+ // invokes the xdg-desktop-portal picker. If we then return one of
+ // those sources, Chromium triggers a SECOND portal because the
+ // pre-enumerated source IDs are stale on Wayland. To collapse this
+ // into a single portal invocation, when the Linux portal sentinel
+ // is set we skip getSources entirely and hand back a synthetic
+ // source id; Chromium then opens the portal once to actually
+ // resolve the capture.
+ // Default to the sentinel on Linux when no source has been
+ // pre-selected (e.g. fresh session where the renderer skipped the
+ // source picker entirely). This avoids calling getSources() which
+ // would itself trigger an extra portal dialog.
+ const isLinuxPortalSentinel =
+ process.platform === "linux" &&
+ (sourceId === "screen:linux-portal" || !sourceId);
+ if (isLinuxPortalSentinel) {
+ callback({ video: { id: "screen:0:0", name: "Entire screen" } });
+ return;
+ }
+ const sources = await desktopCapturer.getSources({ types: ["screen", "window"] });
const source = sourceId
? (sources.find((s) => s.id === sourceId) ?? sources[0])
: sources[0];
diff --git a/electron/windows.ts b/electron/windows.ts
index 7439f716..819c2b5c 100644
--- a/electron/windows.ts
+++ b/electron/windows.ts
@@ -788,6 +788,11 @@ export function createEditorWindow(): BrowserWindow {
win.webContents.on("did-finish-load", () => {
console.log("[editor-window] did-finish-load", win.webContents.getURL());
win?.webContents.send("main-process-message", new Date().toLocaleString());
+ // Fallback for Linux/Wayland where `ready-to-show` may not fire reliably.
+ if (!win.isDestroyed() && !win.isVisible()) {
+ console.log("[editor-window] forcing show after did-finish-load");
+ win.show();
+ }
});
win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL) => {
diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx
index 2b12ea29..2c4e6146 100644
--- a/src/components/launch/LaunchWindow.tsx
+++ b/src/components/launch/LaunchWindow.tsx
@@ -1093,23 +1093,27 @@ export function LaunchWindow() {
const idleControls = (
<>
-
-
-
+ {platform !== "linux" && (
+ <>
+
+
+
+ >
+ )}
toggleDropdown("sources")}
+ onClick={
+ hasSelectedSource || platform === "linux"
+ ? toggleRecording
+ : () => toggleDropdown("sources")
+ }
disabled={countdownActive}
title={t("recording.record")}
>
diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts
index 92684579..0a1db292 100644
--- a/src/hooks/useScreenRecorder.ts
+++ b/src/hooks/useScreenRecorder.ts
@@ -334,6 +334,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
return source;
}
+ // Linux/Wayland portal sentinel: do NOT call getSources here, because
+ // on Wayland that triggers an additional xdg-desktop-portal dialog.
+ // The sentinel is handled later by routing through getDisplayMedia,
+ // which lets the portal pick the source in a single dialog.
+ if (source.id === "screen:linux-portal") {
+ return source;
+ }
+
try {
const liveSources = await window.electronAPI.getSources({
types: ["screen"],
@@ -827,11 +835,27 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
setStarting(true);
try {
- const selectedSource = await window.electronAPI.getSelectedSource();
+ const platform = await window.electronAPI.getPlatform();
+ const existingSource = await window.electronAPI.getSelectedSource();
+ const selectedSource =
+ existingSource ??
+ (platform === "linux"
+ ? { id: "screen:linux-portal", name: "Linux Portal" }
+ : null);
if (!selectedSource) {
alert("Please select a source to record");
return;
}
+ // Persist the synthetic Linux portal sentinel to main so that the
+ // setDisplayMediaRequestHandler can short-circuit getSources() and
+ // avoid triggering an extra portal dialog.
+ if (!existingSource && selectedSource.id === "screen:linux-portal") {
+ try {
+ await window.electronAPI.selectSource(selectedSource);
+ } catch (err) {
+ console.warn("Failed to persist Linux portal sentinel source:", err);
+ }
+ }
const permissionsReady = await preparePermissions();
if (!permissionsReady) {
@@ -841,8 +865,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
recordingSessionTimestamp.current = Date.now();
resetRecordingClock(recordingSessionTimestamp.current);
await prepareWebcamRecorder();
-
- const platform = await window.electronAPI.getPlatform();
const useNativeMacScreenCapture =
platform === "darwin" &&
(selectedSource.id?.startsWith("screen:") ||
@@ -1018,18 +1040,34 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
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",
+ });
if (systemAudioEnabled) {
try {
- screenMediaStream = await mediaDevices.getUserMedia({
- audio: {
- mandatory: {
- chromeMediaSource: CHROME_MEDIA_SOURCE,
- chromeMediaSourceId: browserCaptureSource.id,
- },
- },
- video: browserScreenVideoConstraints,
- });
+ screenMediaStream = useLinuxPortal
+ ? await acquireLinuxPortalStream(true)
+ : await mediaDevices.getUserMedia({
+ audio: {
+ mandatory: {
+ chromeMediaSource: CHROME_MEDIA_SOURCE,
+ chromeMediaSourceId: browserCaptureSource.id,
+ },
+ },
+ video: browserScreenVideoConstraints,
+ });
} catch (audioError) {
console.warn(
"System audio capture failed, falling back to video-only:",
@@ -1038,16 +1076,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
alert(
"System audio is not available for this source. Recording will continue without system audio.",
);
- screenMediaStream = await mediaDevices.getUserMedia({
- audio: false,
- video: browserScreenVideoConstraints,
- });
+ screenMediaStream = useLinuxPortal
+ ? await acquireLinuxPortalStream(false)
+ : await mediaDevices.getUserMedia({
+ audio: false,
+ video: browserScreenVideoConstraints,
+ });
}
} else {
- screenMediaStream = await mediaDevices.getUserMedia({
- audio: false,
- video: browserScreenVideoConstraints,
- });
+ screenMediaStream = useLinuxPortal
+ ? await acquireLinuxPortalStream(false)
+ : await mediaDevices.getUserMedia({
+ audio: false,
+ video: browserScreenVideoConstraints,
+ });
}
screenStream.current = screenMediaStream;