// SPDX-License-Identifier: AGPL-3.0-or-later import type {GlobalShortcutsPortalConsent} from '@electron/common/DesktopConfig'; import { GLOBAL_SHORTCUT_ACTIONS, GLOBAL_SHORTCUT_HOLD_ACTIONS, type GlobalShortcutAction, } from '@electron/common/GlobalShortcutActions'; import type {GlobalShortcutsPortalState, GlobalShortcutsPortalStatus, LinuxDesktopKind} from '@electron/common/Types'; export interface PortalShortcutDefinition { id: string; description: string; preferredTrigger?: string | null; } export interface PortalShortcutBinding { id: string; description: string | null; triggerDescription: string | null; } export type PortalEvent = | {type: 'activated'; id: string} | {type: 'deactivated'; id: string} | {type: 'shortcuts-changed'; shortcuts: Array} | {type: 'session-lost'; reason: PortalSessionLostReason} | {type: 'portal-available'}; type PortalSessionLostReason = 'closed' | 'portal-restarted' | 'bus-error'; export interface PortalClientOptions { portalAppId: string | null; sandboxed: boolean; sessionToken: string; desktop: LinuxDesktopKind; } interface PortalOpenResult { version: number; appIdSource: 'sandbox' | 'registered' | 'unregistered'; uniqueName: string; listed: Array; } export type PortalBindOutcome = | {outcome: 'bound'; shortcuts: Array} | {outcome: 'cancelled'} | {outcome: 'denied'} | {outcome: 'failed'; code: number}; export interface PortalClient { open(): Promise; bind(shortcuts: Array, parentWindow: string): Promise; configure(parentWindow: string): Promise; close(): void; } export interface PortalTimer { cancel(): void; } export interface LinuxPortalShortcutsDeps { createPortal: (onEvent: (event: PortalEvent) => void) => PortalClient; desktop: LinuxDesktopKind; plasma5: boolean; portalAppId: string | null; getConsent: () => GlobalShortcutsPortalConsent; setConsent: (consent: GlobalShortcutsPortalConsent) => void; getDefinitions: () => Array; getParentWindow: () => string; onShortcut: (action: GlobalShortcutAction, phase: 'press' | 'release') => void; onStatusChanged: () => void; schedule: (callback: () => void, delayMs: number) => PortalTimer; now: () => number; log: (message: string, details?: Record) => void; } export const PORTAL_RECOVERY_BACKOFF_MS: ReadonlyArray = [1000, 2000, 5000, 10000, 30000]; export const PORTAL_NO_PORTAL_RETRY_WINDOW_MS = 30000; export const PORTAL_TOGGLE_REARM_MS = 1500; const PORTAL_CLOSED_RECOVERY_WINDOW_MS = 60000; const NO_PORTAL_ERROR = 'unsupported:no-portal'; const CONFIGURE_UNSUPPORTED_ERROR = 'unsupported:version'; const RESPONSE_CODE_ERROR = /^unsupported:\d+$/; type OpenFailure = {kind: 'no-portal' | 'unsupported' | 'identity' | 'timeout' | 'failed'; message: string}; function classifyOpenFailure(error: unknown): OpenFailure { const message = error instanceof Error ? error.message : String(error); if (message === NO_PORTAL_ERROR || RESPONSE_CODE_ERROR.test(message)) return {kind: 'no-portal', message}; if (message.startsWith('unsupported:')) return {kind: 'unsupported', message}; if (message.startsWith('identity:')) return {kind: 'identity', message}; if (message === 'error:timeout') return {kind: 'timeout', message}; return {kind: 'failed', message}; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } function isKnownAction(id: string): id is GlobalShortcutAction { return (GLOBAL_SHORTCUT_ACTIONS as ReadonlyArray).includes(id); } function normalizeTrigger(value: string | null): string | null { return value !== null && value.trim().length > 0 ? value : null; } export class LinuxPortalShortcutsManager { private state: GlobalShortcutsPortalState = 'unknown'; private error: string | null = null; private version: number | null = null; private registered = true; private configureUnsupported = false; private readonly triggers = new Map(); private readonly listedActions = new Set(); private retainedTriggers = false; private readonly activeIds = new Set(); private readonly lastActivatedAt = new Map(); private portal: PortalClient | null = null; private opened = false; private generation = 0; private active = false; private queue: Promise = Promise.resolve(); private recoveryTimer: PortalTimer | null = null; private recoveryAttempt = 0; private retry: 'session' | 'startup' | null = null; private recovering = false; private retrySince: number | null = null; private lastClosedRecoveryAt: number | null = null; private binding = false; private pendingLoss: PortalSessionLostReason | null = null; private setUpTask: Promise | null = null; constructor(private readonly deps: LinuxPortalShortcutsDeps) {} getState(): GlobalShortcutsPortalState { return this.state; } getStatus(): GlobalShortcutsPortalStatus { return { state: this.state, version: this.version, canConfigure: this.state === 'bound' && this.opened && !this.recovering && this.version !== null && this.version >= 2 && !this.configureUnsupported, recovering: this.recovering, canRecheck: !this.deps.plasma5 && (this.state === 'unsupported' || this.state === 'error'), portalAppId: this.registered ? this.deps.portalAppId : null, shortcuts: GLOBAL_SHORTCUT_ACTIONS.map((action) => ({ action, triggerDescription: this.triggers.get(action) ?? null, })), error: this.error, }; } activate(): Promise { if (this.active) return this.queue.then(() => undefined); this.active = true; if (this.deps.plasma5) { this.setState('unsupported', null); return Promise.resolve(); } if (this.deps.getConsent() !== 'granted') return Promise.resolve(); return this.enqueue(async () => { if (!this.active) return; await this.runStartupSequence(); }); } probe(): Promise { if (!this.active || this.state !== 'unknown') return this.queue.then(() => undefined); return this.enqueue(async () => { if (!this.active || this.state !== 'unknown') return; await this.runStartupSequence(); }); } deactivate(): void { if (!this.active) return; this.active = false; this.cancelRecovery(); this.resetRecovery(); this.pendingLoss = null; this.closePortal(); this.clearTriggers(); this.lastActivatedAt.clear(); this.version = null; this.setState('unknown', null); } releaseAll(): void { for (const action of [...this.activeIds]) { this.activeIds.delete(action); this.deps.onShortcut(action, 'release'); } } recheck(): Promise { if (!this.active || this.deps.plasma5) return this.queue.then(() => undefined); return this.enqueue(async () => { if (!this.active || (this.state !== 'unsupported' && this.state !== 'error')) return; this.cancelRecovery(); this.resetRecovery(); this.lastClosedRecoveryAt = null; this.setState('probing', null); await this.runStartupSequence(); }); } setUp(): Promise { if (!this.active || this.deps.plasma5) return this.queue.then(() => undefined); if (this.state === 'unsupported') return this.recheck(); if (this.setUpTask !== null) return this.setUpTask; if (this.binding) return this.queue.then(() => undefined); const task = this.enqueue(async () => { if (!this.active) return; this.cancelRecovery(); this.resetRecovery(); this.lastClosedRecoveryAt = null; if (!this.opened && !(await this.openSession())) return; const outcome = await this.bind(); if (outcome?.outcome === 'bound' && this.everyTriggerEmpty() && this.deps.desktop !== 'hyprland') { await this.runConfigure(); } }); this.setUpTask = task; void task.finally(() => { if (this.setUpTask === task) this.setUpTask = null; }); return task; } configure(): Promise { return this.enqueue(async () => { if (!this.active || !this.opened || this.recovering || this.portal === null) return; await this.runConfigure(); }); } private async runConfigure(): Promise { const portal = this.portal; if (portal === null || this.version === null || this.version < 2 || this.configureUnsupported) return; try { await portal.configure(this.deps.getParentWindow()); } catch (error) { const message = errorMessage(error); this.deps.log('Global shortcuts portal configure failed', {message}); if (message !== CONFIGURE_UNSUPPORTED_ERROR) return; this.configureUnsupported = true; this.deps.onStatusChanged(); } } private everyTriggerEmpty(): boolean { return GLOBAL_SHORTCUT_ACTIONS.every((action) => (this.triggers.get(action) ?? null) === null); } private async runStartupSequence(): Promise { await this.runStartupSteps(); if (!this.recovering || this.retry !== null) return; this.recovering = false; this.deps.onStatusChanged(); } private async runStartupSteps(): Promise { if (!(await this.openSession())) return; const consent = this.deps.getConsent(); if (consent === 'declined') { this.setState('declined', null); return; } if (consent === 'unset') { this.setState('not-set-up', null); return; } const version = this.version ?? 0; if (version < 2) { if (GLOBAL_SHORTCUT_ACTIONS.every((action) => this.listedActions.has(action))) { this.setState('bound', null); return; } if (this.deps.desktop === 'kde' && this.listedActions.size === 0) { this.setState('not-set-up', null); return; } } await this.bind(); } private async openSession(): Promise { this.closePortal(); this.pendingLoss = null; const generation = ++this.generation; if (this.state === 'unknown') this.setState('probing', null); let result: PortalOpenResult; try { const portal = this.deps.createPortal((event) => this.handleEvent(generation, event)); this.portal = portal; result = await portal.open(); } catch (error) { if (generation !== this.generation) return false; this.pendingLoss = null; this.handleOpenFailure(classifyOpenFailure(error)); return false; } if (generation !== this.generation) return false; const lost = this.pendingLoss; this.pendingLoss = null; if (lost !== null) { this.handleSessionLost(lost); return false; } this.deps.log('Global shortcuts portal opened', { version: result.version, appIdSource: result.appIdSource, uniqueName: result.uniqueName, }); this.opened = true; this.version = result.version; this.registered = result.appIdSource !== 'unregistered'; this.configureUnsupported = false; const recovering = this.recovering; this.resetRecovery(); this.recovering = recovering; this.listedActions.clear(); for (const binding of result.listed) { if (isKnownAction(binding.id)) this.listedActions.add(binding.id); } if (!this.retainedTriggers || this.listedActions.size > 0) this.replaceTriggers(result.listed); this.deps.onStatusChanged(); return true; } private handleOpenFailure(failure: OpenFailure): void { this.deps.log('Global shortcuts portal open failed', {kind: failure.kind, message: failure.message}); if (failure.kind === 'identity' || failure.kind === 'unsupported') { this.closePortal(); this.cancelRecovery(); this.resetRecovery(); if (failure.kind === 'identity') this.setState('error', 'identity'); else this.setState('unsupported', null); this.deps.onStatusChanged(); return; } if (this.retry === 'session') { if (this.recovering && this.recoveryAttempt >= PORTAL_RECOVERY_BACKOFF_MS.length) { this.recovering = false; this.setState('error', 'portal-unavailable'); this.deps.onStatusChanged(); } this.scheduleRecovery(); return; } const now = this.deps.now(); this.retry = 'startup'; this.retrySince ??= now; const remaining = this.retrySince + PORTAL_NO_PORTAL_RETRY_WINDOW_MS - now; const retryable = failure.kind === 'no-portal' || this.deps.getConsent() === 'granted'; this.recovering = retryable && remaining > 0; if (failure.kind === 'no-portal') this.setState('unsupported', null); else this.setState('error', failure.kind === 'timeout' ? 'timeout' : 'open-failed'); this.deps.onStatusChanged(); if (this.recovering) this.scheduleRecovery(remaining); } private async bind(): Promise { const portal = this.portal; if (portal === null) return null; const generation = this.generation; this.setState('binding', null); this.binding = true; let outcome: PortalBindOutcome | null = null; let failure: unknown = null; try { outcome = await portal.bind(this.deps.getDefinitions(), this.deps.getParentWindow()); } catch (error) { failure = error; } finally { this.binding = false; } const lost = this.pendingLoss; this.pendingLoss = null; if (generation !== this.generation) return null; if (outcome === null) { this.deps.log('Global shortcuts portal bind failed', {message: errorMessage(failure)}); if (lost !== null) { this.handleSessionLost(lost); return null; } this.closePortal(); this.setState('error', 'bind-failed'); await this.openSession(); return null; } this.applyBindOutcome(outcome); if (lost !== null) this.handleSessionLost(lost); return outcome; } private applyBindOutcome(outcome: PortalBindOutcome): void { switch (outcome.outcome) { case 'bound': this.replaceTriggers(outcome.shortcuts); this.deps.setConsent('granted'); this.setState('bound', null); return; case 'denied': if (this.deps.desktop !== 'gnome') { this.deps.setConsent('unset'); this.setState('unsupported', null); return; } this.deps.setConsent('declined'); this.setState('declined', null); return; case 'cancelled': this.deps.setConsent('declined'); this.setState('declined', null); return; case 'failed': this.deps.log('Global shortcuts portal bind returned a failure response', {code: outcome.code}); this.setState('error', 'bind-failed'); return; } } private handleEvent(generation: number, event: PortalEvent): void { if (generation !== this.generation) return; switch (event.type) { case 'activated': this.handleActivated(event.id); return; case 'deactivated': if (!isKnownAction(event.id) || !this.activeIds.delete(event.id)) return; this.deps.onShortcut(event.id, 'release'); return; case 'shortcuts-changed': this.handleShortcutsChanged(event.shortcuts); return; case 'session-lost': if (!this.opened) { if (this.portal !== null) this.pendingLoss ??= event.reason; return; } if (this.binding) { this.pendingLoss ??= event.reason; return; } this.handleSessionLost(event.reason); return; case 'portal-available': this.handlePortalAvailable(); return; } } private handleActivated(id: string): void { if (!isKnownAction(id)) return; const now = this.deps.now(); const previous = this.lastActivatedAt.get(id); this.lastActivatedAt.set(id, now); if (this.activeIds.has(id)) { if (GLOBAL_SHORTCUT_HOLD_ACTIONS.has(id)) return; if (previous !== undefined && now - previous < PORTAL_TOGGLE_REARM_MS) return; this.deps.onShortcut(id, 'release'); } else { this.activeIds.add(id); } this.deps.onShortcut(id, 'press'); } private handleShortcutsChanged(shortcuts: ReadonlyArray): void { const previous = new Map(this.triggers); if (this.retainedTriggers) this.replaceTriggers(shortcuts); else this.applyBindings(shortcuts); for (const action of [...this.activeIds]) { const trigger = this.triggers.get(action) ?? null; if (trigger !== null && trigger === (previous.get(action) ?? null)) continue; this.activeIds.delete(action); this.deps.onShortcut(action, 'release'); } this.deps.onStatusChanged(); } private handlePortalAvailable(): void { if (!this.active || this.opened || this.error === 'identity') return; if (this.state === 'unsupported' && this.retrySince === null) return; this.deps.log('Global shortcuts portal appeared on the bus'); this.cancelRecovery(); void this.enqueue(async () => { if (!this.active || this.opened) return; await this.runStartupSequence(); }); } private handleSessionLost(reason: PortalSessionLostReason): void { this.deps.log('Global shortcuts portal session lost', {reason}); this.closePortal(); this.retainedTriggers = this.triggers.size > 0; if (!this.active) return; if (reason === 'closed') { const now = this.deps.now(); const recentlyRecovered = this.lastClosedRecoveryAt !== null && now - this.lastClosedRecoveryAt < PORTAL_CLOSED_RECOVERY_WINDOW_MS; if (recentlyRecovered) { this.cancelRecovery(); this.resetRecovery(); this.setState('error', 'session-closed'); this.deps.onStatusChanged(); return; } this.lastClosedRecoveryAt = now; } this.retry = 'session'; this.recovering = true; this.deps.onStatusChanged(); this.scheduleRecovery(); } private scheduleRecovery(maxDelayMs = Number.POSITIVE_INFINITY): void { this.cancelRecovery(); const index = Math.min(this.recoveryAttempt, PORTAL_RECOVERY_BACKOFF_MS.length - 1); const delay = Math.min(PORTAL_RECOVERY_BACKOFF_MS[index] ?? 30000, maxDelayMs); this.recoveryAttempt += 1; this.recoveryTimer = this.deps.schedule(() => { this.recoveryTimer = null; void this.enqueue(async () => { if (!this.active || this.opened) return; await this.runStartupSequence(); }); }, delay); } private cancelRecovery(): void { this.recoveryTimer?.cancel(); this.recoveryTimer = null; } private resetRecovery(): void { this.retry = null; this.recovering = false; this.recoveryAttempt = 0; this.retrySince = null; } private replaceTriggers(bindings: ReadonlyArray): void { this.clearTriggers(); this.applyBindings(bindings); } private clearTriggers(): void { this.triggers.clear(); this.retainedTriggers = false; } private applyBindings(bindings: ReadonlyArray): void { for (const binding of bindings) { if (!isKnownAction(binding.id)) continue; this.triggers.set(binding.id, normalizeTrigger(binding.triggerDescription)); } } private closePortal(): void { this.generation += 1; this.opened = false; this.releaseAll(); const portal = this.portal; this.portal = null; if (portal === null) return; try { portal.close(); } catch (error) { this.deps.log('Global shortcuts portal close threw', {message: errorMessage(error)}); } } private setState(state: GlobalShortcutsPortalState, error: string | null): void { if (this.state === state && this.error === error) return; if (this.retainedTriggers && state !== 'bound' && state !== 'binding') this.clearTriggers(); this.state = state; this.error = error; this.deps.onStatusChanged(); } private enqueue(task: () => Promise): Promise { const run = this.queue.then(task); this.queue = run.catch((error: unknown) => { this.deps.log('Global shortcuts portal task failed', {message: errorMessage(error)}); }); return this.queue.then(() => undefined); } }