mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
419 lines
13 KiB
TypeScript
419 lines
13 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import {app} from 'electron';
|
|
import log from 'electron-log';
|
|
|
|
const RUNTIME_CACHE_STATE_FILE = 'chromium-runtime-cache-state-v1.json';
|
|
const RUNTIME_CACHE_DIRECTORIES = [
|
|
'Code Cache',
|
|
'GPUCache',
|
|
'DawnCache',
|
|
'DawnGraphiteCache',
|
|
'DawnWebGPUCache',
|
|
'GrShaderCache',
|
|
'GraphiteDawnCache',
|
|
'ShaderCache',
|
|
'Default/Code Cache',
|
|
'Default/GPUCache',
|
|
'Default/DawnCache',
|
|
'Default/DawnGraphiteCache',
|
|
'Default/DawnWebGPUCache',
|
|
'Default/GrShaderCache',
|
|
'Default/GraphiteDawnCache',
|
|
'Default/ShaderCache',
|
|
'Service Worker/ScriptCache',
|
|
'Default/Service Worker/ScriptCache',
|
|
];
|
|
const MACOS_PRE_SEQUOIA_SCREEN_CAPTURE_DISABLED_FEATURES = [
|
|
'ScreenCaptureKitMac',
|
|
'ScreenCaptureKitMacWindow',
|
|
'ScreenCaptureKitMacScreen',
|
|
'ScreenCaptureKitPickerScreen',
|
|
'ScreenCaptureKitStreamPickerSonoma',
|
|
'WarmScreenCaptureSonoma',
|
|
'UseSCContentSharingPicker',
|
|
];
|
|
const WINDOWS_WEBRTC_WGC_DISABLED_FEATURES = [
|
|
'AllowWgcScreenCapturer',
|
|
'AllowWgcWindowCapturer',
|
|
'AllowWgcScreenZeroHz',
|
|
'AllowWgcWindowZeroHz',
|
|
'WebRtcWgcRequireBorder',
|
|
];
|
|
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,
|
|
5079, 5080, 5081, 5082, 5104, 5105, 5106, 5107, 5112, 5113, 5114, 5115, 5121, 5126, 5127, 5159, 5655, 5656, 5657,
|
|
5658, 5735, 5965, 5966, 6044, 6082, 6088, 6128, 6129, 6141,
|
|
]);
|
|
|
|
const PCI_VENDOR_NVIDIA = 0x10de;
|
|
|
|
type ElectronGpuInfo = {
|
|
gpuDevice?: ReadonlyArray<{
|
|
active?: boolean;
|
|
vendorId?: number;
|
|
deviceId?: number;
|
|
}>;
|
|
devices?: ReadonlyArray<{
|
|
active?: boolean;
|
|
vendorId?: number;
|
|
deviceId?: number;
|
|
}>;
|
|
};
|
|
|
|
const CONFIGURED_CHROMIUM_SWITCH_ALLOWLIST = new Set([
|
|
'disable_accelerated_h264_decode',
|
|
'disable_accelerated_h264_encode',
|
|
'disable_accelerated_hevc_decode',
|
|
'disable_d3d11',
|
|
'disable_d3d11_video_decoder',
|
|
'disable_decode_swap_chain',
|
|
'disable_dxgi_zero_copy_video',
|
|
'disable_dynamic_video_encode_framerate_update',
|
|
'disable_media_foundation_clear_playback',
|
|
'disable_media_foundation_frame_size_change',
|
|
'disable_metal',
|
|
'disable_nv12_dxgi_video',
|
|
'force_high_performance_gpu',
|
|
'force_low_power_gpu',
|
|
]);
|
|
|
|
const CHROMIUM_FEATURE_SWITCHES = new Set([
|
|
'disable-blink-features',
|
|
'disable-features',
|
|
'enable-blink-features',
|
|
'enable-features',
|
|
]);
|
|
|
|
const LINUX_FLAGS_CONFIG_FILE_NAMES: Record<'stable' | 'canary', Array<string>> = {
|
|
stable: ['fluxer_desktop-flags.conf', 'fluxer-flags.conf'],
|
|
canary: ['fluxer_desktop_canary-flags.conf', 'fluxer-canary-flags.conf'],
|
|
};
|
|
|
|
export const MIDDLE_CLICK_AUTOSCROLL_BLINK_FEATURE = 'MiddleClickAutoscroll';
|
|
|
|
export const BASE_DISABLED_CHROMIUM_FEATURES = [
|
|
'WinRetrieveSuggestionsOnlyOnDemand',
|
|
'HardwareMediaKeyHandling',
|
|
'MediaSessionService',
|
|
'UseEcoQoSForBackgroundProcess',
|
|
'IntensiveWakeUpThrottling',
|
|
'AllowAggressiveThrottlingWithWebSocket',
|
|
];
|
|
|
|
interface RuntimeCacheState {
|
|
key?: string;
|
|
}
|
|
|
|
function safeFileFingerprint(filePath: string): {mtimeMs: number | null; size: number | null} {
|
|
try {
|
|
const stat = fs.statSync(filePath);
|
|
return {mtimeMs: Math.trunc(stat.mtimeMs), size: stat.size};
|
|
} catch {
|
|
return {mtimeMs: null, size: null};
|
|
}
|
|
}
|
|
|
|
function getRuntimeCacheKey(): string {
|
|
const executable = safeFileFingerprint(process.execPath);
|
|
const appBundle = safeFileFingerprint(path.join(process.resourcesPath, 'app.asar'));
|
|
return JSON.stringify({
|
|
appVersion: app.getVersion(),
|
|
electronVersion: process.versions.electron ?? null,
|
|
chromeVersion: process.versions.chrome ?? null,
|
|
nodeVersion: process.versions.node ?? null,
|
|
v8Version: process.versions.v8 ?? null,
|
|
executable,
|
|
appBundle,
|
|
});
|
|
}
|
|
|
|
function readRuntimeCacheState(filePath: string): RuntimeCacheState {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as RuntimeCacheState;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function writeRuntimeCacheState(filePath: string, state: RuntimeCacheState): void {
|
|
fs.writeFileSync(filePath, JSON.stringify(state, null, 2), 'utf8');
|
|
}
|
|
|
|
function resolveUserDataChild(userDataPath: string, relativePath: string): string | null {
|
|
const userDataRoot = path.resolve(userDataPath);
|
|
const target = path.resolve(userDataRoot, relativePath);
|
|
if (target !== userDataRoot && target.startsWith(`${userDataRoot}${path.sep}`)) {
|
|
return target;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return value !== null && typeof value === 'object';
|
|
}
|
|
|
|
function splitChromiumFeatureList(value: string): Array<string> {
|
|
if (!value) return [];
|
|
return value
|
|
.split(',')
|
|
.map((part) => part.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function appendChromiumFeatureSwitch(switchName: string, features: Iterable<string>): void {
|
|
const featureSet = new Set(splitChromiumFeatureList(app.commandLine.getSwitchValue(switchName)));
|
|
for (const feature of features) {
|
|
const trimmed = feature.trim();
|
|
if (trimmed.length > 0) {
|
|
featureSet.add(trimmed);
|
|
}
|
|
}
|
|
if (featureSet.size > 0) {
|
|
app.commandLine.appendSwitch(switchName, Array.from(featureSet).join(','));
|
|
}
|
|
}
|
|
|
|
function parseChromiumSwitchArgument(argument: string): {name: string; value?: string} | null {
|
|
const trimmed = argument.trim();
|
|
if (!trimmed.startsWith('--') || trimmed === '--') {
|
|
return null;
|
|
}
|
|
const switchText = trimmed.slice(2);
|
|
const equalsIndex = switchText.indexOf('=');
|
|
if (equalsIndex >= 0) {
|
|
const name = switchText.slice(0, equalsIndex).trim();
|
|
const value = switchText.slice(equalsIndex + 1).trim();
|
|
if (!isChromiumSwitchName(name)) return null;
|
|
return {name, value: stripMatchingQuotes(value)};
|
|
}
|
|
const whitespaceIndex = switchText.search(/\s/);
|
|
if (whitespaceIndex >= 0) {
|
|
const name = switchText.slice(0, whitespaceIndex).trim();
|
|
const value = switchText.slice(whitespaceIndex + 1).trim();
|
|
if (!isChromiumSwitchName(name)) return null;
|
|
return value.length > 0 ? {name, value: stripMatchingQuotes(value)} : {name};
|
|
}
|
|
if (!isChromiumSwitchName(switchText)) return null;
|
|
return {name: switchText};
|
|
}
|
|
|
|
function isChromiumSwitchName(name: string): boolean {
|
|
return /^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name);
|
|
}
|
|
|
|
function stripMatchingQuotes(value: string): string {
|
|
if (value.length < 2) return value;
|
|
const first = value[0];
|
|
const last = value[value.length - 1];
|
|
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
return value.slice(1, -1);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function parseChromiumFlagsConfig(contents: string): Array<string> {
|
|
return contents
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0 && !line.startsWith('#'));
|
|
}
|
|
|
|
function getLinuxConfigHome(): string | null {
|
|
const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim();
|
|
if (xdgConfigHome) {
|
|
return xdgConfigHome;
|
|
}
|
|
const home = os.homedir();
|
|
return home ? path.join(home, '.config') : null;
|
|
}
|
|
|
|
function appendChromiumCommandLineArgument(argument: string): boolean {
|
|
const parsed = parseChromiumSwitchArgument(argument);
|
|
if (!parsed) {
|
|
log.warn('[ChromiumRuntime] Ignoring invalid Chromium flags config entry', {argument});
|
|
return false;
|
|
}
|
|
if (parsed.value !== undefined && CHROMIUM_FEATURE_SWITCHES.has(parsed.name)) {
|
|
appendChromiumFeatureSwitch(parsed.name, splitChromiumFeatureList(parsed.value));
|
|
return true;
|
|
}
|
|
if (parsed.value !== undefined) {
|
|
app.commandLine.appendSwitch(parsed.name, parsed.value);
|
|
return true;
|
|
}
|
|
app.commandLine.appendSwitch(parsed.name);
|
|
return true;
|
|
}
|
|
|
|
export function appendEnabledBlinkFeature(feature: string): void {
|
|
appendChromiumFeatureSwitch('enable-blink-features', [feature]);
|
|
}
|
|
|
|
export function hasEnabledBlinkFeature(feature: string): boolean {
|
|
return splitChromiumFeatureList(app.commandLine.getSwitchValue('enable-blink-features')).includes(feature);
|
|
}
|
|
|
|
export function appendConfiguredChromiumSwitches(rawSwitches: unknown): void {
|
|
const switches = Array.isArray(rawSwitches) ? rawSwitches : isRecord(rawSwitches) ? Object.keys(rawSwitches) : [];
|
|
for (const chromiumSwitch of switches) {
|
|
if (typeof chromiumSwitch !== 'string') continue;
|
|
if (!CONFIGURED_CHROMIUM_SWITCH_ALLOWLIST.has(chromiumSwitch)) continue;
|
|
app.commandLine.appendSwitch(chromiumSwitch);
|
|
}
|
|
}
|
|
|
|
export function appendEnabledChromiumFeatures(features: Iterable<string>): void {
|
|
appendChromiumFeatureSwitch('enable-features', features);
|
|
}
|
|
|
|
export function appendDisabledChromiumFeatures(features: Iterable<string>): void {
|
|
appendChromiumFeatureSwitch('disable-features', features);
|
|
}
|
|
|
|
export function appendLinuxChromiumFlagsConfig(channel: 'stable' | 'canary'): void {
|
|
if (process.platform !== 'linux') return;
|
|
const configHome = getLinuxConfigHome();
|
|
if (!configHome) {
|
|
log.warn('[ChromiumRuntime] Cannot resolve Linux Chromium flags config directory');
|
|
return;
|
|
}
|
|
for (const fileName of LINUX_FLAGS_CONFIG_FILE_NAMES[channel]) {
|
|
const filePath = path.join(configHome, fileName);
|
|
if (!fs.existsSync(filePath)) continue;
|
|
try {
|
|
const flags = parseChromiumFlagsConfig(fs.readFileSync(filePath, 'utf8'));
|
|
let applied = 0;
|
|
let ignored = 0;
|
|
for (const flag of flags) {
|
|
if (appendChromiumCommandLineArgument(flag)) {
|
|
applied += 1;
|
|
} else {
|
|
ignored += 1;
|
|
}
|
|
}
|
|
log.info('[ChromiumRuntime] Loaded Linux Chromium flags config', {path: filePath, applied, ignored});
|
|
} catch (error) {
|
|
log.warn('[ChromiumRuntime] Failed to load Linux Chromium flags config', {path: filePath, error});
|
|
}
|
|
}
|
|
}
|
|
|
|
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 addWindowsWebRtcWgcDisabledFeatures(features: Set<string>): void {
|
|
if (process.platform !== 'win32') return;
|
|
for (const feature of WINDOWS_WEBRTC_WGC_DISABLED_FEATURES) {
|
|
features.add(feature);
|
|
}
|
|
}
|
|
|
|
export function addLinuxHardwareVideoEncodeFeatures(features: Set<string>): void {
|
|
if (process.platform !== 'linux') return;
|
|
features.add('AcceleratedVideoEncoder');
|
|
}
|
|
|
|
export interface ChromiumCommandLine {
|
|
appendSwitch(name: string, value?: string): void;
|
|
getSwitchValue(name: string): string;
|
|
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;
|
|
}
|
|
|
|
export function appendLinuxOzonePlatformHint(commandLine: ChromiumCommandLine = app.commandLine): void {
|
|
if (process.platform !== 'linux') return;
|
|
if (hasConfiguredOzonePlatformSwitch(commandLine)) return;
|
|
if (commandLine.hasSwitch('ozone-platform-hint')) return;
|
|
commandLine.appendSwitch('ozone-platform-hint', 'auto');
|
|
}
|
|
|
|
export function addWindowsHardwareVideoEncodeFeatures(features: Set<string>): void {
|
|
if (process.platform !== 'win32') return;
|
|
features.add('WebRtcAV1HWEncode');
|
|
}
|
|
|
|
export async function appendWindowsGpuDriverWorkaroundSwitches(): Promise<void> {
|
|
if (process.platform !== 'win32') return;
|
|
try {
|
|
const info = (await app.getGPUInfo('basic')) as ElectronGpuInfo;
|
|
const devices = info.gpuDevice ?? info.devices ?? [];
|
|
for (const gpu of devices) {
|
|
if (
|
|
gpu.active === true &&
|
|
gpu.vendorId === PCI_VENDOR_NVIDIA &&
|
|
typeof gpu.deviceId === 'number' &&
|
|
WINDOWS_NVIDIA_HEVC_DECODE_WORKAROUND_DEVICE_IDS.has(gpu.deviceId)
|
|
) {
|
|
app.commandLine.appendSwitch('disable_accelerated_hevc_decode', '1');
|
|
log.info('Windows NVIDIA HEVC decode workaround enabled', {deviceId: gpu.deviceId});
|
|
return;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
log.warn('Failed to evaluate Windows GPU driver workarounds', {error});
|
|
}
|
|
}
|
|
|
|
export function purgeChromiumRuntimeCachesIfNeeded(userDataPath: string): void {
|
|
try {
|
|
fs.mkdirSync(userDataPath, {recursive: true});
|
|
} catch (error) {
|
|
log.warn('[ChromiumRuntime] Failed to create user data directory for runtime cache guard', {error});
|
|
return;
|
|
}
|
|
const stateFilePath = path.join(userDataPath, RUNTIME_CACHE_STATE_FILE);
|
|
const cacheKey = getRuntimeCacheKey();
|
|
const previousState = readRuntimeCacheState(stateFilePath);
|
|
if (previousState.key === cacheKey) {
|
|
return;
|
|
}
|
|
const removed: Array<string> = [];
|
|
const errors: Array<{path: string; error: unknown}> = [];
|
|
for (const relativePath of RUNTIME_CACHE_DIRECTORIES) {
|
|
const target = resolveUserDataChild(userDataPath, relativePath);
|
|
if (!target) {
|
|
continue;
|
|
}
|
|
try {
|
|
if (fs.existsSync(target)) {
|
|
fs.rmSync(target, {recursive: true, force: true});
|
|
removed.push(relativePath);
|
|
}
|
|
} catch (error) {
|
|
errors.push({path: relativePath, error});
|
|
}
|
|
}
|
|
if (errors.length > 0) {
|
|
log.warn('[ChromiumRuntime] Failed to purge one or more runtime caches after an app/runtime change', {
|
|
removed,
|
|
errors,
|
|
});
|
|
return;
|
|
}
|
|
try {
|
|
writeRuntimeCacheState(stateFilePath, {key: cacheKey});
|
|
log.info('[ChromiumRuntime] Runtime cache guard completed', {removed});
|
|
} catch (error) {
|
|
log.warn('[ChromiumRuntime] Failed to write runtime cache state', {error});
|
|
}
|
|
}
|