mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
328 lines
12 KiB
TypeScript
328 lines
12 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {watch} from 'node:fs';
|
|
import {userInfo} from 'node:os';
|
|
import {getGlobalShortcutsSettings, setGlobalShortcutsSettings} from '@electron/common/DesktopConfig';
|
|
import {LINUX_DESKTOP_ENTRY_ID, LINUX_PORTAL_SESSION_TOKEN} from '@electron/common/DesktopIdentity';
|
|
import {createChildLogger} from '@electron/common/Logger';
|
|
import type {GlobalShortcutsStatus} from '@electron/common/Types';
|
|
import {
|
|
GlobalShortcutsEngine,
|
|
type GlobalShortcutsEngineDeps,
|
|
type GlobalShortcutsLinuxEnvironment,
|
|
type GlobalShortcutsSettingsStore,
|
|
type InputDeviceWatch,
|
|
portalDefinitionsFromStored,
|
|
sanitizeGlobalShortcutsSyncPayload,
|
|
} from '@electron/main/GlobalShortcutsEngine';
|
|
import {
|
|
createLinuxSessionStateMonitor,
|
|
createNativeGlobalShortcutsPortal,
|
|
createNativeHookBackend,
|
|
hasMacInputMonitoringAccess,
|
|
isLinuxKeyboardReadable,
|
|
} from '@electron/main/GlobalShortcutsNative';
|
|
import {getLinuxInputHookMode, getLinuxPortalsMode} from '@electron/main/LaunchOptions';
|
|
import {LegacyGlobalKeyHookAdapter, registerLegacyGlobalKeyHookHandlers} from '@electron/main/LegacyGlobalKeyHookIpc';
|
|
import {LinuxPortalShortcutsManager} from '@electron/main/LinuxGlobalShortcutsPortal';
|
|
import {getFlatpakAppId, isFlatpakRuntime} from '@electron/main/LinuxSandbox';
|
|
import {getLinuxDesktop, getLinuxSessionType, isKdePlasma5Session} from '@electron/main/LinuxSession';
|
|
import {requirePrivilegedRendererDocumentSender} from '@electron/main/PrivilegedRendererDocuments';
|
|
import {getMainWindow} from '@electron/main/Window';
|
|
import {app, ipcMain, powerMonitor} from 'electron';
|
|
|
|
const logger = createChildLogger('GlobalShortcuts');
|
|
|
|
let engine: GlobalShortcutsEngine | null = null;
|
|
let legacyAdapter: LegacyGlobalKeyHookAdapter | null = null;
|
|
const senderWatchers = new Map<number, Set<() => void>>();
|
|
let detachPowerListeners: (() => void) | null = null;
|
|
let detachPermissionRetry: (() => void) | null = null;
|
|
|
|
function log(message: string, details?: Record<string, unknown>): void {
|
|
logger.info(message, details ?? {});
|
|
}
|
|
|
|
function getShortcutsPlatform(): GlobalShortcutsEngineDeps['platform'] {
|
|
if (process.platform === 'darwin') return 'macos';
|
|
if (process.platform === 'win32') return 'windows';
|
|
return 'linux';
|
|
}
|
|
|
|
function watchSender(sender: Electron.WebContents, onGone: () => void): void {
|
|
const senderId = sender.id;
|
|
const existing = senderWatchers.get(senderId);
|
|
if (existing) {
|
|
existing.add(onGone);
|
|
return;
|
|
}
|
|
const callbacks = new Set([onGone]);
|
|
senderWatchers.set(senderId, callbacks);
|
|
const runCallbacks = (): void => {
|
|
const pending = [...callbacks];
|
|
callbacks.clear();
|
|
for (const callback of pending) callback();
|
|
};
|
|
sender.on('did-navigate', runCallbacks);
|
|
sender.on('render-process-gone', runCallbacks);
|
|
sender.once('destroyed', () => {
|
|
runCallbacks();
|
|
senderWatchers.delete(senderId);
|
|
});
|
|
}
|
|
|
|
function createSettingsStore(): GlobalShortcutsSettingsStore {
|
|
return {
|
|
getDirectInputEnabled: () => getGlobalShortcutsSettings().directInputEnabled,
|
|
setDirectInputEnabled: (enabled) => {
|
|
setGlobalShortcutsSettings({directInputEnabled: enabled});
|
|
},
|
|
isMigrated: () => getGlobalShortcutsSettings().migrated,
|
|
setMigrated: () => {
|
|
setGlobalShortcutsSettings({migrated: true});
|
|
},
|
|
getLastActions: () => getGlobalShortcutsSettings().lastActions,
|
|
setLastActions: (actions) => {
|
|
setGlobalShortcutsSettings({lastActions: actions});
|
|
},
|
|
};
|
|
}
|
|
|
|
function getPortalParentWindow(linux: GlobalShortcutsLinuxEnvironment): string {
|
|
const window = getMainWindow();
|
|
if (window === null || window.isDestroyed()) return '';
|
|
if (linux.sandboxed && linux.session === 'wayland') return '';
|
|
const x11Window = app.commandLine.getSwitchValue('ozone-platform') === 'x11' || linux.session !== 'wayland';
|
|
if (!x11Window) return '';
|
|
const handle = window.getNativeWindowHandle();
|
|
if (handle.length === 8) return `x11:${handle.readBigUInt64LE(0).toString(16)}`;
|
|
if (handle.length === 4) return `x11:${handle.readUInt32LE(0).toString(16)}`;
|
|
return '';
|
|
}
|
|
|
|
function schedule(callback: () => void, delayMs: number): {cancel(): void} {
|
|
const timer = setTimeout(callback, delayMs);
|
|
timer.unref?.();
|
|
return {cancel: () => clearTimeout(timer)};
|
|
}
|
|
|
|
function watchLinuxInputDevices(onChange: () => void): InputDeviceWatch | null {
|
|
try {
|
|
const watcher = watch('/dev/input', {persistent: false}, () => onChange());
|
|
watcher.on('error', (error) => {
|
|
log('Input device watch failed', {message: error.message});
|
|
watcher.close();
|
|
});
|
|
return {close: () => watcher.close()};
|
|
} catch (error) {
|
|
log('Input device watch unavailable', {message: error instanceof Error ? error.message : String(error)});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function createPortalManager(
|
|
linux: GlobalShortcutsLinuxEnvironment,
|
|
getEngine: () => GlobalShortcutsEngine | null,
|
|
): LinuxPortalShortcutsManager | null {
|
|
if (linux.session !== 'wayland' || getLinuxPortalsMode() === 'off') return null;
|
|
const hostPortalAppId = linux.sandboxed ? null : LINUX_DESKTOP_ENTRY_ID;
|
|
return new LinuxPortalShortcutsManager({
|
|
createPortal: (onEvent) =>
|
|
createNativeGlobalShortcutsPortal(onEvent, {
|
|
portalAppId: hostPortalAppId,
|
|
sandboxed: linux.sandboxed,
|
|
sessionToken: LINUX_PORTAL_SESSION_TOKEN,
|
|
desktop: linux.desktop,
|
|
}),
|
|
desktop: linux.desktop,
|
|
plasma5: isKdePlasma5Session(process.env),
|
|
portalAppId: linux.sandboxed ? getFlatpakAppId() : LINUX_DESKTOP_ENTRY_ID,
|
|
getConsent: () => getGlobalShortcutsSettings().portalConsent,
|
|
setConsent: (consent) => {
|
|
setGlobalShortcutsSettings({portalConsent: consent});
|
|
},
|
|
getDefinitions: () => portalDefinitionsFromStored(getGlobalShortcutsSettings().lastActions),
|
|
getParentWindow: () => getPortalParentWindow(linux),
|
|
onShortcut: (action, phase) => getEngine()?.handlePortalShortcut(action, phase),
|
|
onStatusChanged: () => getEngine()?.notifyStatus(),
|
|
schedule,
|
|
now: () => Date.now(),
|
|
log,
|
|
});
|
|
}
|
|
|
|
function attachPowerListeners(target: GlobalShortcutsEngine): () => void {
|
|
const releaseAll = (): void => {
|
|
target.releaseAllPressed();
|
|
};
|
|
powerMonitor.on('suspend', releaseAll);
|
|
powerMonitor.on('lock-screen', releaseAll);
|
|
const sessionMonitor = createLinuxSessionStateMonitor((event) => {
|
|
if (event.type === 'screen-locked') releaseAll();
|
|
});
|
|
return () => {
|
|
powerMonitor.off('suspend', releaseAll);
|
|
powerMonitor.off('lock-screen', releaseAll);
|
|
sessionMonitor?.close();
|
|
};
|
|
}
|
|
|
|
export function retryBlockedGlobalShortcutHooks(): void {
|
|
void engine?.retryBlockedHooks();
|
|
}
|
|
|
|
function attachMacPermissionRetry(target: GlobalShortcutsEngine): () => void {
|
|
const retryOnceGranted = (): void => {
|
|
if (target.getStatus().hookError !== 'permission' || !hasMacInputMonitoringAccess()) return;
|
|
void target.retryBlockedHooks();
|
|
};
|
|
app.on('browser-window-focus', retryOnceGranted);
|
|
app.on('activate', retryOnceGranted);
|
|
return () => {
|
|
app.off('browser-window-focus', retryOnceGranted);
|
|
app.off('activate', retryOnceGranted);
|
|
};
|
|
}
|
|
|
|
function logDesktopIdMismatch(linux: GlobalShortcutsLinuxEnvironment): void {
|
|
if (linux.sandboxed) return;
|
|
const expected = `${LINUX_DESKTOP_ENTRY_ID}.desktop`;
|
|
const actual = process.env.CHROME_DESKTOP ?? null;
|
|
if (actual !== expected) {
|
|
logger.warn('CHROME_DESKTOP does not match the portal app id', {expected, actual});
|
|
}
|
|
}
|
|
|
|
function readUsername(): string | null {
|
|
try {
|
|
return userInfo().username;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function requireEngine(): GlobalShortcutsEngine {
|
|
if (engine === null) throw new Error('Global shortcuts are not initialised');
|
|
return engine;
|
|
}
|
|
|
|
function ensureClient(event: Electron.IpcMainInvokeEvent, channel: string): number {
|
|
requirePrivilegedRendererDocumentSender(event, channel);
|
|
const current = requireEngine();
|
|
const sender = event.sender;
|
|
const senderId = sender.id;
|
|
if (!current.hasClient(senderId)) {
|
|
current.attachClient(senderId, (eventChannel, payload) => {
|
|
if (!sender.isDestroyed()) sender.send(eventChannel, payload);
|
|
});
|
|
watchSender(sender, () => engine?.detachClient(senderId));
|
|
}
|
|
return senderId;
|
|
}
|
|
|
|
function registerGlobalShortcutsHandlers(): void {
|
|
ipcMain.handle('global-shortcuts:sync', (event, payload: unknown): void => {
|
|
const senderId = ensureClient(event, 'global-shortcuts:sync');
|
|
const sanitized = sanitizeGlobalShortcutsSyncPayload(payload);
|
|
if (sanitized === null) throw new Error('Invalid global shortcuts sync payload');
|
|
requireEngine().sync(senderId, sanitized);
|
|
});
|
|
ipcMain.handle('global-shortcuts:set-paused', (event, paused: unknown): void => {
|
|
const senderId = ensureClient(event, 'global-shortcuts:set-paused');
|
|
if (typeof paused !== 'boolean') throw new Error('Invalid paused flag');
|
|
requireEngine().setPaused(senderId, paused);
|
|
});
|
|
ipcMain.handle('global-shortcuts:get-status', (event): GlobalShortcutsStatus => {
|
|
ensureClient(event, 'global-shortcuts:get-status');
|
|
return requireEngine().getStatus();
|
|
});
|
|
ipcMain.handle('global-shortcuts:set-up', async (event): Promise<GlobalShortcutsStatus> => {
|
|
ensureClient(event, 'global-shortcuts:set-up');
|
|
await requireEngine().setUp();
|
|
return requireEngine().getStatus();
|
|
});
|
|
ipcMain.handle('global-shortcuts:recheck', async (event): Promise<GlobalShortcutsStatus> => {
|
|
ensureClient(event, 'global-shortcuts:recheck');
|
|
await requireEngine().recheck();
|
|
return requireEngine().getStatus();
|
|
});
|
|
ipcMain.handle('global-shortcuts:start-capture', (event): Promise<number | null> => {
|
|
const senderId = ensureClient(event, 'global-shortcuts:start-capture');
|
|
return requireEngine().startCapture(senderId);
|
|
});
|
|
ipcMain.handle('global-shortcuts:stop-capture', (event, captureId: unknown): void => {
|
|
const senderId = ensureClient(event, 'global-shortcuts:stop-capture');
|
|
if (typeof captureId !== 'number' || !Number.isSafeInteger(captureId)) throw new Error('Invalid capture id');
|
|
requireEngine().stopCapture(senderId, captureId);
|
|
});
|
|
ipcMain.handle('global-shortcuts:configure', async (event): Promise<void> => {
|
|
ensureClient(event, 'global-shortcuts:configure');
|
|
await requireEngine().configure();
|
|
});
|
|
ipcMain.handle(
|
|
'global-shortcuts:set-direct-input-enabled',
|
|
async (event, enabled: unknown): Promise<GlobalShortcutsStatus> => {
|
|
ensureClient(event, 'global-shortcuts:set-direct-input-enabled');
|
|
if (typeof enabled !== 'boolean') throw new Error('Invalid direct input flag');
|
|
const current = requireEngine();
|
|
current.setDirectInputEnabled(enabled);
|
|
await current.refreshHooks();
|
|
return current.getStatus();
|
|
},
|
|
);
|
|
}
|
|
|
|
export function initializeGlobalShortcuts(): void {
|
|
if (engine !== null) return;
|
|
const platform = getShortcutsPlatform();
|
|
const linux: GlobalShortcutsLinuxEnvironment | null =
|
|
platform === 'linux'
|
|
? {
|
|
session: getLinuxSessionType(),
|
|
sandboxed: isFlatpakRuntime(),
|
|
desktop: getLinuxDesktop(),
|
|
inputHookMode: getLinuxInputHookMode(),
|
|
}
|
|
: null;
|
|
if (linux !== null) logDesktopIdMismatch(linux);
|
|
const portal = linux === null ? null : createPortalManager(linux, () => engine);
|
|
const created = new GlobalShortcutsEngine({
|
|
platform,
|
|
linux,
|
|
settings: createSettingsStore(),
|
|
portal,
|
|
createHookBackend: createNativeHookBackend,
|
|
isDirectInputAvailable: isLinuxKeyboardReadable,
|
|
watchInputDevices: watchLinuxInputDevices,
|
|
schedule,
|
|
log,
|
|
});
|
|
engine = created;
|
|
const adapter = new LegacyGlobalKeyHookAdapter();
|
|
legacyAdapter = adapter;
|
|
created.addConsumer(adapter);
|
|
registerGlobalShortcutsHandlers();
|
|
registerLegacyGlobalKeyHookHandlers(created, adapter, {
|
|
platform: process.platform,
|
|
sandboxed: linux?.sandboxed === true,
|
|
username: readUsername(),
|
|
hasInputMonitoringAccess: hasMacInputMonitoringAccess,
|
|
watchSender,
|
|
});
|
|
detachPowerListeners = attachPowerListeners(created);
|
|
if (platform === 'macos') detachPermissionRetry = attachMacPermissionRetry(created);
|
|
log('Global shortcuts initialised', {platform, linux, backend: created.getBackendSelection()});
|
|
created.start();
|
|
}
|
|
|
|
export function cleanupGlobalShortcuts(): void {
|
|
detachPowerListeners?.();
|
|
detachPowerListeners = null;
|
|
detachPermissionRetry?.();
|
|
detachPermissionRetry = null;
|
|
legacyAdapter?.clear();
|
|
legacyAdapter = null;
|
|
engine?.dispose();
|
|
engine = null;
|
|
}
|