mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(voice): rework screen share delivery behind an experiment (#2859)
This commit is contained in:
@@ -71,14 +71,6 @@ export interface GpuInfo {
|
||||
|
||||
export type StreamingPriorityDiagnostics = Record<string, unknown>;
|
||||
|
||||
export interface OpenH264Status {
|
||||
enabled: boolean;
|
||||
downloaded: boolean;
|
||||
downloading: boolean;
|
||||
version: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface CpuInfo {
|
||||
model: string;
|
||||
speed: number;
|
||||
@@ -640,8 +632,6 @@ export interface ElectronAPI {
|
||||
buildChannel: 'stable' | 'canary';
|
||||
getDesktopInfo: () => Promise<DesktopInfo>;
|
||||
getGpuInfo: () => Promise<GpuInfo>;
|
||||
getOpenH264Status: () => Promise<OpenH264Status>;
|
||||
setOpenH264Enabled: (enabled: boolean) => Promise<OpenH264Status>;
|
||||
getDesktopWindowBehaviorSettings: () => Promise<DesktopWindowBehaviorSettings>;
|
||||
setDesktopWindowBehaviorSettings: (
|
||||
settings: Partial<DesktopWindowBehaviorSettings>,
|
||||
|
||||
@@ -57,6 +57,17 @@ function loadChromiumRuntime(platform = 'win32') {
|
||||
return {appendedSwitches, module: module.exports};
|
||||
}
|
||||
|
||||
function collectChromiumFeatureNames(platform) {
|
||||
const {module} = loadChromiumRuntime(platform);
|
||||
const features = new Set(module.BASE_DISABLED_CHROMIUM_FEATURES);
|
||||
for (const [name, value] of Object.entries(module)) {
|
||||
if (typeof value !== 'function') continue;
|
||||
if (!name.startsWith('add') || !name.endsWith('Features')) continue;
|
||||
value(features);
|
||||
}
|
||||
return [...features];
|
||||
}
|
||||
|
||||
describe('ChromiumRuntime Windows capture policy', () => {
|
||||
test('leaves the choice of Windows graphics capture to Chromium', () => {
|
||||
const {module} = loadChromiumRuntime('win32');
|
||||
@@ -75,6 +86,35 @@ describe('ChromiumRuntime Windows capture policy', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChromiumRuntime macOS capture policy', () => {
|
||||
test('leaves the choice of macOS screen capture device to Chromium', () => {
|
||||
assert.deepEqual(
|
||||
collectChromiumFeatureNames('darwin').filter(
|
||||
(feature) => feature.includes('ScreenCaptureKit') || feature.includes('SCContentSharingPicker'),
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('exposes no macOS release-gated screen capture override', () => {
|
||||
const {module} = loadChromiumRuntime('darwin');
|
||||
|
||||
assert.deepEqual(
|
||||
Object.keys(module).filter((name) => name.includes('ScreenCapture')),
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChromiumRuntime Linux capture policy', () => {
|
||||
test('leaves the choice of PipeWire screen capture to Chromium', () => {
|
||||
assert.deepEqual(
|
||||
collectChromiumFeatureNames('linux').filter((feature) => feature.includes('PipeWire')),
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChromiumRuntime configured switch allowlist', () => {
|
||||
test('rejects settings-supplied switches that would disable or shrink the HTTP cache', () => {
|
||||
const {appendedSwitches, module} = loadChromiumRuntime('win32');
|
||||
|
||||
@@ -27,15 +27,6 @@ const RUNTIME_CACHE_DIRECTORIES = [
|
||||
'Service Worker/ScriptCache',
|
||||
'Default/Service Worker/ScriptCache',
|
||||
];
|
||||
const MACOS_PRE_SEQUOIA_SCREEN_CAPTURE_DISABLED_FEATURES = [
|
||||
'ScreenCaptureKitMac',
|
||||
'ScreenCaptureKitMacWindow',
|
||||
'ScreenCaptureKitMacScreen',
|
||||
'ScreenCaptureKitPickerScreen',
|
||||
'ScreenCaptureKitStreamPickerSonoma',
|
||||
'WarmScreenCaptureSonoma',
|
||||
'UseSCContentSharingPicker',
|
||||
];
|
||||
const WINDOWS_NVIDIA_HEVC_DECODE_WORKAROUND_DEVICE_IDS = new Set([
|
||||
4928, 4929, 4932, 4934, 4935, 4936, 4937, 4939, 4941, 4942, 4943, 4986, 4987, 4992, 4993, 4994, 5008, 5009, 5010,
|
||||
5011, 5016, 5017, 5018, 5019, 5020, 5021, 5040, 5041, 5042, 5043, 5044, 5046, 5049, 5050, 5051, 5052, 5056, 5058,
|
||||
@@ -296,15 +287,6 @@ export function appendLinuxChromiumFlagsConfig(channel: 'stable' | 'canary'): vo
|
||||
}
|
||||
}
|
||||
|
||||
export function addMacosPreSequoiaScreenCaptureDisabledFeatures(features: Set<string>): void {
|
||||
if (process.platform !== 'darwin') return;
|
||||
const majorRelease = Number.parseInt(os.release().split('.')[0] ?? '', 10);
|
||||
if (!Number.isFinite(majorRelease) || majorRelease >= 24) return;
|
||||
for (const feature of MACOS_PRE_SEQUOIA_SCREEN_CAPTURE_DISABLED_FEATURES) {
|
||||
features.add(feature);
|
||||
}
|
||||
}
|
||||
|
||||
export function addLinuxHardwareVideoEncodeFeatures(features: Set<string>): void {
|
||||
if (process.platform !== 'linux') return;
|
||||
features.add('AcceleratedVideoEncoder');
|
||||
@@ -316,11 +298,6 @@ export interface ChromiumCommandLine {
|
||||
hasSwitch(name: string): boolean;
|
||||
}
|
||||
|
||||
export function addLinuxScreenCapturePipeWireFeature(features: Set<string>): void {
|
||||
if (process.platform !== 'linux') return;
|
||||
features.add('WebRTCPipeWireCapturer');
|
||||
}
|
||||
|
||||
export function hasConfiguredOzonePlatformSwitch(commandLine: ChromiumCommandLine = app.commandLine): boolean {
|
||||
return commandLine.hasSwitch('ozone-platform') || commandLine.getSwitchValue('ozone-platform').length > 0;
|
||||
}
|
||||
|
||||
@@ -461,7 +461,7 @@ export function registerDisplayMediaHandlers(): void {
|
||||
name: selectedSource.name,
|
||||
withAudio: withAudio === true,
|
||||
});
|
||||
startWindowsScreenCaptureGuardForSource(selectedSource);
|
||||
startWindowsScreenCaptureGuardForSource(selectedSource, event.sender);
|
||||
const _attachAudio = shouldHonorSelectedAudio(pending.audioRequested, withAudio);
|
||||
const streams: Electron.Streams = {
|
||||
video: selectedSource,
|
||||
|
||||
@@ -32,7 +32,6 @@ import {setNativeStrings} from '@electron/main/MainI18n';
|
||||
import {copyRemoteFileToClipboard, parseClipboardWriteFileOptions} from '@electron/main/MediaClipboard';
|
||||
import {registerNotificationIpcHandlers} from '@electron/main/NotificationsIpc';
|
||||
import {openExternalDeduped} from '@electron/main/OpenExternal';
|
||||
import {getStatus as getOpenH264Status, setEnabled as setOpenH264Enabled} from '@electron/main/OpenH264Manager';
|
||||
import {registerPasskeyHandlers} from '@electron/main/Passkeys';
|
||||
import {getAppMetricsSnapshot, getDesktopInfo, getGpuInfo} from '@electron/main/PlatformInfo';
|
||||
import {requirePrivilegedRendererDocumentSender} from '@electron/main/PrivilegedRendererDocuments';
|
||||
@@ -140,8 +139,6 @@ export function registerIpcHandlers(): void {
|
||||
ipcMain.handle('get-desktop-info', () => getDesktopInfo());
|
||||
ipcMain.handle('get-gpu-info', () => getGpuInfo());
|
||||
ipcMain.handle('get-app-metrics', () => getAppMetricsSnapshot());
|
||||
ipcMain.handle('get-openh264-status', () => getOpenH264Status());
|
||||
ipcMain.handle('set-openh264-enabled', (_event, enabled: unknown) => setOpenH264Enabled(Boolean(enabled)));
|
||||
ipcMain.handle('streamer-mode:get-capture-app-status', () => getStreamerModeCaptureAppStatus());
|
||||
ipcMain.handle('system-idle-time-ms', (): number => {
|
||||
return Math.max(0, powerMonitor.getSystemIdleTime() * 1000);
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {execFile} from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type {OpenH264Status} from '@electron/common/Types';
|
||||
import {downloadFile} from '@electron/main/FileDownloads';
|
||||
import {app} from 'electron';
|
||||
import log from 'electron-log';
|
||||
|
||||
const OPENH264_VERSION = '2.5.1';
|
||||
const CISCO_CDN_BASE = 'https://ciscobinary.openh264.org';
|
||||
const PREFERENCES_FILE = 'preferences.json';
|
||||
const STATUS_FILE = 'status.json';
|
||||
const DECOMPRESS_TIMEOUT_MS = 30_000;
|
||||
|
||||
interface PersistedPreferences {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface PersistedStatus {
|
||||
version: string;
|
||||
binaryPath: string;
|
||||
downloadedAt: string;
|
||||
}
|
||||
|
||||
function getOpenH264Dir(): string {
|
||||
return path.join(app.getPath('userData'), 'openh264');
|
||||
}
|
||||
|
||||
function getStatusFilePath(): string {
|
||||
return path.join(getOpenH264Dir(), STATUS_FILE);
|
||||
}
|
||||
|
||||
function getPreferencesFilePath(): string {
|
||||
return path.join(getOpenH264Dir(), PREFERENCES_FILE);
|
||||
}
|
||||
|
||||
function getBinaryFilename(): string | null {
|
||||
if (process.platform !== 'linux') return null;
|
||||
return `libopenh264-${OPENH264_VERSION}-linux64.7.so`;
|
||||
}
|
||||
|
||||
function getDownloadUrl(): string | null {
|
||||
const filename = getBinaryFilename();
|
||||
if (!filename) return null;
|
||||
return `${CISCO_CDN_BASE}/${filename}.bz2`;
|
||||
}
|
||||
|
||||
function _getBinaryPath(): string | null {
|
||||
const filename = getBinaryFilename();
|
||||
if (!filename) return null;
|
||||
return path.join(getOpenH264Dir(), filename);
|
||||
}
|
||||
|
||||
function readPersistedStatus(): PersistedStatus | null {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(getStatusFilePath(), 'utf8')) as Partial<PersistedStatus>;
|
||||
if (typeof data.version === 'string' && typeof data.binaryPath === 'string') {
|
||||
return data as PersistedStatus;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readPersistedEnabled(): boolean {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(getPreferencesFilePath(), 'utf8')) as PersistedPreferences;
|
||||
return typeof data.enabled === 'boolean' ? data.enabled : true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function writePersistedEnabled(value: boolean): void {
|
||||
try {
|
||||
fs.mkdirSync(getOpenH264Dir(), {recursive: true});
|
||||
fs.writeFileSync(getPreferencesFilePath(), JSON.stringify({enabled: value}, null, 2), 'utf8');
|
||||
} catch (error) {
|
||||
log.warn('[OpenH264] Failed to write preferences file', {error});
|
||||
}
|
||||
}
|
||||
|
||||
function writePersistedStatus(status: PersistedStatus): void {
|
||||
try {
|
||||
fs.mkdirSync(getOpenH264Dir(), {recursive: true});
|
||||
fs.writeFileSync(getStatusFilePath(), JSON.stringify(status, null, 2), 'utf8');
|
||||
} catch (error) {
|
||||
log.warn('[OpenH264] Failed to write status file', {error});
|
||||
}
|
||||
}
|
||||
|
||||
function binaryExists(): boolean {
|
||||
const persisted = readPersistedStatus();
|
||||
if (!persisted) return false;
|
||||
if (persisted.version !== OPENH264_VERSION) return false;
|
||||
try {
|
||||
fs.accessSync(persisted.binaryPath, fs.constants.R_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getBinaryPathIfReady(): string | null {
|
||||
const persisted = readPersistedStatus();
|
||||
if (!persisted) return null;
|
||||
if (persisted.version !== OPENH264_VERSION) return null;
|
||||
try {
|
||||
fs.accessSync(persisted.binaryPath, fs.constants.R_OK);
|
||||
return persisted.binaryPath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decompressBz2(compressedPath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile('bunzip2', ['--keep', '--force', compressedPath], {timeout: DECOMPRESS_TIMEOUT_MS}, (error) => {
|
||||
if (error) {
|
||||
reject(new Error(`bunzip2 failed: ${error.message}`));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let enabled: boolean | null = null;
|
||||
let downloading = false;
|
||||
let lastError: string | null = null;
|
||||
|
||||
function getEnabled(): boolean {
|
||||
if (enabled === null) {
|
||||
enabled = readPersistedEnabled();
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
|
||||
async function downloadOpenH264(): Promise<void> {
|
||||
const url = getDownloadUrl();
|
||||
const filename = getBinaryFilename();
|
||||
if (!url || !filename) {
|
||||
lastError = 'Unsupported platform';
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = getOpenH264Dir();
|
||||
const compressedPath = path.join(dir, `${filename}.bz2`);
|
||||
const binaryPath = path.join(dir, filename);
|
||||
|
||||
downloading = true;
|
||||
lastError = null;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(dir, {recursive: true});
|
||||
|
||||
log.info('[OpenH264] Downloading from Cisco CDN', {url, version: OPENH264_VERSION});
|
||||
await downloadFile(url, compressedPath);
|
||||
|
||||
log.info('[OpenH264] Decompressing', {compressedPath});
|
||||
await decompressBz2(compressedPath);
|
||||
|
||||
if (fs.existsSync(binaryPath)) {
|
||||
writePersistedStatus({
|
||||
version: OPENH264_VERSION,
|
||||
binaryPath,
|
||||
downloadedAt: new Date().toISOString(),
|
||||
});
|
||||
log.info('[OpenH264] Binary ready', {binaryPath, version: OPENH264_VERSION});
|
||||
} else {
|
||||
lastError = 'Decompression produced no output file';
|
||||
log.warn('[OpenH264] Binary not found after decompression', {binaryPath});
|
||||
}
|
||||
|
||||
try {
|
||||
fs.unlinkSync(compressedPath);
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
log.warn('[OpenH264] Download failed', {error: lastError});
|
||||
} finally {
|
||||
downloading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getStatus(): OpenH264Status {
|
||||
if (process.platform !== 'linux') {
|
||||
return {
|
||||
enabled: false,
|
||||
downloaded: false,
|
||||
downloading: false,
|
||||
version: null,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
const binaryPath = getBinaryPathIfReady();
|
||||
return {
|
||||
enabled: getEnabled(),
|
||||
downloaded: binaryPath !== null,
|
||||
downloading,
|
||||
version: binaryPath !== null ? OPENH264_VERSION : null,
|
||||
error: lastError,
|
||||
};
|
||||
}
|
||||
|
||||
export async function setEnabled(value: boolean): Promise<OpenH264Status> {
|
||||
if (process.platform !== 'linux') {
|
||||
enabled = false;
|
||||
return getStatus();
|
||||
}
|
||||
enabled = value;
|
||||
writePersistedEnabled(value);
|
||||
if (getEnabled() && !binaryExists() && !downloading) {
|
||||
void downloadOpenH264();
|
||||
}
|
||||
return getStatus();
|
||||
}
|
||||
|
||||
export function initOpenH264(): void {
|
||||
if (process.platform !== 'linux') return;
|
||||
if (getEnabled() && !binaryExists()) {
|
||||
void downloadOpenH264();
|
||||
}
|
||||
}
|
||||
|
||||
export function appendOpenH264Switches(): void {
|
||||
if (process.platform !== 'linux' || !getEnabled()) return;
|
||||
const binaryPath = getBinaryPathIfReady();
|
||||
if (!binaryPath) return;
|
||||
app.commandLine.appendSwitch('enable-libopenh264');
|
||||
app.commandLine.appendSwitch('openh264-library-path', binaryPath);
|
||||
log.info('[OpenH264] Chromium OpenH264 library path configured', {binaryPath, version: OPENH264_VERSION});
|
||||
}
|
||||
@@ -87,7 +87,7 @@ describe('PlatformInfo Chromium runtime diagnostics', () => {
|
||||
switches.filter((name) => name.startsWith('enable-h264-mf')),
|
||||
[],
|
||||
);
|
||||
assert.equal(switches.includes('enable-libopenh264'), true);
|
||||
assert.equal(switches.includes('disable_accelerated_h264_encode'), true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -52,10 +52,8 @@ const CHROMIUM_RUNTIME_SWITCHES = [
|
||||
'disable_metal',
|
||||
'disable-renderer-backgrounding',
|
||||
'disable_nv12_dxgi_video',
|
||||
'enable-libopenh264',
|
||||
'force_high_performance_gpu',
|
||||
'force_low_power_gpu',
|
||||
'openh264-library-path',
|
||||
];
|
||||
|
||||
interface DesktopInfoOptions {
|
||||
|
||||
@@ -53,7 +53,6 @@ function loadStreamingPriority({
|
||||
clearedIntervals: [],
|
||||
powerStart: [],
|
||||
powerStop: [],
|
||||
guardRetain: 0,
|
||||
guardStop: [],
|
||||
setPriority: [],
|
||||
nativeModuleImports: [],
|
||||
@@ -84,8 +83,8 @@ function loadStreamingPriority({
|
||||
const osModule = {
|
||||
constants: {priority: {PRIORITY_ABOVE_NORMAL: -7}},
|
||||
getPriority: () => currentPriority,
|
||||
setPriority(priority) {
|
||||
calls.setPriority.push(priority);
|
||||
setPriority(pid, priority) {
|
||||
calls.setPriority.push({processId: pid, priority});
|
||||
},
|
||||
};
|
||||
const log = {
|
||||
@@ -94,9 +93,6 @@ function loadStreamingPriority({
|
||||
warn: (...args) => calls.logs.warn.push(args),
|
||||
};
|
||||
const guard = {
|
||||
retainWindowsScreenCaptureGuard() {
|
||||
calls.guardRetain += 1;
|
||||
},
|
||||
stopWindowsScreenCaptureGuard(reason) {
|
||||
calls.guardStop.push(reason);
|
||||
},
|
||||
@@ -167,7 +163,10 @@ describe('StreamingPriority GPU scheduling priority', () => {
|
||||
{processId: 3002, priorityClass: 'high'},
|
||||
{processId: 3003, priorityClass: 'high'},
|
||||
]);
|
||||
assert.deepEqual(calls.setPriority, [-7]);
|
||||
assert.deepEqual(calls.setPriority, [
|
||||
{processId: 1000, priority: -7},
|
||||
{processId: 2001, priority: -7},
|
||||
]);
|
||||
assert.deepEqual(webContents.backgroundThrottlingAllowed, [false]);
|
||||
assert.equal(calls.intervals.length, 1);
|
||||
assert.equal(calls.intervals[0].delay, 20000);
|
||||
@@ -180,6 +179,10 @@ describe('StreamingPriority GPU scheduling priority', () => {
|
||||
streamingPriority: -7,
|
||||
savedPriority: 0,
|
||||
elevated: true,
|
||||
elevatedProcesses: [
|
||||
{processId: 1000, savedPriority: 0},
|
||||
{processId: 2001, savedPriority: 0},
|
||||
],
|
||||
},
|
||||
gpuScheduling: {
|
||||
supported: true,
|
||||
@@ -234,7 +237,12 @@ describe('StreamingPriority GPU scheduling priority', () => {
|
||||
{processId: 3002},
|
||||
{processId: 3003},
|
||||
]);
|
||||
assert.deepEqual(calls.setPriority, [-7, 0]);
|
||||
assert.deepEqual(calls.setPriority, [
|
||||
{processId: 1000, priority: -7},
|
||||
{processId: 2001, priority: -7},
|
||||
{processId: 1000, priority: 0},
|
||||
{processId: 2001, priority: 0},
|
||||
]);
|
||||
assert.deepEqual(normalize(module.getStreamingPriorityDiagnostics().gpuScheduling.elevatedProcesses), []);
|
||||
assert.deepEqual(normalize(module.getStreamingPriorityDiagnostics().gpuScheduling.lastRestore), {
|
||||
status: 'succeeded',
|
||||
@@ -332,6 +340,28 @@ describe('StreamingPriority GPU scheduling priority', () => {
|
||||
assert.equal(module.getStreamingPriorityDiagnostics().processPriority.savedPriority, null);
|
||||
});
|
||||
|
||||
test('forgets a renderer whose process is gone instead of rewriting a dead pid', () => {
|
||||
const webContents = makeWebContents(2001);
|
||||
const {calls, module} = loadStreamingPriority();
|
||||
|
||||
module.acquireStreamingPriority(webContents);
|
||||
|
||||
assert.deepEqual(calls.setPriority, [
|
||||
{processId: 1000, priority: -7},
|
||||
{processId: 2001, priority: -7},
|
||||
]);
|
||||
|
||||
webContents.emit('render-process-gone');
|
||||
module.releaseStreamingPriority();
|
||||
|
||||
assert.deepEqual(calls.setPriority, [
|
||||
{processId: 1000, priority: -7},
|
||||
{processId: 2001, priority: -7},
|
||||
{processId: 1000, priority: 0},
|
||||
]);
|
||||
assert.deepEqual(calls.restore, [{processId: 1000}]);
|
||||
});
|
||||
|
||||
test('records the real native module load failure detail', () => {
|
||||
const webContents = makeWebContents(2001);
|
||||
const {calls, module} = loadStreamingPriority({
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {createRequire} from 'node:module';
|
||||
import os from 'node:os';
|
||||
import {retainWindowsScreenCaptureGuard, stopWindowsScreenCaptureGuard} from '@electron/main/WindowsScreenCaptureGuard';
|
||||
import {stopWindowsScreenCaptureGuard} from '@electron/main/WindowsScreenCaptureGuard';
|
||||
import {app, powerSaveBlocker} from 'electron';
|
||||
import log from 'electron-log';
|
||||
|
||||
@@ -24,6 +24,12 @@ type WindowsGpuPriorityTargetReason =
|
||||
| 'chromium-video-encode'
|
||||
| 'chromium-media-utility';
|
||||
|
||||
const PROCESS_PRIORITY_TARGET_REASONS: ReadonlySet<WindowsGpuPriorityTargetReason> = new Set([
|
||||
'browser-main',
|
||||
'renderer',
|
||||
'tracked-renderer',
|
||||
]);
|
||||
|
||||
type GpuPriorityModuleStatus = 'disabled' | 'unsupported-platform' | 'not-loaded' | 'loaded' | 'unavailable';
|
||||
type GpuPriorityAttemptStatus =
|
||||
| 'disabled'
|
||||
@@ -80,6 +86,7 @@ export interface StreamingPriorityDiagnostics {
|
||||
streamingPriority: number;
|
||||
savedPriority: number | null;
|
||||
elevated: boolean;
|
||||
elevatedProcesses: Array<{processId: number; savedPriority: number}>;
|
||||
};
|
||||
gpuScheduling: {
|
||||
supported: boolean;
|
||||
@@ -98,7 +105,7 @@ export interface StreamingPriorityDiagnostics {
|
||||
|
||||
let refCount = 0;
|
||||
let powerBlockerId: number | null = null;
|
||||
let savedPriority: number | null = null;
|
||||
const savedProcessPriorities = new Map<number, number>();
|
||||
let gpuPriorityModule: WindowsGpuPriorityModule | null | undefined;
|
||||
let gpuPriorityModuleLoadErrorDetail: string | null = null;
|
||||
const gpuPriorityElevatedProcessIds = new Map<number, WindowsGpuSchedulingPriority>();
|
||||
@@ -165,29 +172,37 @@ function stopPowerSaveBlocker(): void {
|
||||
powerBlockerId = null;
|
||||
}
|
||||
|
||||
function elevateProcessPriority(): void {
|
||||
if (!CAN_ELEVATE_PROCESS_PRIORITY) return;
|
||||
function elevateProcessPriority(processId: number): void {
|
||||
if (!CAN_ELEVATE_PROCESS_PRIORITY || savedProcessPriorities.has(processId)) return;
|
||||
try {
|
||||
savedPriority = os.getPriority();
|
||||
if (savedPriority > STREAMING_PRIORITY) {
|
||||
os.setPriority(STREAMING_PRIORITY);
|
||||
} else {
|
||||
savedPriority = null;
|
||||
}
|
||||
const currentPriority = os.getPriority(processId);
|
||||
if (currentPriority <= STREAMING_PRIORITY) return;
|
||||
os.setPriority(processId, STREAMING_PRIORITY);
|
||||
savedProcessPriorities.set(processId, currentPriority);
|
||||
} catch (error) {
|
||||
log.debug('[StreamingPriority] Failed to elevate process priority', {error});
|
||||
savedPriority = null;
|
||||
log.debug('[StreamingPriority] Failed to elevate process priority', {processId, error});
|
||||
}
|
||||
}
|
||||
|
||||
function elevateProcessPriorityTargets(targets: Array<GpuPriorityTarget>): void {
|
||||
if (!CAN_ELEVATE_PROCESS_PRIORITY) return;
|
||||
for (const target of targets) {
|
||||
if (!target.reasons.some((reason) => PROCESS_PRIORITY_TARGET_REASONS.has(reason))) continue;
|
||||
elevateProcessPriority(target.processId);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreProcessPriority(): void {
|
||||
if (savedPriority === null) return;
|
||||
try {
|
||||
os.setPriority(savedPriority);
|
||||
} catch (error) {
|
||||
log.debug('[StreamingPriority] Failed to restore process priority', {error});
|
||||
if (savedProcessPriorities.size === 0) return;
|
||||
const entries = [...savedProcessPriorities.entries()];
|
||||
savedProcessPriorities.clear();
|
||||
for (const [processId, priority] of entries) {
|
||||
try {
|
||||
os.setPriority(processId, priority);
|
||||
} catch (error) {
|
||||
log.debug('[StreamingPriority] Failed to restore process priority', {processId, error});
|
||||
}
|
||||
}
|
||||
savedPriority = null;
|
||||
}
|
||||
|
||||
function loadWindowsGpuPriorityModule(): WindowsGpuPriorityModule | null {
|
||||
@@ -263,19 +278,30 @@ function restoreBackgroundThrottling(): void {
|
||||
function rememberStreamingPriorityWebContents(webContents?: Electron.WebContents): void {
|
||||
if (!webContents || webContents.isDestroyed() || streamingPriorityWebContents.has(webContents)) return;
|
||||
streamingPriorityWebContents.add(webContents);
|
||||
const processId = getRendererProcessId(webContents);
|
||||
const forgetProcessPriority = (): void => {
|
||||
if (processId === undefined) return;
|
||||
savedProcessPriorities.delete(processId);
|
||||
gpuPriorityElevatedProcessIds.delete(processId);
|
||||
};
|
||||
const cleanup = (): void => {
|
||||
streamingPriorityWebContents.delete(webContents);
|
||||
streamingPriorityWebContentsCleanup.delete(webContents);
|
||||
backgroundThrottlingBypassedWebContents.delete(webContents);
|
||||
forgetProcessPriority();
|
||||
};
|
||||
streamingPriorityWebContentsCleanup.set(webContents, cleanup);
|
||||
streamingPriorityWebContentsCleanup.set(webContents, () => {
|
||||
webContents.removeListener('destroyed', cleanup);
|
||||
webContents.removeListener('render-process-gone', forgetProcessPriority);
|
||||
});
|
||||
webContents.once('destroyed', cleanup);
|
||||
webContents.once('render-process-gone', forgetProcessPriority);
|
||||
}
|
||||
|
||||
function clearStreamingPriorityWebContents(): void {
|
||||
for (const [webContents, cleanup] of streamingPriorityWebContentsCleanup) {
|
||||
for (const [webContents, detachListeners] of streamingPriorityWebContentsCleanup) {
|
||||
if (webContents && !webContents.isDestroyed()) {
|
||||
webContents.removeListener('destroyed', cleanup);
|
||||
detachListeners();
|
||||
}
|
||||
}
|
||||
streamingPriorityWebContents.clear();
|
||||
@@ -367,7 +393,7 @@ function cloneGpuPriorityRestoreDiagnostics(
|
||||
};
|
||||
}
|
||||
|
||||
function elevateGpuSchedulingPriority(webContents?: Electron.WebContents): void {
|
||||
function elevateGpuSchedulingPriority(targets: Array<GpuPriorityTarget>): void {
|
||||
if (GPU_SCHEDULING_PRIORITY === null) {
|
||||
lastGpuPriorityAcquire = {
|
||||
status: 'disabled',
|
||||
@@ -380,7 +406,6 @@ function elevateGpuSchedulingPriority(webContents?: Electron.WebContents): void
|
||||
};
|
||||
return;
|
||||
}
|
||||
const targets = collectGpuSchedulingPriorityTargets(webContents);
|
||||
if (process.platform !== 'win32') {
|
||||
lastGpuPriorityAcquire = {
|
||||
status: 'unsupported-platform',
|
||||
@@ -467,7 +492,9 @@ function elevateGpuSchedulingPriority(webContents?: Electron.WebContents): void
|
||||
|
||||
function refreshGpuSchedulingPriority(): void {
|
||||
if (refCount <= 0) return;
|
||||
elevateGpuSchedulingPriority();
|
||||
const targets = collectGpuSchedulingPriorityTargets();
|
||||
elevateProcessPriorityTargets(targets);
|
||||
elevateGpuSchedulingPriority(targets);
|
||||
}
|
||||
|
||||
function startGpuPriorityRefresh(): void {
|
||||
@@ -551,8 +578,12 @@ export function getStreamingPriorityDiagnostics(): StreamingPriorityDiagnostics
|
||||
processPriority: {
|
||||
supported: CAN_ELEVATE_PROCESS_PRIORITY,
|
||||
streamingPriority: STREAMING_PRIORITY,
|
||||
savedPriority,
|
||||
elevated: savedPriority !== null,
|
||||
savedPriority: savedProcessPriorities.get(process.pid) ?? null,
|
||||
elevated: savedProcessPriorities.size > 0,
|
||||
elevatedProcesses: [...savedProcessPriorities.entries()].map(([processId, savedPriority]) => ({
|
||||
processId,
|
||||
savedPriority,
|
||||
})),
|
||||
},
|
||||
gpuScheduling: {
|
||||
supported: process.platform === 'win32' && GPU_SCHEDULING_PRIORITY !== null,
|
||||
@@ -581,12 +612,12 @@ export function acquireStreamingPriority(webContents?: Electron.WebContents): vo
|
||||
refCount++;
|
||||
if (refCount === 1) {
|
||||
startPowerSaveBlocker();
|
||||
elevateProcessPriority();
|
||||
retainWindowsScreenCaptureGuard();
|
||||
startGpuPriorityRefresh();
|
||||
log.info('[StreamingPriority] Acquired', {refCount});
|
||||
}
|
||||
elevateGpuSchedulingPriority(webContents);
|
||||
const targets = collectGpuSchedulingPriorityTargets(webContents);
|
||||
elevateProcessPriorityTargets(targets);
|
||||
elevateGpuSchedulingPriority(targets);
|
||||
}
|
||||
|
||||
export function releaseStreamingPriority(): void {
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import os from 'node:os';
|
||||
import {BrowserWindow, screen} from 'electron';
|
||||
import log from 'electron-log';
|
||||
|
||||
const PENDING_GUARD_TTL_MS = 120000;
|
||||
const GUARD_SIZE_DIP = 2;
|
||||
const WGC_SCREEN_CAPTURE_MIN_WINDOWS_BUILD = 26100;
|
||||
|
||||
interface ActiveWindowsScreenCaptureGuard {
|
||||
interface ActiveMonitorCompositionGuard {
|
||||
window: BrowserWindow;
|
||||
sourceId: string;
|
||||
displayId?: string;
|
||||
retained: boolean;
|
||||
timeout: NodeJS.Timeout | null;
|
||||
owner: Electron.WebContents;
|
||||
onOwnerDestroyed: () => void;
|
||||
}
|
||||
|
||||
let activeGuard: ActiveWindowsScreenCaptureGuard | null = null;
|
||||
let guardRetentionRequested = false;
|
||||
let activeGuard: ActiveMonitorCompositionGuard | null = null;
|
||||
|
||||
function isScreenSource(source: Electron.DesktopCapturerSource): boolean {
|
||||
return source.id.startsWith('screen:');
|
||||
}
|
||||
|
||||
function getWindowsBuildNumber(): number | null {
|
||||
const build = Number.parseInt(os.release().split('.')[2] ?? '', 10);
|
||||
return Number.isSafeInteger(build) && build > 0 ? build : null;
|
||||
}
|
||||
|
||||
function monitorCaptureUsesDxgiDuplication(): boolean {
|
||||
const build = getWindowsBuildNumber();
|
||||
return build === null || build < WGC_SCREEN_CAPTURE_MIN_WINDOWS_BUILD;
|
||||
}
|
||||
|
||||
function parseScreenSourceOrdinal(sourceId: string): number | null {
|
||||
const match = /^screen:([0-9]+):(?:0|1)$/.exec(sourceId);
|
||||
if (!match) return null;
|
||||
@@ -41,31 +51,15 @@ function resolveDisplayForSource(source: Electron.DesktopCapturerSource): Electr
|
||||
return screen.getPrimaryDisplay();
|
||||
}
|
||||
|
||||
function clearGuardTimeout(guard: ActiveWindowsScreenCaptureGuard): void {
|
||||
if (guard.timeout) {
|
||||
clearTimeout(guard.timeout);
|
||||
guard.timeout = null;
|
||||
function detachOwnerListener(guard: ActiveMonitorCompositionGuard): void {
|
||||
try {
|
||||
guard.owner.removeListener('destroyed', guard.onOwnerDestroyed);
|
||||
} catch (error) {
|
||||
log.debug('[WindowsScreenCaptureGuard] Failed to detach capturer listener', {error});
|
||||
}
|
||||
}
|
||||
|
||||
function armPendingGuardTimeout(guard: ActiveWindowsScreenCaptureGuard): void {
|
||||
clearGuardTimeout(guard);
|
||||
guard.timeout = setTimeout(() => {
|
||||
if (activeGuard !== guard || guard.retained) return;
|
||||
destroyActiveGuard('pending-timeout');
|
||||
}, PENDING_GUARD_TTL_MS);
|
||||
}
|
||||
|
||||
function retainGuardIfRequested(guard: ActiveWindowsScreenCaptureGuard): void {
|
||||
guard.retained = guardRetentionRequested;
|
||||
if (guard.retained) {
|
||||
clearGuardTimeout(guard);
|
||||
} else {
|
||||
armPendingGuardTimeout(guard);
|
||||
}
|
||||
}
|
||||
|
||||
function createGuardWindow(display: Electron.Display): BrowserWindow {
|
||||
function createCompositionGuardWindow(display: Electron.Display): BrowserWindow {
|
||||
const {x, y, width, height} = display.bounds;
|
||||
const guardWindow = new BrowserWindow({
|
||||
x: x + Math.max(0, width - GUARD_SIZE_DIP),
|
||||
@@ -118,57 +112,67 @@ function createGuardWindow(display: Electron.Display): BrowserWindow {
|
||||
return guardWindow;
|
||||
}
|
||||
|
||||
export function startWindowsScreenCaptureGuardForSource(source: Electron.DesktopCapturerSource): void {
|
||||
export function startWindowsScreenCaptureGuardForSource(
|
||||
source: Electron.DesktopCapturerSource,
|
||||
owner: Electron.WebContents,
|
||||
): void {
|
||||
if (process.platform !== 'win32') return;
|
||||
if (!isScreenSource(source)) return;
|
||||
if (activeGuard?.sourceId === source.id && !activeGuard.window.isDestroyed()) {
|
||||
retainGuardIfRequested(activeGuard);
|
||||
if (owner.isDestroyed()) return;
|
||||
if (!monitorCaptureUsesDxgiDuplication()) {
|
||||
log.info('[WindowsScreenCaptureGuard] Not forcing DWM composition; Chromium captures monitors with WGC here', {
|
||||
sourceId: source.id,
|
||||
windowsBuild: getWindowsBuildNumber(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (activeGuard?.sourceId === source.id && activeGuard.owner === owner && !activeGuard.window.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
destroyActiveGuard('replace');
|
||||
const display = resolveDisplayForSource(source);
|
||||
if (!display) return;
|
||||
try {
|
||||
const guardWindow = createGuardWindow(display);
|
||||
const guard: ActiveWindowsScreenCaptureGuard = {
|
||||
const guardWindow = createCompositionGuardWindow(display);
|
||||
const onOwnerDestroyed = (): void => {
|
||||
if (activeGuard?.window === guardWindow) {
|
||||
destroyActiveGuard('capturer-destroyed');
|
||||
}
|
||||
};
|
||||
const guard: ActiveMonitorCompositionGuard = {
|
||||
window: guardWindow,
|
||||
sourceId: source.id,
|
||||
displayId: source.display_id,
|
||||
retained: guardRetentionRequested,
|
||||
timeout: null,
|
||||
owner,
|
||||
onOwnerDestroyed,
|
||||
};
|
||||
guardWindow.once('closed', () => {
|
||||
if (activeGuard === guard) {
|
||||
clearGuardTimeout(guard);
|
||||
detachOwnerListener(guard);
|
||||
activeGuard = null;
|
||||
}
|
||||
});
|
||||
owner.once('destroyed', onOwnerDestroyed);
|
||||
activeGuard = guard;
|
||||
retainGuardIfRequested(guard);
|
||||
log.info('[WindowsScreenCaptureGuard] Armed monitor composition guard', {
|
||||
log.info('[WindowsScreenCaptureGuard] Forcing DWM composition on the captured monitor for DXGI duplication', {
|
||||
sourceId: source.id,
|
||||
displayId: source.display_id,
|
||||
retained: guard.retained,
|
||||
windowsBuild: getWindowsBuildNumber(),
|
||||
bounds: display.bounds,
|
||||
});
|
||||
} catch (error) {
|
||||
log.warn('[WindowsScreenCaptureGuard] Failed to arm monitor composition guard', {sourceId: source.id, error});
|
||||
log.warn('[WindowsScreenCaptureGuard] Failed to force DWM composition on the captured monitor', {
|
||||
sourceId: source.id,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function retainWindowsScreenCaptureGuard(): void {
|
||||
if (process.platform !== 'win32') return;
|
||||
guardRetentionRequested = true;
|
||||
if (!activeGuard) return;
|
||||
activeGuard.retained = true;
|
||||
clearGuardTimeout(activeGuard);
|
||||
}
|
||||
|
||||
function destroyActiveGuard(reason: string): void {
|
||||
const guard = activeGuard;
|
||||
if (!guard) return;
|
||||
activeGuard = null;
|
||||
clearGuardTimeout(guard);
|
||||
detachOwnerListener(guard);
|
||||
if (!guard.window.isDestroyed()) {
|
||||
try {
|
||||
guard.window.destroy();
|
||||
@@ -176,7 +180,7 @@ function destroyActiveGuard(reason: string): void {
|
||||
log.debug('[WindowsScreenCaptureGuard] Failed to destroy guard window', {reason, error});
|
||||
}
|
||||
}
|
||||
log.info('[WindowsScreenCaptureGuard] Stopped monitor composition guard', {
|
||||
log.info('[WindowsScreenCaptureGuard] Stopped forcing DWM composition on the captured monitor', {
|
||||
reason,
|
||||
sourceId: guard.sourceId,
|
||||
displayId: guard.displayId,
|
||||
@@ -184,6 +188,5 @@ function destroyActiveGuard(reason: string): void {
|
||||
}
|
||||
|
||||
export function stopWindowsScreenCaptureGuard(reason: string): void {
|
||||
guardRetentionRequested = false;
|
||||
destroyActiveGuard(reason);
|
||||
}
|
||||
|
||||
@@ -19,8 +19,6 @@ import {configureUserDataPath} from '@electron/common/UserDataPath';
|
||||
import {registerAutostartHandlers} from '@electron/main/Autostart';
|
||||
import {
|
||||
addLinuxHardwareVideoEncodeFeatures,
|
||||
addLinuxScreenCapturePipeWireFeature,
|
||||
addMacosPreSequoiaScreenCaptureDisabledFeatures,
|
||||
addWindowsHardwareVideoEncodeFeatures,
|
||||
appendConfiguredChromiumSwitches,
|
||||
appendDisabledChromiumFeatures,
|
||||
@@ -67,7 +65,6 @@ import {
|
||||
} from '@electron/main/NativeHardwareEncoder';
|
||||
import {runNativeModulePreflight} from '@electron/main/NativeModulePreflight';
|
||||
import {cleanupNativeScreenCapture, registerNativeScreenCaptureHandlers} from '@electron/main/NativeScreenCapture';
|
||||
import {appendOpenH264Switches} from '@electron/main/OpenH264Manager';
|
||||
import {cleanupLinuxChromiumSpellcheckDictionaries} from '@electron/main/Spellcheck';
|
||||
import {registerUpdater} from '@electron/main/Updater';
|
||||
import {
|
||||
@@ -265,10 +262,6 @@ if (launchConfigurationError) {
|
||||
addLinuxHardwareVideoEncodeFeatures(enabledChromiumFeatures);
|
||||
addWindowsHardwareVideoEncodeFeatures(enabledChromiumFeatures);
|
||||
}
|
||||
addLinuxScreenCapturePipeWireFeature(enabledChromiumFeatures);
|
||||
if (process.platform === 'darwin') {
|
||||
addMacosPreSequoiaScreenCaptureDisabledFeatures(disabledChromiumFeatures);
|
||||
}
|
||||
appendDisabledChromiumFeatures(disabledChromiumFeatures);
|
||||
if (enabledChromiumFeatures.size > 0) {
|
||||
appendEnabledChromiumFeatures(enabledChromiumFeatures);
|
||||
@@ -282,7 +275,6 @@ if (launchConfigurationError) {
|
||||
app.setToastActivatorCLSID(WINDOWS_TOAST_ACTIVATOR_CLSID);
|
||||
app.setAppUserModelId(WINDOWS_APP_USER_MODEL_ID);
|
||||
}
|
||||
appendOpenH264Switches();
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
if (!gotTheLock) {
|
||||
app.quit();
|
||||
@@ -339,12 +331,6 @@ if (launchConfigurationError) {
|
||||
} catch (error) {
|
||||
log.error('[Init] Failed to register IPC handlers:', error);
|
||||
}
|
||||
try {
|
||||
const {initOpenH264} = await import('@electron/main/OpenH264Manager');
|
||||
initOpenH264();
|
||||
} catch (error) {
|
||||
log.warn('[Init] OpenH264 initialization skipped:', error);
|
||||
}
|
||||
try {
|
||||
runStartupPhase('autostart-handlers', registerAutostartHandlers);
|
||||
} catch (error) {
|
||||
|
||||
@@ -42,7 +42,6 @@ import type {
|
||||
NativeScreenCaptureStartResult,
|
||||
NotificationOptions,
|
||||
NotificationResult,
|
||||
OpenH264Status,
|
||||
SetDesktopTroubleshootingDisableHardwareAccelerationOptions,
|
||||
SpellcheckBundledDictionary,
|
||||
SpellcheckResolvedEngineInfo,
|
||||
@@ -340,9 +339,6 @@ const api: ElectronAPI = {
|
||||
getDesktopInfo: (): Promise<DesktopInfo> => ipcRenderer.invoke('get-desktop-info'),
|
||||
getGpuInfo: (): Promise<GpuInfo> => ipcRenderer.invoke('get-gpu-info'),
|
||||
getAppMetrics: (): Promise<AppMetricsSnapshot> => ipcRenderer.invoke('get-app-metrics'),
|
||||
getOpenH264Status: (): Promise<OpenH264Status> => ipcRenderer.invoke('get-openh264-status'),
|
||||
setOpenH264Enabled: (enabled: boolean): Promise<OpenH264Status> =>
|
||||
ipcRenderer.invoke('set-openh264-enabled', enabled),
|
||||
getSystemIdleTimeMs: (): Promise<number> => ipcRenderer.invoke('system-idle-time-ms'),
|
||||
getDesktopWindowBehaviorSettings: (): Promise<DesktopWindowBehaviorSettings> =>
|
||||
ipcRenderer.invoke('desktop-window-behavior-get'),
|
||||
|
||||
Reference in New Issue
Block a user