Files
fluxer/fluxer_app/src/features/app/keybindings/KeybindManager.ts
T

2093 lines
76 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import {organizeChannels} from '@app/features/app/components/layout/utils/ChannelOrganization';
import {
keyboardEventCanRecoverStaleMacMetaPress,
keyboardEventMatchesCombo,
keyboardEventReleasesComboModifier,
keyboardEventStartsComboPress,
keyboardEventTriggerMatchesCombo,
shouldAllowLocalShortcutForChannelTextarea,
} from '@app/features/app/keybindings/KeybindEventUtils';
import {
isKeybindAllowedDuringVoiceCallFullscreen,
isKeybindBlockedByCompactVoiceCallView,
} from '@app/features/app/keybindings/KeybindScopeUtils';
import {registerDefaultKeybindHandlers} from '@app/features/app/keybindings/keybind_manager/handlers/defaultHandlers';
import {registerMessageHandlers} from '@app/features/app/keybindings/keybind_manager/handlers/messageHandlers';
import type {
CombokeysInstance,
HoldBindingRuntime,
KeybindHandler,
ShortcutSource,
} from '@app/features/app/keybindings/keybind_manager/shared';
import {comboToCombokeysStrings} from '@app/features/app/keybindings/utils/ComboShortcutStrings';
import {
EDITABLE_CAPTURE_SHORTCUT_ACTIONS,
getEditableElementValue,
isChannelTextareaElement,
isEditableElement,
} from '@app/features/app/keybindings/utils/EditableElement';
import {keyNameForGlobalHook, physicalKeyNameForGlobalHook} from '@app/features/app/keybindings/utils/GlobalHookKeys';
import {
shouldSuppressLocalShortcutForModalFocus,
shouldSuppressShortcutForFullscreenMedia,
} from '@app/features/app/keybindings/utils/ModalSuppression';
import {reactToPushToTalkModeChanges} from '@app/features/app/keybindings/utils/PushToTalkModeReconcile';
import {
buildCustomRuntimeKeybinds,
buildDefaultRuntimeKeybinds,
gamepadSourceIdForKeybind,
HOLD_ACTIONS,
HOLD_ACTIONS_FOR_PTT_MODE,
HOLD_ACTIONS_FOR_VOICE_ACTIVITY_MODE,
type HoldAction,
hookShortcutIdForAction,
hookShortcutIdForKeybind,
type RuntimeKeybind,
sourceIdForKeybind,
} from '@app/features/app/keybindings/utils/RuntimeKeybinds';
import {LOCAL_SHORTCUT_ACTION_PRIORITY} from '@app/features/app/keybindings/utils/ShortcutPriority';
import Authentication from '@app/features/auth/state/Authentication';
import type {Channel} from '@app/features/channel/models/Channel';
import Channels from '@app/features/channel/state/Channels';
import type {Guild} from '@app/features/guild/models/Guild';
import GuildList from '@app/features/guild/state/GuildList';
import Guilds from '@app/features/guild/state/Guilds';
import GlobalShortcuts, {getGlobalShortcutsApi} from '@app/features/input/state/GlobalShortcuts';
import Keybind, {
type CustomKeybindEntry,
isKeybindCommand,
type KeybindCommand,
type KeybindConfig,
type KeyCombo,
} from '@app/features/input/state/InputKeybind';
import {getSuppressedBuiltinActions} from '@app/features/input/state/KeybindResolution';
import {isGamepadButtonPressed} from '@app/features/input/utils/GamepadButtonUtils';
import {shouldPreferLayoutKeyForShortcut} from '@app/features/input/utils/KeybindComboUtils';
import {shouldUseKeyboardShortcutsOverlayFallbackFromEvent} from '@app/features/input/utils/KeyboardShortcutLayoutUtils';
import {jsKeyToUiohookKeycode} from '@app/features/input/utils/UiohookKeycodes';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import MessageFocus from '@app/features/messaging/state/MessageFocus';
import {getSortedDmChannels} from '@app/features/messaging/utils/DmChannelUtils';
import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands';
import Navigation from '@app/features/navigation/state/Navigation';
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
import SelectedGuild from '@app/features/navigation/state/SelectedGuild';
import NativePermission from '@app/features/permissions/system/state/NativePermission';
import {ensureMacPermission} from '@app/features/permissions/system/utils/MacPermissionGate';
import {Logger} from '@app/features/platform/utils/AppLogger';
import ReadStates from '@app/features/read_state/state/ReadStates';
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
import {isWebReservedZoomShortcut} from '@app/features/ui/utils/AppZoomKeybindUtils';
import {getElectronAPI, isNativeMacOS} from '@app/features/ui/utils/NativeUtils';
import * as CallCommands from '@app/features/voice/commands/CallCommands';
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
import CallInitiator from '@app/features/voice/state/CallInitiator';
import CallState from '@app/features/voice/state/CallState';
import CompactVoiceCallHeight, {
getCompactVoiceCallExpansionKey,
getGuildVoiceCallExpansionKey,
} from '@app/features/voice/state/CompactVoiceCallHeight';
import MockIncomingCall from '@app/features/voice/state/MockIncomingCall';
import VoiceCallFullscreen from '@app/features/voice/state/VoiceCallFullscreen';
import type {
GlobalShortcutActionDefinition,
GlobalShortcutBinding,
GlobalShortcutCombo,
GlobalShortcutEvent,
GlobalShortcutsApi,
GlobalShortcutsSyncPayload,
} from '@app/types/electron.d';
import {ME} from '@fluxer/constants/src/AppConstants';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import type {I18n} from '@lingui/core';
import CombokeysImport from 'combokeys';
import {autorun, compareStructural, reaction} from 'mobx';
const normalizeKeyboardShortcutKey = (key: string): string => {
if (key === ' ') return 'space';
if (key === 'Break') return 'pause';
return key.toLowerCase();
};
export {comboToCombokeysStrings} from '@app/features/app/keybindings/utils/ComboShortcutStrings';
export {keyNameForGlobalHook, physicalKeyNameForGlobalHook} from '@app/features/app/keybindings/utils/GlobalHookKeys';
export {
shouldSuppressLocalShortcutForModalFocus,
shouldSuppressShortcutForFullscreenMedia,
} from '@app/features/app/keybindings/utils/ModalSuppression';
const ROUTE_ALLOWED_ACTIONS = new Set<KeybindCommand>(['system_open_theme_studio_popout']);
const GAMEPAD_POLL_INTERVAL_MS = 50;
const PORTAL_SOURCE_ID_PREFIX = 'portal:';
interface HoldBindingEntry {
action: HoldAction;
sourceId: string;
gamepadSourceId: string;
combo: KeyCombo;
}
interface GlobalHoldRouting {
hooksActive: boolean;
supportsMouseButtons: boolean;
supportsModifierOnly: boolean;
}
function isHoldAction(action: string): action is HoldAction {
return HOLD_ACTIONS.includes(action as HoldAction);
}
function comboHasModifiers(combo: GlobalShortcutCombo): boolean {
return combo.ctrl || combo.alt || combo.shift || combo.meta;
}
function toGlobalShortcutCombo(combo: KeyCombo): GlobalShortcutCombo | null {
if (combo.gamepadButton != null) return null;
const hasKey = (combo.key ?? '') !== '' || (combo.code ?? '') !== '';
if (!hasKey && combo.mouseButton == null) return null;
const isMacOS = isNativeMacOS();
const result: GlobalShortcutCombo = {
key: combo.key ?? '',
ctrl: Boolean(combo.ctrl) || (!isMacOS && Boolean(combo.ctrlOrMeta)),
alt: Boolean(combo.alt),
shift: Boolean(combo.shift),
meta: Boolean(combo.meta) || (isMacOS && Boolean(combo.ctrlOrMeta)),
};
if (combo.code) result.code = combo.code;
if (combo.mouseButton != null) result.mouseButton = combo.mouseButton;
if (combo.modifierOnly) result.modifierOnly = true;
if (combo.modifierOnly && combo.bothSides) result.bothSides = true;
return result;
}
function createHoldBindingRuntime(entry: HoldBindingEntry): HoldBindingRuntime {
const {combo} = entry;
return {
action: entry.action,
sourceId: entry.sourceId,
gamepadSourceId: entry.gamepadSourceId,
combo,
keycode: null,
keyName: null,
physicalKeyName: null,
mouseButton: combo.mouseButton ?? null,
gamepadButton: combo.gamepadButton ?? null,
isModifierOnly: Boolean(combo.modifierOnly),
ctrlOrMeta: Boolean(combo.ctrlOrMeta),
requireBothSides: Boolean(combo.modifierOnly && combo.bothSides),
modifiers: {
ctrl: Boolean(combo.ctrl),
alt: Boolean(combo.alt),
shift: Boolean(combo.shift),
meta: Boolean(combo.meta),
},
routing: null,
pressedKeycodes: new Set<number>(),
localPressedCodes: new Set<string>(),
localActiveCode: null,
localMouseActive: false,
globalMouseActive: false,
localKeyDown: null,
localKeyUp: null,
localMouseDown: null,
localMouseUp: null,
gamepadHeld: false,
};
}
function legacyHoldSourceId(binding: HoldBindingRuntime): string {
return `legacy:${hookShortcutIdForAction(binding.action, binding.combo)}`;
}
class KeybindManager {
private handlers = new Map<KeybindCommand, KeybindHandler>();
private initialized = false;
private manualSuspendCount = 0;
private routeSuspended = false;
private disposers: Array<() => void> = [];
private combokeys: CombokeysInstance | null = null;
private inputMonitoringHookStatus: 'unknown' | 'granted' | 'denied' = 'unknown';
private registeredGlobalHookShortcutIds = new Set<string>();
private globalShortcutsEventUnsubscribe: (() => void) | null = null;
private syncedGlobalSourceIds = new Set<string>();
private syncedSourceIdByHookId = new Map<string, string>();
private syncedGlobalActions: Array<GlobalShortcutActionDefinition> = [];
private globalKeyHookUnsubscribes: Array<() => void> = [];
private globalKeybindTriggeredUnsubscribe: (() => void) | null = null;
private globalKeyHookStarted = false;
private activeGlobalShortcutPressIds = new Set<string>();
private activeLocalShortcutPressIds = new Set<string>();
private inputSyncQueue: Promise<void> = Promise.resolve();
private holdBindings: Array<HoldBindingRuntime> = [];
private localHoldListenerAttached = false;
private localKeyboardShortcutListenerAttached = false;
private localMouseShortcutListenerAttached = false;
private gamepadPollIntervalId: number | null = null;
private gamepadListenersAttached = false;
private gamepadShortcutStates = new Map<string, {binding: RuntimeKeybind; pressed: boolean}>();
logger = new Logger('KeybindManager');
private get suspended(): boolean {
return this.manualSuspendCount > 0;
}
get currentChannelId(): string | null {
return SelectedChannel.currentChannelId;
}
get currentGuildId(): string | null {
return SelectedGuild.selectedGuildId;
}
navigateToChannel(guildId: string | null, channelId: string): void {
const channel = Channels.getChannel(channelId);
const effectiveGuildId = guildId ?? channel?.guildId ?? null;
if (channel?.guildId) {
NavigationCommands.selectChannel(channel.guildId, channelId);
return;
}
if (channel && !channel.guildId) {
NavigationCommands.selectChannel(ME, channelId);
return;
}
if (effectiveGuildId) {
NavigationCommands.selectChannel(effectiveGuildId, channelId);
}
}
private get resolvedKeybinds(): Array<RuntimeKeybind> {
const skipDefaults = Keybind.getDisableBuiltinKeybinds();
const defaults = skipDefaults ? [] : Keybind.getDefaultsForRuntimeDispatch();
const customs = Keybind.getCustomKeybinds();
return [
...buildDefaultRuntimeKeybinds(defaults, getSuppressedBuiltinActions(customs)),
...buildCustomRuntimeKeybinds(customs, (action) => Keybind.getDefaultByAction(action)),
];
}
private get activeKeybinds(): Array<RuntimeKeybind> {
return this.resolvedKeybinds.filter((entry) => this.isActionAllowedForCurrentView(entry.action));
}
private get activeGlobalKeybinds(): Array<RuntimeKeybind> {
return this.activeKeybinds.filter(
(k) =>
!HOLD_ACTIONS.includes(k.action as HoldAction) &&
k.allowGlobal &&
(k.combo.global ?? false) &&
((k.combo.key ?? '') !== '' || (k.combo.code ?? '') !== ''),
);
}
private get localMouseShortcutKeybinds(): Array<RuntimeKeybind> {
return this.resolvedKeybinds.filter(
(k) => !HOLD_ACTIONS.includes(k.action as HoldAction) && k.combo.mouseButton != null,
);
}
private get localGamepadShortcutKeybinds(): Array<RuntimeKeybind> {
return this.resolvedKeybinds.filter(
(k) => !HOLD_ACTIONS.includes(k.action as HoldAction) && k.combo.gamepadButton != null,
);
}
private getOrderedGuilds(): Array<Guild> {
if (GuildList.guilds.length > 0) {
return GuildList.guilds;
}
return Guilds.getGuilds();
}
navigateToDirectMessages(): void {
const channelId = SelectedChannel.selectedChannelIds.get(ME);
if (channelId && Channels.getChannel(channelId)) {
this.navigateToChannel(ME, channelId);
return;
}
NavigationCommands.deselectGuild();
}
navigateToLastCommunityChannel(): boolean {
const guildId = this.currentGuildId ?? SelectedGuild.lastSelectedGuildId;
if (!guildId) return false;
const channelId =
SelectedChannel.getNavigableSelectedChannelId(guildId) ?? this.getFirstSelectableChannelId(guildId);
if (!channelId) return false;
this.navigateToChannel(guildId, channelId);
return true;
}
navigateToGuildLikeSlot(slotIndex: number): void {
if (slotIndex === 0) {
this.navigateToDirectMessages();
return;
}
const guild = this.getOrderedGuilds()[slotIndex - 1];
if (!guild) return;
const channelId =
SelectedChannel.getNavigableSelectedChannelId(guild.id) ?? this.getFirstSelectableChannelId(guild.id);
if (!channelId) return;
this.navigateToChannel(guild.id, channelId);
}
cycleGuildLikeSlot(direction: 1 | -1): void {
const guilds = this.getOrderedGuilds();
const currentGuildId = this.currentGuildId;
const currentIndex = currentGuildId ? guilds.findIndex((guild) => guild.id === currentGuildId) + 1 : 0;
const safeIndex = currentIndex <= 0 && currentGuildId ? 0 : currentIndex;
const slotCount = guilds.length + 1;
const nextIndex = (safeIndex + direction + slotCount) % slotCount;
this.navigateToGuildLikeSlot(nextIndex);
}
private getFirstSelectableChannelId(guildId: string): string | undefined {
const channels = Channels.getGuildChannels(guildId);
const selectableChannel = channels.find((c) => this.isNavigableChannel(c));
return selectableChannel?.id;
}
private isNavigableChannel(channel: Channel): boolean {
if (channel.type === ChannelTypes.GUILD_CATEGORY) return false;
if (channel.type === ChannelTypes.GUILD_LINK) return false;
return true;
}
private getNavigableChannelsInCurrentContext(): ReadonlyArray<Channel> {
const guildId = this.currentGuildId;
if (!guildId) return [];
return this.flattenGuildChannelsByDisplayOrder(Channels.getGuildChannels(guildId));
}
private flattenGuildChannelsByDisplayOrder(channels: ReadonlyArray<Channel>): Array<Channel> {
const groups = organizeChannels(channels);
const flat: Array<Channel> = [];
for (const group of groups) {
for (const ch of group.textChannels) {
if (this.isNavigableChannel(ch)) flat.push(ch);
}
for (const ch of group.voiceChannels) {
if (this.isNavigableChannel(ch)) flat.push(ch);
}
}
return flat;
}
private cycleDirectMessageContext(direction: 1 | -1): void {
const dmChannels = getSortedDmChannels(Channels.dmChannels, Authentication.currentUserId);
const slotCount = dmChannels.length + 1;
const currentChannelId = this.currentChannelId;
const currentIndex = currentChannelId ? dmChannels.findIndex((channel) => channel.id === currentChannelId) + 1 : 0;
const safeIndex = currentIndex <= 0 && currentChannelId ? 0 : currentIndex;
const nextIndex = (safeIndex + direction + slotCount) % slotCount;
if (nextIndex === 0) {
NavigationCommands.deselectGuild();
return;
}
const channel = dmChannels[nextIndex - 1];
if (channel) {
this.navigateToChannel(ME, channel.id);
}
}
cycleChannelInCurrentContext(direction: 1 | -1): void {
if (Navigation.context === 'dm') {
this.cycleDirectMessageContext(direction);
return;
}
const channels = this.getNavigableChannelsInCurrentContext();
if (!channels.length) return;
const current = this.currentChannelId;
const idx = current ? channels.findIndex((c) => c.id === current) : -1;
const base = idx === -1 ? (direction === 1 ? -1 : 0) : idx;
const target = channels[(base + direction + channels.length) % channels.length];
this.navigateToChannel(target.guildId ?? null, target.id);
}
cycleFilteredChannelInCurrentGuild(predicate: (channel: Channel) => boolean, direction: 1 | -1): void {
const guildId = this.currentGuildId;
if (!guildId) return;
const channels = this.flattenGuildChannelsByDisplayOrder(Channels.getGuildChannels(guildId)).filter((c) =>
predicate(c),
);
if (!channels.length) return;
const current = this.currentChannelId;
const idx = current ? channels.findIndex((c) => c.id === current) : -1;
const base = idx === -1 ? (direction === 1 ? -1 : 0) : idx;
const target = channels[(base + direction + channels.length) % channels.length];
this.navigateToChannel(guildId, target.id);
}
getIncomingCallChannelId(): string | null {
const mockCall = MockIncomingCall.mockCall;
if (mockCall) return mockCall.channel.id;
const currentUserId = Authentication.currentUserId;
if (!currentUserId) return null;
for (const call of CallState.getActiveCalls()) {
if (MediaEngine.connected && MediaEngine.channelId === call.channelId) continue;
if (CallInitiator.hasInitiated(call.channelId)) continue;
if (CallState.isUserPendingRinging(call.channelId, currentUserId)) {
return call.channelId;
}
}
return null;
}
acceptIncomingCall(channelId: string): void {
if (MockIncomingCall.isMockCall(channelId)) {
MockIncomingCall.clearMockCall();
return;
}
CallCommands.joinCall(channelId);
}
declineIncomingCall(channelId: string): void {
if (MockIncomingCall.isMockCall(channelId)) {
MockIncomingCall.clearMockCall();
return;
}
CallCommands.rejectCall(channelId);
}
private isActionAvailableForLocalShortcut(action: KeybindCommand): boolean {
if (action === 'voice_answer_call' || action === 'voice_decline_call') {
return this.getIncomingCallChannelId() !== null;
}
if (action === 'chat_mark_channel_read') {
const channelId = this.currentChannelId;
return Boolean(channelId && ReadStates.hasUnread(channelId));
}
return true;
}
private isAppRoute(pathname: string): boolean {
return pathname.startsWith('/channels/');
}
private ensureCombokeys(): CombokeysInstance | null {
if (!this.combokeys && CombokeysImport) {
this.combokeys = new CombokeysImport(document.documentElement);
if (this.combokeys) {
this.combokeys.stopCallback = () => false;
}
}
return this.combokeys;
}
private async checkInputMonitoringPermission(): Promise<boolean> {
if (!isNativeMacOS()) return true;
if (this.inputMonitoringHookStatus === 'granted') return true;
const electronApi = getElectronAPI();
if (await electronApi?.checkInputMonitoringAccess?.()) {
NativePermission.setInputMonitoringStatus('granted');
this.inputMonitoringHookStatus = 'granted';
return true;
}
const result = await ensureMacPermission('input-monitoring', {behavior: 'passive'});
switch (result) {
case 'granted':
NativePermission.setInputMonitoringStatus('granted');
this.inputMonitoringHookStatus = 'granted';
return true;
case 'denied':
case 'declined':
NativePermission.setInputMonitoringStatus('denied');
this.inputMonitoringHookStatus = 'denied';
return false;
case 'unsupported-platform':
this.inputMonitoringHookStatus = 'unknown';
return true;
}
}
async init(i18n: I18n) {
if (this.initialized) return;
this.initialized = true;
this.ensureCombokeys();
this.registerDefaultHandlers(i18n);
await Keybind.refreshKeyboardShortcutLayout();
this.routeSuspended = !this.isAppRoute(Navigation.pathname);
this.refreshLocalShortcuts();
this.disposers.push(
reaction(
() => this.resolvedKeybinds,
() => this.refreshLocalShortcuts(),
),
);
GlobalShortcuts.attach();
const globalShortcutsApi = getGlobalShortcutsApi();
if (globalShortcutsApi) {
this.attachGlobalShortcuts(globalShortcutsApi);
} else {
this.disposers.push(
autorun(() => {
const desired = this.computeDesiredGlobalHookShortcuts();
void this.enqueueInputSync(() => this.applyGlobalShortcuts(desired));
}),
);
}
this.disposers.push(reactToPushToTalkModeChanges((options) => MediaEngine.handlePushToTalkModeChange(options)));
this.disposers.push(
reaction(
() => ({entries: this.buildHoldBindingEntries(), routing: this.getGlobalHoldRouting()}),
({entries, routing}) => {
const bindings = entries.map(createHoldBindingRuntime);
void this.enqueueInputSync(() => this.applyHoldBindings(bindings, routing));
},
{equals: compareStructural, fireImmediately: true},
),
);
this.disposers.push(
autorun(() => {
const pathname = Navigation.pathname;
const isAppRoute = this.isAppRoute(pathname);
this.setRouteSuspended(!isAppRoute);
}),
);
await this.inputSyncQueue;
}
private enqueueInputSync(task: () => Promise<void> | void): Promise<void> {
const run = this.inputSyncQueue.then(() => task());
this.inputSyncQueue = run.then(
() => undefined,
(error) => {
this.logger.error('Input binding sync task failed', error);
},
);
return this.inputSyncQueue;
}
private buildHoldBindingEntries(): Array<HoldBindingEntry> {
const entries: Array<HoldBindingEntry> = [];
const inPttMode = Keybind.isPushToTalkEffective();
const eligibleActions = inPttMode ? HOLD_ACTIONS_FOR_PTT_MODE : HOLD_ACTIONS_FOR_VOICE_ACTIVITY_MODE;
const customs: ReadonlyArray<CustomKeybindEntry> = Keybind.getCustomKeybinds();
for (const action of eligibleActions) {
for (const entry of customs) {
if (!entry.enabled) continue;
if (entry.action !== action) continue;
const combo = entry.combo;
const hasBinding = !!(combo.key || combo.code || combo.gamepadButton != null || combo.mouseButton != null);
if (!hasBinding) continue;
const keybind = {id: entry.id, action};
entries.push({
action,
sourceId: sourceIdForKeybind(keybind),
gamepadSourceId: gamepadSourceIdForKeybind(keybind),
combo: {...combo},
});
}
}
return entries;
}
private getGlobalHoldRouting(): GlobalHoldRouting | null {
if (!getGlobalShortcutsApi()) return null;
const status = GlobalShortcuts.status;
return {
hooksActive: GlobalShortcuts.hooksActive && GlobalShortcuts.backend !== 'portal',
supportsMouseButtons: status?.supportsMouseButtons === true,
supportsModifierOnly: status?.supportsModifierOnly === true,
};
}
private canRouteHoldBindingGlobally(binding: HoldBindingRuntime, routing: GlobalHoldRouting): boolean {
if (!routing.hooksActive) return false;
if (binding.mouseButton !== null) return routing.supportsMouseButtons;
if (binding.isModifierOnly) return routing.supportsModifierOnly;
return true;
}
private rebuildHoldBindings(): void {
const bindings = this.buildHoldBindingEntries().map(createHoldBindingRuntime);
const routing = this.getGlobalHoldRouting();
void this.enqueueInputSync(() => this.applyHoldBindings(bindings, routing));
}
private async applyHoldBindings(
bindings: Array<HoldBindingRuntime>,
routing: GlobalHoldRouting | null,
): Promise<void> {
this.detachLocalHoldListener();
this.releaseGlobalHoldBindings();
this.releaseGamepadHoldBindings();
this.holdBindings = bindings;
if (bindings.length === 0 || this.suspended || !this.initialized) {
if (routing === null) this.maybeStopGlobalKeyHook();
this.refreshGamepadPolling();
return;
}
const legacyGlobalReady = routing === null && (await this.startLegacyGlobalHoldHook(bindings));
let needsLocal = false;
for (const binding of bindings) {
const hasGlobalRoutable = !!(binding.combo.key || binding.combo.code || binding.mouseButton != null);
const wantsGlobal = hasGlobalRoutable && (binding.combo.global ?? false);
if (wantsGlobal && routing !== null && this.canRouteHoldBindingGlobally(binding, routing)) {
binding.routing = 'global';
} else if (wantsGlobal && legacyGlobalReady) {
binding.keycode = jsKeyToUiohookKeycode(binding.combo.code ?? binding.combo.key);
binding.keyName = keyNameForGlobalHook(binding.combo);
binding.physicalKeyName = physicalKeyNameForGlobalHook(binding.combo);
binding.routing = 'global';
} else if (hasGlobalRoutable || binding.isModifierOnly) {
binding.routing = 'local';
needsLocal = true;
} else {
binding.routing = null;
}
}
if (routing === null) this.maybeStopGlobalKeyHook();
if (needsLocal) {
this.attachLocalHoldListener();
}
this.refreshGamepadPolling();
}
private async startLegacyGlobalHoldHook(bindings: ReadonlyArray<HoldBindingRuntime>): Promise<boolean> {
if (!getElectronAPI()?.globalKeyHookStart) return false;
const wantsGlobal = bindings.some((binding) => {
const hasGlobalRoutable = !!(binding.combo.key || binding.combo.code || binding.mouseButton != null);
return hasGlobalRoutable && (binding.combo.global ?? false);
});
if (!wantsGlobal) return false;
return this.startGlobalKeyHook();
}
private attachGlobalShortcuts(api: GlobalShortcutsApi): void {
this.globalShortcutsEventUnsubscribe = api.onEvent((event) => this.handleGlobalShortcutEvent(event));
void this.enqueueInputSync(() => api.setPaused(this.suspended));
this.disposers.push(
reaction(
() => this.buildGlobalShortcutsSyncPayload(),
(payload) => {
void this.enqueueInputSync(() => this.syncGlobalShortcuts(api, payload));
},
{equals: compareStructural, fireImmediately: true},
),
reaction(
() => this.canDedupeGlobalPresses(),
() => this.activeGlobalShortcutPressIds.clear(),
),
);
}
private detachGlobalShortcuts(api: GlobalShortcutsApi): void {
this.globalShortcutsEventUnsubscribe?.();
this.globalShortcutsEventUnsubscribe = null;
const payload: GlobalShortcutsSyncPayload = {bindings: [], actions: this.syncedGlobalActions};
this.syncedGlobalSourceIds = new Set();
this.syncedSourceIdByHookId = new Map();
void this.enqueueInputSync(() => api.sync(payload));
const pushToTalkEngaged = Keybind.pushToTalkHeld;
const pushToMuteEngaged = Keybind.pushToMuteHeld;
Keybind.resetPushToTalkState();
Keybind.resetPushToMuteState();
if (pushToTalkEngaged) MediaEngine.applyPushToTalkHold(false);
if (pushToMuteEngaged) MediaEngine.applyPushToMuteHold(false);
}
private buildGlobalShortcutsSyncPayload(): GlobalShortcutsSyncPayload {
const bindings: Array<GlobalShortcutBinding> = [];
const seenHookIds = new Set<string>();
for (const entry of this.resolvedKeybinds) {
if (!entry.allowGlobal || !(entry.combo.global ?? false)) continue;
const combo = toGlobalShortcutCombo(entry.combo);
if (!combo) continue;
const hookId = hookShortcutIdForKeybind(entry);
if (hookId === null || seenHookIds.has(hookId)) continue;
seenHookIds.add(hookId);
bindings.push({
sourceId: sourceIdForKeybind(entry),
action: entry.action,
combo,
});
}
const actions: Array<GlobalShortcutActionDefinition> = [];
for (const config of Keybind.getDefaults()) {
if (!config.allowGlobal) continue;
const hold = isHoldAction(config.action);
const preferred = bindings.find(
(binding) =>
binding.action === config.action &&
binding.combo.mouseButton === undefined &&
!(hold && comboHasModifiers(binding.combo)),
);
actions.push({
action: config.action,
description: config.label,
preferredCombo: preferred ? preferred.combo : null,
});
}
return {bindings, actions};
}
private async syncGlobalShortcuts(api: GlobalShortcutsApi, payload: GlobalShortcutsSyncPayload): Promise<void> {
if (!this.initialized) return;
if (payload.bindings.length > 0) {
await this.checkInputMonitoringPermission();
}
const sourceIds = new Set(payload.bindings.map((binding) => binding.sourceId));
const sourceIdByHookId = new Map<string, string>();
for (const entry of this.resolvedKeybinds) {
const sourceId = sourceIdForKeybind(entry);
if (!sourceIds.has(sourceId)) continue;
const hookId = hookShortcutIdForKeybind(entry);
if (hookId !== null) sourceIdByHookId.set(hookId, sourceId);
}
this.syncedGlobalSourceIds = sourceIds;
this.syncedSourceIdByHookId = sourceIdByHookId;
this.syncedGlobalActions = payload.actions;
await api.sync(payload);
}
private isSyncedGlobalShortcutSource(event: GlobalShortcutEvent): boolean {
if (event.sourceId.startsWith(PORTAL_SOURCE_ID_PREFIX)) {
return this.syncedGlobalActions.some((definition) => definition.action === event.action);
}
return this.syncedGlobalSourceIds.has(event.sourceId);
}
private handleGlobalShortcutEvent(event: GlobalShortcutEvent): void {
if (!isKeybindCommand(event.action)) return;
const action = event.action;
const handler = this.handlers.get(action);
if (!handler) return;
const payload = {type: event.phase, source: 'global', sourceId: event.sourceId} as const;
if (isHoldAction(action)) {
if (event.phase === 'press' && (this.suspended || !this.isSyncedGlobalShortcutSource(event))) return;
handler(payload);
return;
}
if (event.phase === 'release') {
if (!this.activeGlobalShortcutPressIds.delete(event.sourceId)) return;
handler(payload);
return;
}
if (!this.isSyncedGlobalShortcutSource(event)) return;
if (this.activeGlobalShortcutPressIds.has(event.sourceId)) return;
if (this.suspended) return;
if (!this.isActionAllowedForCurrentView(action)) return;
if (shouldSuppressShortcutForFullscreenMedia()) return;
if (Keybind.isActionMuted(action)) return;
this.activeGlobalShortcutPressIds.add(event.sourceId);
handler(payload);
}
private canDedupeGlobalPresses(): boolean {
const backend = GlobalShortcuts.backend;
return GlobalShortcuts.hooksActive && backend !== 'portal' && backend !== 'none';
}
private globalPressDedupeId(entry: RuntimeKeybind): string | null {
const hookId = hookShortcutIdForKeybind(entry);
if (getGlobalShortcutsApi()) {
if (!this.canDedupeGlobalPresses() || hookId === null) return null;
return this.syncedSourceIdByHookId.get(hookId) ?? null;
}
if (!(entry.combo.global ?? false)) return null;
return hookId !== null && this.registeredGlobalHookShortcutIds.has(hookId) ? hookId : null;
}
private attachLocalHoldListener(): void {
if (this.localHoldListenerAttached) {
this.detachLocalHoldListener();
}
this.localHoldListenerAttached = true;
const onKeyDown = (event: KeyboardEvent): void => {
for (const binding of this.holdBindings) {
if (binding.routing !== 'local') continue;
if (binding.isModifierOnly) {
if (!this.localKeyEventMatchesModifierOnly(binding, event)) continue;
if (binding.localPressedCodes.has(event.code)) continue;
binding.localPressedCodes.add(event.code);
const required = this.requiredModifierKeyCount(binding);
if (binding.localPressedCodes.size === required) {
this.fireHoldHandler(binding, 'press', 'local', binding.sourceId);
}
continue;
}
if (!this.localKeyEventMatchesBinding(binding, event)) continue;
if (binding.localActiveCode === event.code) continue;
binding.localActiveCode = event.code;
this.fireHoldHandler(binding, 'press', 'local', binding.sourceId);
}
};
const onKeyUp = (event: KeyboardEvent): void => {
for (const binding of this.holdBindings) {
if (binding.routing !== 'local') continue;
if (binding.isModifierOnly) {
if (!binding.localPressedCodes.has(event.code)) continue;
const required = this.requiredModifierKeyCount(binding);
const wasAtThreshold = binding.localPressedCodes.size === required;
binding.localPressedCodes.delete(event.code);
if (wasAtThreshold) {
this.fireHoldHandler(binding, 'release', 'local', binding.sourceId);
}
continue;
}
if (binding.localActiveCode !== event.code) continue;
binding.localActiveCode = null;
this.fireHoldHandler(binding, 'release', 'local', binding.sourceId);
}
};
const onMouseDown = (event: MouseEvent): void => {
for (const binding of this.holdBindings) {
if (binding.routing !== 'local') continue;
if (binding.mouseButton === null) continue;
if (event.button !== binding.mouseButton) continue;
if (!this.matchesModifiers(binding, event)) continue;
if (binding.localMouseActive) continue;
binding.localMouseActive = true;
this.fireHoldHandler(binding, 'press', 'local', binding.sourceId);
}
};
const onMouseUp = (event: MouseEvent): void => {
for (const binding of this.holdBindings) {
if (binding.routing !== 'local') continue;
if (binding.mouseButton === null) continue;
if (event.button !== binding.mouseButton) continue;
if (!binding.localMouseActive) continue;
binding.localMouseActive = false;
this.fireHoldHandler(binding, 'release', 'local', binding.sourceId);
}
};
for (const binding of this.holdBindings) {
binding.localKeyDown = onKeyDown;
binding.localKeyUp = onKeyUp;
binding.localMouseDown = onMouseDown;
binding.localMouseUp = onMouseUp;
}
const onWindowBlur = (): void => {
for (const binding of this.holdBindings) {
if (binding.routing !== 'local') continue;
if (binding.localActiveCode !== null) {
binding.localActiveCode = null;
this.fireHoldHandler(binding, 'release', 'local', binding.sourceId);
}
if (binding.localPressedCodes.size > 0) {
const required = this.requiredModifierKeyCount(binding);
const wasAtThreshold = binding.localPressedCodes.size >= required;
binding.localPressedCodes.clear();
if (wasAtThreshold) {
this.fireHoldHandler(binding, 'release', 'local', binding.sourceId);
}
}
if (binding.localMouseActive) {
binding.localMouseActive = false;
this.fireHoldHandler(binding, 'release', 'local', binding.sourceId);
}
}
};
window.addEventListener('keydown', onKeyDown, true);
window.addEventListener('keyup', onKeyUp, true);
window.addEventListener('mousedown', onMouseDown, true);
document.addEventListener('mouseup', onMouseUp, true);
window.addEventListener('blur', onWindowBlur);
this.localKeyDownRef = onKeyDown;
this.localKeyUpRef = onKeyUp;
this.localMouseDownRef = onMouseDown;
this.localMouseUpRef = onMouseUp;
this.localBlurRef = onWindowBlur;
}
private localKeyDownRef: ((event: KeyboardEvent) => void) | null = null;
private localKeyUpRef: ((event: KeyboardEvent) => void) | null = null;
private localMouseDownRef: ((event: MouseEvent) => void) | null = null;
private localMouseUpRef: ((event: MouseEvent) => void) | null = null;
private localBlurRef: (() => void) | null = null;
private localEditableShortcutKeyDownRef: ((event: KeyboardEvent) => void) | null = null;
private localEditableShortcutKeyUpRef: ((event: KeyboardEvent) => void) | null = null;
private localShortcutKeyDownRef: ((event: KeyboardEvent) => void) | null = null;
private localShortcutKeyUpRef: ((event: KeyboardEvent) => void) | null = null;
private localShortcutMouseDownRef: ((event: MouseEvent) => void) | null = null;
private localShortcutMouseUpRef: ((event: MouseEvent) => void) | null = null;
private detachLocalHoldListener(): void {
if (!this.localHoldListenerAttached) return;
this.localHoldListenerAttached = false;
if (this.localKeyDownRef) window.removeEventListener('keydown', this.localKeyDownRef, true);
if (this.localKeyUpRef) window.removeEventListener('keyup', this.localKeyUpRef, true);
if (this.localMouseDownRef) window.removeEventListener('mousedown', this.localMouseDownRef, true);
if (this.localMouseUpRef) document.removeEventListener('mouseup', this.localMouseUpRef, true);
if (this.localBlurRef) window.removeEventListener('blur', this.localBlurRef);
this.localKeyDownRef = null;
this.localKeyUpRef = null;
this.localMouseDownRef = null;
this.localMouseUpRef = null;
this.localBlurRef = null;
for (const binding of this.holdBindings) {
if (binding.localActiveCode !== null) {
binding.localActiveCode = null;
this.fireHoldHandler(binding, 'release', 'local', binding.sourceId);
}
if (binding.localPressedCodes.size > 0) {
const required = this.requiredModifierKeyCount(binding);
const wasAtThreshold = binding.localPressedCodes.size >= required;
binding.localPressedCodes.clear();
if (wasAtThreshold) {
this.fireHoldHandler(binding, 'release', 'local', binding.sourceId);
}
}
if (binding.localMouseActive) {
binding.localMouseActive = false;
this.fireHoldHandler(binding, 'release', 'local', binding.sourceId);
}
binding.localKeyDown = null;
binding.localKeyUp = null;
binding.localMouseDown = null;
binding.localMouseUp = null;
}
}
private refreshLocalMouseShortcutListener(): void {
this.detachLocalMouseShortcutListener();
if (this.localMouseShortcutKeybinds.length === 0) return;
this.localMouseShortcutListenerAttached = true;
const onMouseDown = (event: MouseEvent): void => {
this.handleLocalMouseShortcutEvent(event, 'press');
};
const onMouseUp = (event: MouseEvent): void => {
this.handleLocalMouseShortcutEvent(event, 'release');
};
window.addEventListener('mousedown', onMouseDown, true);
document.addEventListener('mouseup', onMouseUp, true);
this.localShortcutMouseDownRef = onMouseDown;
this.localShortcutMouseUpRef = onMouseUp;
}
private detachLocalMouseShortcutListener(): void {
if (!this.localMouseShortcutListenerAttached) return;
this.localMouseShortcutListenerAttached = false;
if (this.localShortcutMouseDownRef) window.removeEventListener('mousedown', this.localShortcutMouseDownRef, true);
if (this.localShortcutMouseUpRef) document.removeEventListener('mouseup', this.localShortcutMouseUpRef, true);
this.localShortcutMouseDownRef = null;
this.localShortcutMouseUpRef = null;
}
private refreshLocalKeyboardShortcutListener(): void {
this.detachLocalKeyboardShortcutListener();
if (this.resolvedKeybinds.every((entry) => !this.canBindLocalShortcut(entry))) return;
this.localKeyboardShortcutListenerAttached = true;
const onKeyDown = (event: KeyboardEvent): void => {
this.handleLocalKeyboardShortcutEvent(event, 'press');
};
const onKeyUp = (event: KeyboardEvent): void => {
this.handleLocalKeyboardShortcutEvent(event, 'release');
};
window.addEventListener('keydown', onKeyDown, true);
window.addEventListener('keyup', onKeyUp, true);
this.localShortcutKeyDownRef = onKeyDown;
this.localShortcutKeyUpRef = onKeyUp;
}
private detachLocalKeyboardShortcutListener(): void {
if (!this.localKeyboardShortcutListenerAttached) return;
this.localKeyboardShortcutListenerAttached = false;
if (this.localShortcutKeyDownRef) window.removeEventListener('keydown', this.localShortcutKeyDownRef, true);
if (this.localShortcutKeyUpRef) window.removeEventListener('keyup', this.localShortcutKeyUpRef, true);
this.localShortcutKeyDownRef = null;
this.localShortcutKeyUpRef = null;
}
private refreshLocalEditableShortcutCaptureListener(): void {
this.detachLocalEditableShortcutCaptureListener();
if (this.resolvedKeybinds.every((entry) => !this.shouldCaptureLocalShortcutInEditable(entry))) return;
const onKeyDown = (event: KeyboardEvent): void => {
this.handleLocalEditableShortcutCaptureEvent(event, 'press');
};
const onKeyUp = (event: KeyboardEvent): void => {
this.handleLocalEditableShortcutCaptureEvent(event, 'release');
};
window.addEventListener('keydown', onKeyDown, true);
window.addEventListener('keyup', onKeyUp, true);
this.localEditableShortcutKeyDownRef = onKeyDown;
this.localEditableShortcutKeyUpRef = onKeyUp;
}
private detachLocalEditableShortcutCaptureListener(): void {
if (this.localEditableShortcutKeyDownRef) {
window.removeEventListener('keydown', this.localEditableShortcutKeyDownRef, true);
}
if (this.localEditableShortcutKeyUpRef) {
window.removeEventListener('keyup', this.localEditableShortcutKeyUpRef, true);
}
this.localEditableShortcutKeyDownRef = null;
this.localEditableShortcutKeyUpRef = null;
}
private shouldCaptureLocalShortcutInEditable(entry: RuntimeKeybind): boolean {
if (!EDITABLE_CAPTURE_SHORTCUT_ACTIONS.has(entry.action)) return false;
if (!this.canBindLocalShortcut(entry)) return false;
return entry.editableFocusBehavior === 'allow';
}
private handleLocalEditableShortcutCaptureEvent(event: KeyboardEvent, type: 'press' | 'release'): void {
if (event.defaultPrevented) return;
const target = event.target ?? null;
if (!isEditableElement(target) || !isChannelTextareaElement(target)) return;
const entries = this.orderLocalShortcutGroup(
this.activeKeybinds.filter((entry) => this.shouldCaptureLocalShortcutInEditable(entry)),
);
for (const entry of entries) {
if (!keyboardEventMatchesCombo(entry.combo, event, {isMacOS: isNativeMacOS()})) continue;
if (!this.dispatchLocalShortcut(entry, type, event)) continue;
event.stopPropagation();
event.stopImmediatePropagation();
return;
}
}
private handleLocalMouseShortcutEvent(event: MouseEvent, type: 'press' | 'release'): void {
for (const binding of this.localMouseShortcutKeybinds) {
if (!this.isActionAllowedForCurrentView(binding.action)) continue;
const combo = binding.combo;
if (combo.mouseButton == null) continue;
if (event.button !== combo.mouseButton) continue;
if (type === 'press' && shouldSuppressLocalShortcutForModalFocus(binding, event.target ?? null)) continue;
const id = hookShortcutIdForKeybind(binding);
if (!id) continue;
const globalPressId = this.globalPressDedupeId(binding);
const sourceId = sourceIdForKeybind(binding);
if (type === 'release') {
if (!this.activeLocalShortcutPressIds.delete(id)) continue;
if (globalPressId !== null) {
this.activeGlobalShortcutPressIds.delete(globalPressId);
}
this.fireShortcutHandler(binding, type, 'local', sourceId, {shiftKey: event.shiftKey});
continue;
}
if (!this.comboModifiersMatch(combo, event)) continue;
if (this.activeLocalShortcutPressIds.has(id)) continue;
if (globalPressId !== null) {
if (this.activeGlobalShortcutPressIds.has(globalPressId)) continue;
this.activeGlobalShortcutPressIds.add(globalPressId);
}
this.activeLocalShortcutPressIds.add(id);
this.fireShortcutHandler(binding, type, 'local', sourceId, {shiftKey: event.shiftKey});
}
}
private localKeyboardShortcutId(entry: RuntimeKeybind): string {
const combo = entry.combo;
return JSON.stringify({
key: combo.key || '',
code: combo.code || '',
ctrlOrMeta: Boolean(combo.ctrlOrMeta),
ctrl: Boolean(combo.ctrl),
alt: Boolean(combo.alt),
shift: Boolean(combo.shift),
meta: Boolean(combo.meta),
});
}
private clearActiveLocalKeyboardShortcut(entry: RuntimeKeybind, id: string): void {
this.activeLocalShortcutPressIds.delete(id);
for (const shortcut of comboToCombokeysStrings(entry.combo)) {
this.activeLocalShortcutPressIds.delete(shortcut);
}
const globalPressId = this.globalPressDedupeId(entry);
if (globalPressId !== null) {
this.activeGlobalShortcutPressIds.delete(globalPressId);
}
}
private handleLocalKeyboardShortcutEvent(event: KeyboardEvent, type: 'press' | 'release'): void {
if (event.defaultPrevented) return;
const entries = this.orderLocalShortcutGroup(
this.activeKeybinds.filter((entry) => this.canBindLocalShortcut(entry)),
);
const isMacOS = isNativeMacOS();
for (const entry of entries) {
const id = this.localKeyboardShortcutId(entry);
if (type === 'release') {
if (!this.activeLocalShortcutPressIds.has(id)) continue;
if (!keyboardEventTriggerMatchesCombo(entry.combo, event)) {
if (keyboardEventReleasesComboModifier(entry.combo, event, {isMacOS})) {
this.clearActiveLocalKeyboardShortcut(entry, id);
}
continue;
}
this.clearActiveLocalKeyboardShortcut(entry, id);
if (this.dispatchLocalShortcut(entry, type, event)) {
event.stopPropagation();
event.stopImmediatePropagation();
}
return;
}
if (!keyboardEventStartsComboPress(entry.combo, event, {isMacOS})) continue;
if (this.activeLocalShortcutPressIds.has(id)) {
if (
this.globalPressDedupeId(entry) !== null ||
!keyboardEventCanRecoverStaleMacMetaPress(entry.combo, event, {isMacOS})
) {
continue;
}
this.clearActiveLocalKeyboardShortcut(entry, id);
}
if (this.dispatchLocalShortcut(entry, type, event)) {
this.activeLocalShortcutPressIds.add(id);
event.stopPropagation();
event.stopImmediatePropagation();
return;
}
}
}
private localKeyEventMatchesBinding(binding: HoldBindingRuntime, event: KeyboardEvent): boolean {
const expectedCode = binding.combo.code ?? null;
const expectedKey = binding.combo.key ?? null;
if (shouldPreferLayoutKeyForShortcut(binding.combo) && expectedKey) {
if (normalizeKeyboardShortcutKey(event.key) !== normalizeKeyboardShortcutKey(expectedKey)) return false;
} else if (expectedCode && event.code !== expectedCode) {
return false;
} else if (!expectedCode && expectedKey && event.key !== expectedKey) {
return false;
}
if (!expectedCode && !expectedKey) return false;
if (binding.isModifierOnly) return true;
return this.matchesModifiers(binding, event);
}
private requiredModifierKeyCount(binding: HoldBindingRuntime): number {
if (binding.requireBothSides) return 2;
const {modifiers, ctrlOrMeta} = binding;
let count = 0;
if (ctrlOrMeta) count++;
if (modifiers.ctrl) count++;
if (modifiers.alt) count++;
if (modifiers.shift) count++;
if (modifiers.meta) count++;
return Math.max(count, 1);
}
private localKeyEventMatchesModifierOnly(binding: HoldBindingRuntime, event: KeyboardEvent): boolean {
const {modifiers, ctrlOrMeta} = binding;
const eventKey = event.key;
if (ctrlOrMeta) {
if (isNativeMacOS()) return eventKey === 'Meta';
return eventKey === 'Control';
}
if (modifiers.ctrl && eventKey === 'Control') return true;
if (modifiers.alt && eventKey === 'Alt') return true;
if (modifiers.shift && eventKey === 'Shift') return true;
if (modifiers.meta && eventKey === 'Meta') return true;
return false;
}
private matchesModifiers(binding: HoldBindingRuntime, event: KeyboardEvent | MouseEvent): boolean {
return this.comboModifiersMatch(binding.combo, event);
}
private comboModifiersMatch(combo: KeyCombo, event: KeyboardEvent | MouseEvent): boolean {
const anyModifier = Boolean(combo.ctrlOrMeta || combo.ctrl || combo.meta || combo.alt || combo.shift);
if (!anyModifier) return true;
if (combo.ctrlOrMeta && (isNativeMacOS() ? !event.metaKey : !event.ctrlKey)) return false;
if (combo.ctrl !== undefined && event.ctrlKey !== Boolean(combo.ctrl)) return false;
if (combo.meta !== undefined && event.metaKey !== Boolean(combo.meta)) return false;
if (combo.alt !== undefined && event.altKey !== Boolean(combo.alt)) return false;
if (combo.shift !== undefined && event.shiftKey !== Boolean(combo.shift)) return false;
return true;
}
private hasGamepadBindings(): boolean {
return (
this.holdBindings.some((binding) => binding.gamepadButton !== null) ||
this.localGamepadShortcutKeybinds.length > 0
);
}
private refreshGamepadPolling(): void {
if (!this.hasGamepadBindings() || typeof navigator.getGamepads !== 'function') {
this.stopGamepadMonitoring();
return;
}
this.ensureGamepadListeners();
if (this.hasConnectedGamepad()) {
this.startGamepadPolling();
return;
}
this.stopGamepadPolling();
}
private ensureGamepadListeners(): void {
if (!this.gamepadListenersAttached) {
this.gamepadListenersAttached = true;
window.addEventListener('gamepadconnected', this.handleGamepadConnected);
window.addEventListener('gamepaddisconnected', this.handleGamepadDisconnected);
}
}
private startGamepadPolling(): void {
if (this.gamepadPollIntervalId !== null) return;
if (typeof navigator.getGamepads !== 'function') return;
this.tickGamepadBindings();
this.gamepadPollIntervalId = window.setInterval(() => this.tickGamepadBindings(), GAMEPAD_POLL_INTERVAL_MS);
}
private stopGamepadPolling(): void {
if (this.gamepadPollIntervalId !== null) {
window.clearInterval(this.gamepadPollIntervalId);
this.gamepadPollIntervalId = null;
}
this.releaseGamepadShortcutStates();
this.releaseGamepadHoldBindings();
}
private stopGamepadMonitoring(): void {
this.stopGamepadPolling();
if (this.gamepadListenersAttached) {
this.gamepadListenersAttached = false;
window.removeEventListener('gamepadconnected', this.handleGamepadConnected);
window.removeEventListener('gamepaddisconnected', this.handleGamepadDisconnected);
}
}
private releaseGamepadHoldBindings(): void {
for (const binding of this.holdBindings) {
if (binding.gamepadHeld) {
binding.gamepadHeld = false;
this.fireHoldHandler(binding, 'release', 'local', binding.gamepadSourceId);
}
}
}
private releaseGamepadShortcutStates(): void {
for (const {binding, pressed} of this.gamepadShortcutStates.values()) {
if (pressed) {
this.fireShortcutHandler(binding, 'release', 'local', gamepadSourceIdForKeybind(binding));
}
}
this.gamepadShortcutStates.clear();
}
private handleGamepadConnected = (): void => {
for (const binding of this.holdBindings) binding.gamepadHeld = false;
this.gamepadShortcutStates.clear();
if (this.hasGamepadBindings()) {
this.startGamepadPolling();
}
};
private handleGamepadDisconnected = (): void => {
if (this.hasConnectedGamepad()) {
this.tickGamepadBindings();
return;
}
this.stopGamepadPolling();
};
private hasConnectedGamepad(): boolean {
const pads = navigator.getGamepads?.() ?? [];
for (const pad of pads) {
if (pad != null) return true;
}
return false;
}
private tickGamepadBindings(): void {
const pads = navigator.getGamepads?.() ?? [];
for (const binding of this.holdBindings) {
const target = binding.gamepadButton;
if (target === null) continue;
const pressed = this.isGamepadButtonPressed(pads, target);
if (pressed === binding.gamepadHeld) continue;
binding.gamepadHeld = pressed;
this.fireHoldHandler(binding, pressed ? 'press' : 'release', 'local', binding.gamepadSourceId);
}
for (const binding of this.localGamepadShortcutKeybinds) {
if (!this.isActionAllowedForCurrentView(binding.action)) continue;
const target = binding.combo.gamepadButton;
if (target == null) continue;
const id = hookShortcutIdForKeybind(binding) ?? `gamepad:${binding.action}:${target}`;
const pressed = this.isGamepadButtonPressed(pads, target);
const previous = this.gamepadShortcutStates.get(id)?.pressed ?? false;
if (pressed === previous) continue;
this.gamepadShortcutStates.set(id, {binding, pressed});
this.fireShortcutHandler(binding, pressed ? 'press' : 'release', 'local', gamepadSourceIdForKeybind(binding));
}
}
private isGamepadButtonPressed(pads: ReadonlyArray<Gamepad | null>, target: number): boolean {
for (const pad of pads) {
if (!pad) continue;
const btn = pad.buttons[target];
if (isGamepadButtonPressed(btn)) return true;
}
return false;
}
private fireHoldHandler(
binding: HoldBindingRuntime,
type: 'press' | 'release',
source: ShortcutSource,
sourceId: string,
): void {
const handler = this.handlers.get(binding.action);
if (!handler) return;
if (type === 'press') {
if (this.suspended) return;
if (source === 'local') {
if (!this.isActionAllowedForCurrentView(binding.action)) return;
if (shouldSuppressShortcutForFullscreenMedia()) return;
if (Keybind.isActionMuted(binding.action)) return;
}
}
handler({type, source, sourceId});
}
private fireShortcutHandler(
binding: RuntimeKeybind,
type: 'press' | 'release',
source: ShortcutSource,
sourceId: string,
options: {shiftKey?: boolean} = {},
): void {
if (this.suspended) return;
if (!this.isActionAllowedForCurrentView(binding.action)) return;
if (type === 'press' && shouldSuppressShortcutForFullscreenMedia()) return;
if (Keybind.isActionMuted(binding.action)) return;
const handler = this.handlers.get(binding.action);
if (!handler) return;
handler({type, source, sourceId, shiftKey: options.shiftKey});
}
async reapplyGlobalShortcuts() {
if (!this.initialized) return;
const globalShortcutsApi = getGlobalShortcutsApi();
if (globalShortcutsApi) {
const payload = this.buildGlobalShortcutsSyncPayload();
await this.enqueueInputSync(() => this.syncGlobalShortcuts(globalShortcutsApi, payload));
} else {
await this.enqueueInputSync(() => this.applyGlobalShortcuts(this.computeDesiredGlobalHookShortcuts()));
}
this.rebuildHoldBindings();
await this.inputSyncQueue;
}
destroy() {
if (!this.initialized) return;
this.initialized = false;
this.disposers.forEach((dispose) => dispose());
this.disposers = [];
GlobalShortcuts.detach();
const globalShortcutsApi = getGlobalShortcutsApi();
if (globalShortcutsApi) {
this.detachGlobalShortcuts(globalShortcutsApi);
}
if (this.globalKeybindTriggeredUnsubscribe) {
this.globalKeybindTriggeredUnsubscribe();
this.globalKeybindTriggeredUnsubscribe = null;
}
this.stopGlobalKeyHook();
this.detachLocalHoldListener();
this.detachLocalKeyboardShortcutListener();
this.detachLocalMouseShortcutListener();
this.detachLocalEditableShortcutCaptureListener();
this.stopGamepadPolling();
this.holdBindings = [];
this.activeGlobalShortcutPressIds.clear();
this.activeLocalShortcutPressIds.clear();
const electronApi = getElectronAPI();
if (electronApi) {
if (this.registeredGlobalHookShortcutIds.size > 0) {
void electronApi.globalKeyHookUnregisterAll?.().catch(() => {});
}
}
this.registeredGlobalHookShortcutIds.clear();
this.handlers.clear();
this.combokeys?.detach();
this.combokeys = null;
}
private async startGlobalKeyHook(): Promise<boolean> {
const electronApi = getElectronAPI();
if (!electronApi?.globalKeyHookStart) return false;
if (this.globalKeyHookStarted) return true;
if (!(await this.checkInputMonitoringPermission())) {
return false;
}
const started = await electronApi.globalKeyHookStart();
if (!started) return false;
this.globalKeyHookStarted = true;
const keyEventUnsub = electronApi.onGlobalKeyEvent?.((event) => {
this.handleGlobalKeyEvent(
event as {
type: 'keydown' | 'keyup';
keycode: number;
keyName: string;
backend?: 'evdev' | 'native' | null;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
metaKey: boolean;
},
);
});
if (keyEventUnsub) this.globalKeyHookUnsubscribes.push(keyEventUnsub);
const mouseEventUnsub = electronApi.onGlobalMouseEvent?.((event) => {
this.handleGlobalMouseEvent(
event as {
type: 'mousedown' | 'mouseup';
button: number;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
metaKey: boolean;
},
);
});
if (mouseEventUnsub) this.globalKeyHookUnsubscribes.push(mouseEventUnsub);
this.ensureGlobalKeybindTriggeredSubscription();
return true;
}
private ensureGlobalKeybindTriggeredSubscription(): void {
if (this.globalKeybindTriggeredUnsubscribe) return;
const electronApi = getElectronAPI();
this.globalKeybindTriggeredUnsubscribe =
electronApi?.onGlobalKeybindTriggered?.((event) => {
if (!this.registeredGlobalHookShortcutIds.has(event.id)) {
return;
}
const keybind = this.resolveGlobalShortcutEventId(event.id);
if (!keybind) return;
const isHoldAction = HOLD_ACTIONS.includes(keybind.action as HoldAction);
if (event.type === 'keyup') {
if (!this.activeGlobalShortcutPressIds.delete(event.id) && isHoldAction) return;
if (!isHoldAction) return;
} else {
if (this.activeGlobalShortcutPressIds.has(event.id)) return;
this.activeGlobalShortcutPressIds.add(event.id);
}
const handler = this.handlers.get(keybind.action);
if (!handler) return;
if (this.suspended) return;
if (!this.isActionAllowedForCurrentView(keybind.action)) return;
if (event.type === 'keydown' && shouldSuppressShortcutForFullscreenMedia()) return;
if (Keybind.isActionMuted(keybind.action)) return;
handler({
type: event.type === 'keydown' ? 'press' : 'release',
source: 'global',
sourceId: `legacy:${event.id}`,
});
}) ?? null;
}
private stopGlobalKeyHook(): void {
const electronApi = getElectronAPI();
this.globalKeyHookUnsubscribes.forEach((unsub) => unsub());
this.globalKeyHookUnsubscribes = [];
this.releaseGlobalHoldBindings();
this.releaseActiveGlobalTriggeredHolds();
this.activeGlobalShortcutPressIds.clear();
if (electronApi?.globalKeyHookStop && this.globalKeyHookStarted) {
void electronApi.globalKeyHookStop();
}
this.globalKeyHookStarted = false;
}
private maybeStopGlobalKeyHook(): void {
if (this.registeredGlobalHookShortcutIds.size > 0) return;
if (this.hasGlobalHoldBindings()) return;
this.stopGlobalKeyHook();
}
private releaseActiveGlobalTriggeredHolds(): void {
for (const id of this.activeGlobalShortcutPressIds) {
const keybind = this.resolveGlobalShortcutEventId(id);
if (!keybind || !HOLD_ACTIONS.includes(keybind.action as HoldAction)) continue;
const handler = this.handlers.get(keybind.action);
if (handler) {
handler({type: 'release', source: 'global', sourceId: `legacy:${id}`});
}
}
}
private hasGlobalHoldBindings(): boolean {
return this.holdBindings.some((binding) => binding.routing === 'global');
}
private releaseGlobalHoldBindings(): void {
for (const binding of this.holdBindings) {
if (binding.routing !== 'global') continue;
if (binding.pressedKeycodes.size > 0) {
binding.pressedKeycodes.clear();
this.fireHoldHandler(binding, 'release', 'global', legacyHoldSourceId(binding));
}
if (binding.globalMouseActive) {
binding.globalMouseActive = false;
this.fireHoldHandler(binding, 'release', 'global', legacyHoldSourceId(binding));
}
}
}
private releaseHoldBindingsForSuspension(): void {
this.detachLocalHoldListener();
this.releaseGlobalHoldBindings();
this.releaseGamepadHoldBindings();
}
private handleGlobalKeyEvent(event: {
type: 'keydown' | 'keyup';
keycode: number;
keyName: string;
backend?: 'evdev' | 'native' | null;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
metaKey: boolean;
}): void {
for (const binding of this.holdBindings) {
if (binding.routing !== 'global') continue;
this.handleGlobalKeyEventForBinding(binding, event);
}
}
private handleGlobalKeyEventForBinding(
binding: HoldBindingRuntime,
event: {
type: 'keydown' | 'keyup';
keycode: number;
keyName: string;
backend?: 'evdev' | 'native' | null;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
metaKey: boolean;
},
): void {
if (binding.isModifierOnly) {
if (!this.isHoldModifierEvent(binding, event)) return;
const requiredCount = this.requiredModifierKeyCount(binding);
if (event.type === 'keydown') {
if (binding.pressedKeycodes.has(event.keycode)) return;
binding.pressedKeycodes.add(event.keycode);
if (binding.pressedKeycodes.size === requiredCount) {
this.fireHoldHandler(binding, 'press', 'global', legacyHoldSourceId(binding));
}
} else {
if (!binding.pressedKeycodes.has(event.keycode)) return;
const wasAtThreshold = binding.pressedKeycodes.size === requiredCount;
binding.pressedKeycodes.delete(event.keycode);
if (wasAtThreshold) {
this.fireHoldHandler(binding, 'release', 'global', legacyHoldSourceId(binding));
}
}
return;
}
if (binding.keycode === null && binding.keyName === null) return;
if (!this.globalKeyEventMatchesHoldBinding(binding, event)) return;
if (event.type === 'keyup') {
if (!binding.pressedKeycodes.delete(event.keycode)) return;
this.fireHoldHandler(binding, 'release', 'global', legacyHoldSourceId(binding));
return;
}
if (!this.globalHoldModifiersMatch(binding, event)) return;
if (binding.pressedKeycodes.has(event.keycode)) return;
binding.pressedKeycodes.add(event.keycode);
this.fireHoldHandler(binding, 'press', 'global', legacyHoldSourceId(binding));
}
private globalKeyEventMatchesHoldBinding(
binding: HoldBindingRuntime,
event: {
keycode: number;
keyName: string;
backend?: 'evdev' | 'native' | null;
},
): boolean {
const expectedName =
event.backend === 'evdev' || (event.backend === 'native' && isNativeMacOS())
? (binding.physicalKeyName ?? binding.keyName)
: binding.keyName;
if (expectedName !== null && event.keyName === expectedName) {
return true;
}
return binding.keycode !== null && event.keycode === binding.keycode;
}
private isHoldPrimaryModifierDown(event: {ctrlKey: boolean; metaKey: boolean}): boolean {
return isNativeMacOS() ? event.metaKey : event.ctrlKey;
}
private globalHoldModifiersMatch(
binding: HoldBindingRuntime,
event: {
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
metaKey: boolean;
},
): boolean {
const {modifiers, ctrlOrMeta} = binding;
const anyModifierExpected = ctrlOrMeta || modifiers.ctrl || modifiers.alt || modifiers.shift || modifiers.meta;
if (!anyModifierExpected) return true;
if (ctrlOrMeta && !this.isHoldPrimaryModifierDown(event)) return false;
if (event.ctrlKey !== modifiers.ctrl && !ctrlOrMeta) return false;
if (event.metaKey !== modifiers.meta && !ctrlOrMeta) return false;
if (modifiers.ctrl && !event.ctrlKey) return false;
if (modifiers.meta && !event.metaKey) return false;
if (event.altKey !== modifiers.alt) return false;
if (event.shiftKey !== modifiers.shift) return false;
return true;
}
private isHoldModifierEvent(
binding: HoldBindingRuntime,
event: {
keycode: number;
keyName: string;
},
): boolean {
const {modifiers, ctrlOrMeta} = binding;
const keyName = event.keyName;
if (ctrlOrMeta) {
if (isNativeMacOS()) {
return keyName === 'MetaLeft' || keyName === 'MetaRight';
}
return keyName === 'ControlLeft' || keyName === 'ControlRight';
}
if (modifiers.ctrl && (keyName === 'ControlLeft' || keyName === 'ControlRight')) return true;
if (modifiers.alt && (keyName === 'AltLeft' || keyName === 'AltRight')) return true;
if (modifiers.shift && (keyName === 'ShiftLeft' || keyName === 'ShiftRight')) return true;
if (modifiers.meta && (keyName === 'MetaLeft' || keyName === 'MetaRight')) return true;
return false;
}
private handleGlobalMouseEvent(event: {
type: 'mousedown' | 'mouseup';
button: number;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
metaKey: boolean;
}): void {
for (const binding of this.holdBindings) {
if (binding.routing !== 'global') continue;
if (binding.mouseButton === null) continue;
if (event.button !== binding.mouseButton) continue;
if (event.type === 'mouseup') {
if (!binding.globalMouseActive) continue;
binding.globalMouseActive = false;
this.fireHoldHandler(binding, 'release', 'global', legacyHoldSourceId(binding));
continue;
}
if (!this.globalHoldModifiersMatch(binding, event)) continue;
if (binding.globalMouseActive) continue;
binding.globalMouseActive = true;
this.fireHoldHandler(binding, 'press', 'global', legacyHoldSourceId(binding));
}
}
suspend(): void {
const wasSuspended = this.suspended;
this.manualSuspendCount += 1;
this.combokeys?.reset();
this.releaseHoldBindingsForSuspension();
this.detachLocalEditableShortcutCaptureListener();
const globalShortcutsApi = getGlobalShortcutsApi();
if (globalShortcutsApi) {
if (!wasSuspended && this.initialized) {
void this.enqueueInputSync(() => globalShortcutsApi.setPaused(true));
}
return;
}
void this.enqueueInputSync(() => {
this.stopGlobalKeyHook();
});
}
resume(): void {
const wasSuspended = this.suspended;
this.manualSuspendCount = Math.max(0, this.manualSuspendCount - 1);
if (this.suspended) return;
this.refreshLocalShortcuts();
const globalShortcutsApi = getGlobalShortcutsApi();
if (globalShortcutsApi) {
if (wasSuspended && this.initialized) {
void this.enqueueInputSync(() => globalShortcutsApi.setPaused(false));
}
} else {
void this.enqueueInputSync(() => this.applyGlobalShortcuts(this.computeDesiredGlobalHookShortcuts()));
}
this.rebuildHoldBindings();
}
isSuspended(): boolean {
return this.suspended;
}
private setRouteSuspended(value: boolean): void {
if (this.routeSuspended === value) return;
this.routeSuspended = value;
const globalShortcutsApi = getGlobalShortcutsApi();
if (value) {
this.combokeys?.reset();
this.detachLocalHoldListener();
this.releaseGamepadHoldBindings();
this.detachLocalEditableShortcutCaptureListener();
}
if (!this.suspended) {
this.refreshLocalShortcuts();
if (!globalShortcutsApi) {
void this.enqueueInputSync(() => this.applyGlobalShortcuts(this.computeDesiredGlobalHookShortcuts()));
}
if (!this.localHoldListenerAttached && this.holdBindings.some((binding) => binding.routing === 'local')) {
this.attachLocalHoldListener();
}
}
}
private isActionAllowedForCurrentView(action: KeybindCommand): boolean {
if (this.routeSuspended && !ROUTE_ALLOWED_ACTIONS.has(action)) return false;
if (this.isCompactCallTextareaActionBlocked(action)) return false;
return !VoiceCallFullscreen.isActive || isKeybindAllowedDuringVoiceCallFullscreen(action);
}
private isCompactCallTextareaActionBlocked(action: KeybindCommand): boolean {
const channelId = this.currentChannelId;
if (!channelId) return false;
const channel = Channels.getChannel(channelId);
if (!channel) return false;
if (channel.type === ChannelTypes.GUILD_VOICE) {
return isKeybindBlockedByCompactVoiceCallView({
action,
channelType: channel.type,
isPrivateChannel: false,
isGuildVoiceCallExpanded: CompactVoiceCallHeight.getExpandedForKey(
getGuildVoiceCallExpansionKey(channel.id),
true,
),
isConnectedToPrivateCall: false,
isPrivateCompactCallExpanded: false,
});
}
if (!channel.isPrivate()) return false;
if (MediaEngine.channelId !== channel.id || !MediaEngine.connected) return false;
const call = CallState.getCall(channel.id);
if (!call) return false;
return isKeybindBlockedByCompactVoiceCallView({
action,
channelType: channel.type,
isPrivateChannel: true,
isGuildVoiceCallExpanded: false,
isConnectedToPrivateCall: true,
isPrivateCompactCallExpanded: CompactVoiceCallHeight.getExpandedForKey(
getCompactVoiceCallExpansionKey(channel.id, call.messageId ?? null),
false,
),
});
}
register(action: KeybindCommand, handler: KeybindHandler) {
this.handlers.set(action, handler);
}
private registerDefaultHandlers(i18n: I18n) {
registerMessageHandlers(this, i18n);
registerDefaultKeybindHandlers(this, i18n);
}
private resolveGlobalShortcutEventId(id: string):
| (KeybindConfig & {
combo: KeyCombo;
})
| null {
const currentKeybinds = this.activeKeybinds;
const actionKeybind = currentKeybinds.find((keybind) => keybind.action === id);
if (actionKeybind) return actionKeybind;
const hookKeybind = currentKeybinds.find((keybind) => hookShortcutIdForKeybind(keybind) === id);
if (hookKeybind) return hookKeybind;
return null;
}
private computeDesiredGlobalHookShortcuts(): Map<string, KeyCombo> {
const desiredCombos = new Map<string, KeyCombo>();
for (const k of this.activeGlobalKeybinds) {
const shortcut = hookShortcutIdForKeybind(k);
if (shortcut && !desiredCombos.has(shortcut)) {
desiredCombos.set(shortcut, k.combo);
}
}
for (const k of this.localMouseShortcutKeybinds) {
if (!k.allowGlobal || (k.combo.global ?? false) !== true) continue;
if (!this.isActionAllowedForCurrentView(k.action)) continue;
const shortcut = hookShortcutIdForKeybind(k);
if (shortcut && !desiredCombos.has(shortcut)) {
desiredCombos.set(shortcut, k.combo);
}
}
return desiredCombos;
}
private async applyGlobalShortcuts(desiredCombos: ReadonlyMap<string, KeyCombo>): Promise<void> {
const electronApi = getElectronAPI();
if (!electronApi) return;
if (this.suspended || !this.initialized) {
this.stopGlobalKeyHook();
return;
}
const desired = new Set(desiredCombos.keys());
const previouslyHookRegistered = new Set(this.registeredGlobalHookShortcutIds);
for (const shortcut of previouslyHookRegistered) {
if (desired.has(shortcut)) continue;
try {
this.releaseGlobalRegistration(shortcut);
await electronApi.globalKeyHookUnregister?.(shortcut);
} catch (error) {
this.logger.error(`Failed to unregister global hook shortcut ${shortcut}`, error);
}
this.registeredGlobalHookShortcutIds.delete(shortcut);
this.activeGlobalShortcutPressIds.delete(shortcut);
}
for (const shortcut of desired) {
if (this.registeredGlobalHookShortcutIds.has(shortcut)) continue;
const desiredCombo = desiredCombos.get(shortcut);
if (!desiredCombo) continue;
if (await this.tryRegisterGlobalHookShortcut(shortcut, desiredCombo)) {
this.registeredGlobalHookShortcutIds.add(shortcut);
}
}
if (this.registeredGlobalHookShortcutIds.size > 0 && !this.globalKeyHookStarted) {
const started = await this.startGlobalKeyHook();
if (!started) {
this.registeredGlobalHookShortcutIds.clear();
this.activeGlobalShortcutPressIds.clear();
void electronApi.globalKeyHookUnregisterAll?.().catch((error) => {
this.logger.error('Failed to unregister global hook shortcuts after hook start failure', error);
});
}
}
this.maybeStopGlobalKeyHook();
}
private releaseGlobalRegistration(shortcutId: string): void {
for (const binding of this.holdBindings) {
if (binding.routing !== 'global') continue;
if (hookShortcutIdForAction(binding.action, binding.combo) !== shortcutId) continue;
if (binding.pressedKeycodes.size > 0) {
binding.pressedKeycodes.clear();
this.fireHoldHandler(binding, 'release', 'global', legacyHoldSourceId(binding));
}
if (binding.globalMouseActive) {
binding.globalMouseActive = false;
this.fireHoldHandler(binding, 'release', 'global', legacyHoldSourceId(binding));
}
}
}
private async tryRegisterGlobalHookShortcut(shortcutId: string, combo: KeyCombo): Promise<boolean> {
const electronApi = getElectronAPI();
if (!electronApi?.globalKeyHookRegister) return false;
const mouseButton = combo.mouseButton;
const keycode = mouseButton == null ? jsKeyToUiohookKeycode(combo.code ?? combo.key) : null;
const keyName = mouseButton == null ? keyNameForGlobalHook(combo) : null;
const physicalKeyName = mouseButton == null ? physicalKeyNameForGlobalHook(combo) : null;
if (mouseButton == null) {
if (keycode == null && !keyName) return false;
}
const started = await this.startGlobalKeyHook();
if (!started) return false;
try {
await electronApi.globalKeyHookRegister({
id: shortcutId,
description: this.resolveGlobalShortcutEventId(shortcutId)?.label,
...(keycode != null ? {keycode} : {}),
...(keyName ? {keyName} : {}),
...(physicalKeyName ? {physicalKeyName} : {}),
...(mouseButton != null ? {mouseButton} : {}),
ctrl: Boolean(combo.ctrl) || (!isNativeMacOS() && Boolean(combo.ctrlOrMeta)),
alt: Boolean(combo.alt),
shift: Boolean(combo.shift),
meta: Boolean(combo.meta) || (isNativeMacOS() && Boolean(combo.ctrlOrMeta)),
});
return true;
} catch (error) {
this.logger.error(`Failed to register global hook shortcut ${shortcutId}`, error);
return false;
}
}
private refreshLocalShortcuts() {
if (!this.combokeys || this.suspended) return;
this.combokeys.reset();
this.activeLocalShortcutPressIds.clear();
this.detachLocalKeyboardShortcutListener();
this.detachLocalMouseShortcutListener();
this.detachLocalEditableShortcutCaptureListener();
this.releaseGamepadShortcutStates();
const groups = new Map<string, Array<RuntimeKeybind>>();
for (const entry of this.resolvedKeybinds) {
if (!this.canBindLocalShortcut(entry)) continue;
for (const shortcut of comboToCombokeysStrings(entry.combo)) {
const entries = groups.get(shortcut);
if (entries) {
entries.push(entry);
} else {
groups.set(shortcut, [entry]);
}
}
}
for (const [shortcut, entries] of groups) {
this.bindLocalShortcutGroup(shortcut, this.orderLocalShortcutGroup(entries));
}
this.refreshLocalKeyboardShortcutListener();
this.refreshLocalEditableShortcutCaptureListener();
this.refreshLocalMouseShortcutListener();
this.refreshGamepadPolling();
}
private canBindLocalShortcut(entry: RuntimeKeybind): boolean {
const {combo, action} = entry;
if (HOLD_ACTIONS.includes(action as HoldAction)) return false;
const handler = this.handlers.get(action);
if (!handler) return false;
if (isWebReservedZoomShortcut(action, combo)) return false;
const shortcuts = comboToCombokeysStrings(combo);
if (shortcuts.length === 0) return false;
return true;
}
private orderLocalShortcutGroup(entries: ReadonlyArray<RuntimeKeybind>): Array<RuntimeKeybind> {
return entries
.map((entry, index) => ({entry, index}))
.sort((left, right) => {
const priorityDiff =
(LOCAL_SHORTCUT_ACTION_PRIORITY[right.entry.action] ?? 0) -
(LOCAL_SHORTCUT_ACTION_PRIORITY[left.entry.action] ?? 0);
return priorityDiff || left.index - right.index;
})
.map(({entry}) => entry);
}
private shouldIgnoreLocalShortcutEvent(entry: RuntimeKeybind, event: KeyboardEvent): boolean {
const {combo} = entry;
const hasModifier = !!(combo.ctrl || combo.ctrlOrMeta || combo.alt || combo.meta);
if (shouldSuppressShortcutForFullscreenMedia(event)) return true;
const target = event.target ?? null;
if (shouldSuppressLocalShortcutForModalFocus(entry, target)) return true;
if (!isEditableElement(target)) return false;
if (this.shouldAllowLocalShortcutFromEditable(entry, target)) {
return false;
}
if (!hasModifier) return true;
if (entry.ignoreWhileTyping) return true;
return false;
}
private shouldAllowLocalShortcutFromEditable(entry: RuntimeKeybind, target: HTMLElement): boolean {
if (entry.section === 'voice_and_video') return true;
return (
isChannelTextareaElement(target) &&
shouldAllowLocalShortcutForChannelTextarea(entry, getEditableElementValue(target))
);
}
private shouldPreserveEditableDefaultForShortcut(entry: RuntimeKeybind, event: KeyboardEvent): boolean {
if (entry.preventDefaultInEditable) return false;
const target = event.target ?? null;
return entry.section === 'voice_and_video' && isEditableElement(target);
}
private dispatchLocalShortcut(entry: RuntimeKeybind, type: 'press' | 'release', event: KeyboardEvent): boolean {
const {combo, action} = entry;
const requiresKeyboardMode = entry.requiresKeyboardMode ?? false;
const requiresMessageFocus = entry.requiresMessageFocus ?? false;
const handler = this.handlers.get(action);
if (!handler) return false;
if (type === 'press' && event.repeat) return false;
if (this.suspended) return false;
if (!this.isActionAllowedForCurrentView(action)) return false;
if (!keyboardEventTriggerMatchesCombo(combo, event)) {
return false;
}
if (this.shouldIgnoreLocalShortcutEvent(entry, event)) return false;
if (Keybind.isActionMuted(action)) return false;
if (requiresKeyboardMode && !KeyboardMode.keyboardModeEnabled) {
return false;
}
if (action === 'chat_focus_textarea' && KeyboardMode.keyboardModeEnabled) {
return false;
}
if (!this.isActionAvailableForLocalShortcut(action)) {
return false;
}
let focusedMessage: Message | null = null;
let focusedChannel = null;
if (requiresMessageFocus) {
focusedMessage = MessageFocus.getFocusedMessage();
if (!focusedMessage) {
return false;
}
focusedChannel = MessageFocus.getFocusedChannel();
}
const globalPressId = this.globalPressDedupeId(entry);
if (globalPressId !== null) {
if (type === 'press') {
if (this.activeGlobalShortcutPressIds.has(globalPressId)) return false;
this.activeGlobalShortcutPressIds.add(globalPressId);
} else {
this.activeGlobalShortcutPressIds.delete(globalPressId);
}
}
handler({
type,
source: 'local',
sourceId: sourceIdForKeybind(entry),
context: focusedMessage ? {focusedMessage, focusedChannel} : undefined,
shiftKey: event.shiftKey,
});
if (
type === 'press' &&
action === 'system_toggle_shortcuts_overlay' &&
shouldUseKeyboardShortcutsOverlayFallbackFromEvent(event)
) {
this.scheduleKeyboardShortcutsOverlayFallback();
}
if (type === 'press' && !this.shouldPreserveEditableDefaultForShortcut(entry, event)) {
event.preventDefault();
}
return true;
}
private scheduleKeyboardShortcutsOverlayFallback(): void {
const applyFallback = () => {
Keybind.useKeyboardShortcutsOverlayFallback();
};
if (typeof queueMicrotask === 'function') {
queueMicrotask(applyFallback);
return;
}
setTimeout(applyFallback, 0);
}
private bindLocalShortcutGroup(shortcut: string, entries: ReadonlyArray<RuntimeKeybind>): void {
const wrapHandler = (type: 'press' | 'release') => (event?: KeyboardEvent) => {
if (!event) return;
if (type === 'release') {
if (!this.activeLocalShortcutPressIds.delete(shortcut)) return;
for (const entry of entries) {
if (this.dispatchLocalShortcut(entry, type, event)) return;
}
return;
}
if (event.repeat) return;
if (this.activeLocalShortcutPressIds.has(shortcut)) return;
for (const entry of entries) {
if (this.dispatchLocalShortcut(entry, type, event)) {
this.activeLocalShortcutPressIds.add(shortcut);
return;
}
}
};
const combokeys = this.ensureCombokeys();
if (combokeys) {
combokeys.bind(shortcut, wrapHandler('press'), 'keydown');
combokeys.bind(shortcut, wrapHandler('release'), 'keyup');
}
}
}
export default new KeybindManager();