Fix screenshare portals linux
All checks were successful
Queue Release Build / prepare (push) Successful in 14s
Queue Release Build / build-windows (push) Successful in 34m25s
Queue Release Build / build-linux (push) Successful in 40m26s
Queue Release Build / finalize (push) Successful in 3m44s

This commit is contained in:
2026-03-11 17:54:04 +01:00
parent 7b3caa0b61
commit be465fd297
5 changed files with 104 additions and 43 deletions

View File

@@ -3,7 +3,6 @@ import { readDesktopSettings } from '../desktop-settings';
export function configureAppFlags(): void { export function configureAppFlags(): void {
linuxSpecificFlags(); linuxSpecificFlags();
audioFlags();
networkFlags(); networkFlags();
setupGpuEncodingFlags(); setupGpuEncodingFlags();
chromiumFlags(); chromiumFlags();
@@ -15,21 +14,40 @@ function chromiumFlags(): void {
// Suppress Autofill devtools errors // Suppress Autofill devtools errors
app.commandLine.appendSwitch('disable-features', 'Autofill,AutofillAssistant,AutofillServerCommunication'); app.commandLine.appendSwitch('disable-features', 'Autofill,AutofillAssistant,AutofillServerCommunication');
}
function audioFlags(): void { // Collect all enabled features into a single switch to avoid later calls overwriting earlier ones
const enabledFeatures: string[] = [];
if (process.platform === 'linux') { if (process.platform === 'linux') {
// Use the new PipeWire-based audio pipeline on Linux for better screen share audio capture support // PipeWire-based audio pipeline for screen share audio capture
app.commandLine.appendSwitch('enable-features', 'AudioServiceOutOfProcess'); enabledFeatures.push('AudioServiceOutOfProcess');
// PipeWire-based screen capture so the xdg-desktop-portal system picker works
enabledFeatures.push('WebRTCPipeWireCapturer');
}
const desktopSettings = readDesktopSettings();
if (process.platform === 'linux' && desktopSettings.vaapiVideoEncode) {
enabledFeatures.push('VaapiVideoEncode');
}
if (enabledFeatures.length > 0) {
app.commandLine.appendSwitch('enable-features', enabledFeatures.join(','));
} }
} }
function linuxSpecificFlags(): void { function linuxSpecificFlags(): void {
// Disable sandbox on Linux to avoid SUID / /tmp shared-memory issues if (process.platform !== 'linux') {
if (process.platform === 'linux') { return;
app.commandLine.appendSwitch('no-sandbox');
app.commandLine.appendSwitch('disable-dev-shm-usage');
} }
// Disable sandbox on Linux to avoid SUID / /tmp shared-memory issues
app.commandLine.appendSwitch('no-sandbox');
app.commandLine.appendSwitch('disable-dev-shm-usage');
// Auto-detect Wayland vs X11 so the xdg-desktop-portal system picker
// works for screen capture on Wayland compositors
app.commandLine.appendSwitch('ozone-platform-hint', 'auto');
} }
function networkFlags(): void { function networkFlags(): void {
@@ -46,11 +64,6 @@ function setupGpuEncodingFlags(): void {
app.disableHardwareAcceleration(); app.disableHardwareAcceleration();
} }
if (process.platform === 'linux' && desktopSettings.vaapiVideoEncode) {
// Enable VA-API hardware video encoding on Linux
app.commandLine.appendSwitch('enable-features', 'VaapiVideoEncode');
}
app.commandLine.appendSwitch('enable-gpu-rasterization'); app.commandLine.appendSwitch('enable-gpu-rasterization');
app.commandLine.appendSwitch('enable-zero-copy'); app.commandLine.appendSwitch('enable-zero-copy');
app.commandLine.appendSwitch('enable-native-gpu-memory-buffers'); app.commandLine.appendSwitch('enable-native-gpu-memory-buffers');

View File

@@ -204,26 +204,32 @@ export function setupSystemHandlers(): void {
}); });
ipcMain.handle('get-sources', async () => { ipcMain.handle('get-sources', async () => {
const thumbnailSize = { width: 240, height: 150 }; try {
const [screenSources, windowSources] = await Promise.all([ const thumbnailSize = { width: 240, height: 150 };
desktopCapturer.getSources({ const [screenSources, windowSources] = await Promise.all([
types: ['screen'], desktopCapturer.getSources({
thumbnailSize types: ['screen'],
}), thumbnailSize
desktopCapturer.getSources({ }),
types: ['window'], desktopCapturer.getSources({
thumbnailSize, types: ['window'],
fetchWindowIcons: true thumbnailSize,
}) fetchWindowIcons: true
]); })
const sources = [...screenSources, ...windowSources]; ]);
const uniqueSources = new Map(sources.map((source) => [source.id, source])); const sources = [...screenSources, ...windowSources];
const uniqueSources = new Map(sources.map((source) => [source.id, source]));
return [...uniqueSources.values()].map((source) => ({ return [...uniqueSources.values()].map((source) => ({
id: source.id, id: source.id,
name: source.name, name: source.name,
thumbnail: source.thumbnail.toDataURL() thumbnail: source.thumbnail.toDataURL()
})); }));
} catch {
// desktopCapturer.getSources fails on Wayland; return empty so the
// renderer falls through to getDisplayMedia with the system picker.
return [];
}
}); });
ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => { ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => {

View File

@@ -1,6 +1,8 @@
import { import {
app, app,
BrowserWindow, BrowserWindow,
desktopCapturer,
session,
shell shell
} from 'electron'; } from 'electron';
import * as fs from 'fs'; import * as fs from 'fs';
@@ -61,6 +63,34 @@ export async function createWindow(): Promise<void> {
} }
}); });
if (process.platform === 'linux') {
session.defaultSession.setDisplayMediaRequestHandler(
async (_request, respond) => {
// On Linux/Wayland the system picker (useSystemPicker: true) handles
// the portal. This handler is only reached if the system picker is
// unavailable (e.g. X11 without a portal). Fall back to
// desktopCapturer so the user still gets something.
try {
const sources = await desktopCapturer.getSources({
types: ['window', 'screen'],
thumbnailSize: { width: 150, height: 150 }
});
const firstSource = sources[0];
if (firstSource) {
respond({ video: firstSource });
return;
}
} catch {
// desktopCapturer also unavailable
}
respond({});
},
{ useSystemPicker: true }
);
}
if (process.env['NODE_ENV'] === 'development') { if (process.env['NODE_ENV'] === 'development') {
const devUrl = process.env['SSL'] === 'true' const devUrl = process.env['SSL'] === 'true'
? 'https://localhost:4200' ? 'https://localhost:4200'

View File

@@ -36,7 +36,7 @@
"electron:build:all": "npm run build:prod && npm run build:electron && electron-builder --win --mac --linux", "electron:build:all": "npm run build:prod && npm run build:electron && electron-builder --win --mac --linux",
"build:prod:all": "npm run build:prod && npm run build:electron && cd server && npm run build", "build:prod:all": "npm run build:prod && npm run build:electron && cd server && npm run build",
"build:prod:win": "npm run build:prod:all && electron-builder --win", "build:prod:win": "npm run build:prod:all && electron-builder --win",
"dev": "npm run electron:full", "dev": "npm run build:electron && npm run electron:full",
"dev:app": "npm run electron:dev", "dev:app": "npm run electron:dev",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "npm run format && npm run sort:props && eslint . --fix", "lint:fix": "npm run format && npm run sort:props && eslint . --fix",

View File

@@ -225,6 +225,7 @@ export class ScreenShareManager {
this.activeScreenStream = await this.startWithLinuxElectronAudioRouting(shareOptions, preset); this.activeScreenStream = await this.startWithLinuxElectronAudioRouting(shareOptions, preset);
captureMethod = 'linux-electron'; captureMethod = 'linux-electron';
} catch (error) { } catch (error) {
this.rethrowIfScreenShareAborted(error);
this.logger.warn('Linux Electron audio routing failed; falling back to standard capture', error); this.logger.warn('Linux Electron audio routing failed; falling back to standard capture', error);
} }
} }
@@ -241,6 +242,7 @@ export class ScreenShareManager {
captureMethod = null; captureMethod = null;
} }
} catch (error) { } catch (error) {
this.rethrowIfScreenShareAborted(error);
this.logger.warn('getDisplayMedia with system audio failed; falling back to Electron desktop capture', error); this.logger.warn('getDisplayMedia with system audio failed; falling back to Electron desktop capture', error);
} }
} }
@@ -253,10 +255,7 @@ export class ScreenShareManager {
shareOptions.includeSystemAudio = electronCapture.includeSystemAudio; shareOptions.includeSystemAudio = electronCapture.includeSystemAudio;
captureMethod = 'electron-desktop'; captureMethod = 'electron-desktop';
} catch (error) { } catch (error) {
if (this.isScreenShareSelectionAborted(error)) { this.rethrowIfScreenShareAborted(error);
throw error;
}
this.logger.warn('Electron desktop capture failed; falling back to getDisplayMedia', error); this.logger.warn('Electron desktop capture failed; falling back to getDisplayMedia', error);
} }
} }
@@ -392,7 +391,15 @@ export class ScreenShareManager {
} }
private isElectronDesktopCaptureAvailable(): boolean { private isElectronDesktopCaptureAvailable(): boolean {
return !!this.getElectronApi()?.getSources; return !!this.getElectronApi()?.getSources && !this.isLinuxElectron();
}
private isLinuxElectron(): boolean {
if (!this.getElectronApi() || typeof navigator === 'undefined') {
return false;
}
return /linux/i.test(`${navigator.userAgent} ${navigator.platform}`);
} }
private isWindowsElectron(): boolean { private isWindowsElectron(): boolean {
@@ -725,7 +732,14 @@ export class ScreenShareManager {
} }
private isScreenShareSelectionAborted(error: unknown): boolean { private isScreenShareSelectionAborted(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError'; return error instanceof Error
&& (error.name === 'AbortError' || error.name === 'NotAllowedError');
}
private rethrowIfScreenShareAborted(error: unknown): void {
if (this.isScreenShareSelectionAborted(error)) {
throw error;
}
} }
private isLinuxElectronAudioRoutingSupported(): boolean { private isLinuxElectronAudioRoutingSupported(): boolean {
@@ -766,13 +780,11 @@ export class ScreenShareManager {
throw new Error(activation.reason || 'Failed to activate Linux Electron audio routing.'); throw new Error(activation.reason || 'Failed to activate Linux Electron audio routing.');
} }
const desktopCapture = await this.startWithElectronDesktopCapturer({ desktopStream = await this.startWithDisplayMedia({
...options, ...options,
includeSystemAudio: false includeSystemAudio: false
}, preset); }, preset);
desktopStream = desktopCapture.stream;
const { audioTrack, captureInfo } = await this.startLinuxScreenShareMonitorTrack(); const { audioTrack, captureInfo } = await this.startLinuxScreenShareMonitorTrack();
const stream = new MediaStream([...desktopStream.getVideoTracks(), audioTrack]); const stream = new MediaStream([...desktopStream.getVideoTracks(), audioTrack]);