From 39c72f0fb09c8e97160ac50ae1f36678b52d8445 Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 28 Sep 2026 22:27:20 +0200 Subject: [PATCH] fix(desktop): require readable keyboards for Linux input access (#3026) --- .../app/keybindings/KeybindManager.ts | 5 + fluxer_desktop/src/main/GlobalKeyHook.ts | 78 +++++++++++---- .../src/main/LinuxInputAccess.test.mjs | 98 +++++++++++++++++++ fluxer_desktop/src/main/LinuxInputAccess.ts | 25 +++++ 4 files changed, 186 insertions(+), 20 deletions(-) create mode 100644 fluxer_desktop/src/main/LinuxInputAccess.test.mjs create mode 100644 fluxer_desktop/src/main/LinuxInputAccess.ts diff --git a/fluxer_app/src/features/app/keybindings/KeybindManager.ts b/fluxer_app/src/features/app/keybindings/KeybindManager.ts index cb3420940..d35493e14 100644 --- a/fluxer_app/src/features/app/keybindings/KeybindManager.ts +++ b/fluxer_app/src/features/app/keybindings/KeybindManager.ts @@ -1156,6 +1156,11 @@ class KeybindManager { return false; } this.globalKeyHookStarted = true; + if (NativePermission.isLinuxWaylandDesktop) { + void NativePermission.recheckLinuxInputAccess().then((status) => { + if (status === 'blocked') NativePermission.requestLinuxInputAccessNagbar(reason); + }); + } const keyEventUnsub = electronApi.onGlobalKeyEvent?.((event) => { this.handleGlobalKeyEvent( event as { diff --git a/fluxer_desktop/src/main/GlobalKeyHook.ts b/fluxer_desktop/src/main/GlobalKeyHook.ts index 4796d1e65..65d5214a6 100644 --- a/fluxer_desktop/src/main/GlobalKeyHook.ts +++ b/fluxer_desktop/src/main/GlobalKeyHook.ts @@ -10,6 +10,11 @@ import {createChildLogger} from '@electron/common/Logger'; import {type EvdevKeyEvent, type EvdevMouseEvent, getEvdevHook, nameToEvdevKeycode} from '@electron/main/EvdevHook'; import {GlobalKeyHookLifecycle} from '@electron/main/GlobalKeyHookLifecycle'; import {getLinuxInputHookMode} from '@electron/main/LaunchOptions'; +import { + hasLinuxEvdevAccess, + isFullKeyboardKeyCapabilities, + type LinuxEvdevAccessProbe, +} from '@electron/main/LinuxInputAccess'; import {isFlatpakRuntime} from '@electron/main/LinuxSandbox'; import {getTccStatus} from '@electron/main/MacTcc'; import {getMainWindow} from '@electron/main/Window'; @@ -315,6 +320,15 @@ async function startEvdevBackend(): Promise { return true; } +async function reopenEvdevDevices(): Promise { + if (activeBackend !== 'evdev') return; + const evdev = getEvdevHook(); + evdev.stop(); + if (!(await evdev.start())) { + logger.warn('evdev input backend did not reopen after input access changed'); + } +} + function startNativeBackend(): boolean { const moduleName = nativeModuleNameForPlatform(); if (moduleName === null) return false; @@ -374,7 +388,16 @@ async function startHook(): Promise { } return false; } - if (await startEvdevBackend()) { + const probe = await probeLinuxEvdevAccess(); + const keyboardsReadable = hasLinuxEvdevAccess(probe); + if (!keyboardsReadable) { + logger.warn('Not every keyboard input device is readable', { + keyboardDevices: probe.keyboardDevices, + readableKeyboardDevices: probe.readableKeyboardDevices, + readableEventDevices: probe.readableEventDevices, + }); + } + if ((keyboardsReadable || wayland) && (await startEvdevBackend())) { activeBackend = 'evdev'; return true; } @@ -495,34 +518,46 @@ async function checkInputMonitoringAccess(): Promise { return false; } -interface LinuxEvdevAccessProbe { - totalEventDevices: number; - readableEventDevices: number; - inInputGroup: boolean; -} - async function probeLinuxEvdevAccess(): Promise { - if (process.platform !== 'linux') { - return {totalEventDevices: 0, readableEventDevices: 0, inInputGroup: true}; - } - const inInputGroup = isFlatpakRuntime() ? false : await isUserInInputGroup(); + const probe: LinuxEvdevAccessProbe = { + totalEventDevices: 0, + readableEventDevices: 0, + keyboardDevices: 0, + readableKeyboardDevices: 0, + inInputGroup: true, + }; + if (process.platform !== 'linux') return probe; + probe.inInputGroup = isFlatpakRuntime() ? false : await isUserInInputGroup(); let entries: Array; try { entries = await readdir('/dev/input'); } catch { - return {totalEventDevices: 0, readableEventDevices: 0, inInputGroup}; + return probe; } - let total = 0; - let readable = 0; for (const name of entries) { if (!name.startsWith('event')) continue; - total += 1; + probe.totalEventDevices += 1; + let readable = false; try { accessSync(path.join('/dev/input', name), fsConstants.R_OK); - readable += 1; + readable = true; } catch {} + if (readable) probe.readableEventDevices += 1; + if (await isKeyboardEventDevice(name)) { + probe.keyboardDevices += 1; + if (readable) probe.readableKeyboardDevices += 1; + } + } + return probe; +} + +async function isKeyboardEventDevice(name: string): Promise { + try { + const capabilities = await readFile(path.join('/sys/class/input', name, 'device/capabilities/key'), 'utf8'); + return isFullKeyboardKeyCapabilities(capabilities); + } catch { + return false; } - return {totalEventDevices: total, readableEventDevices: readable, inInputGroup}; } async function isUserInInputGroup(): Promise { @@ -563,6 +598,8 @@ async function _collectLinuxEvdevDiagnostics(): Promise> flatpak: isFlatpakRuntime(), totalEventDevices: probe.totalEventDevices, readableEventDevices: probe.readableEventDevices, + keyboardDevices: probe.keyboardDevices, + readableKeyboardDevices: probe.readableKeyboardDevices, inInputGroup: probe.inInputGroup, }; } @@ -604,7 +641,7 @@ async function getLinuxEvdevStatus(): Promise { }; } const probe = await probeLinuxEvdevAccess(); - const hasAccess = probe.inInputGroup || probe.readableEventDevices > 0; + const hasAccess = hasLinuxEvdevAccess(probe); let username: string | null = null; try { username = userInfo().username; @@ -701,7 +738,7 @@ async function grantLinuxEvdevAccess(): Promise { }; } const before = await probeLinuxEvdevAccess(); - if (before.inInputGroup || before.readableEventDevices > 0) { + if (hasLinuxEvdevAccess(before)) { return {success: true, needsRelogin: false}; } const username = (() => { @@ -719,7 +756,8 @@ async function grantLinuxEvdevAccess(): Promise { return {success: false, needsRelogin: false, error: uaccess.error}; } const afterUaccess = await waitForLinuxEvdevAccessRefresh(); - if (afterUaccess.inInputGroup || afterUaccess.readableEventDevices > 0) { + if (hasLinuxEvdevAccess(afterUaccess)) { + await reopenEvdevDevices(); return {success: true, needsRelogin: false}; } const groupFallback = await runPkexec(['usermod', '-aG', 'input', username]); diff --git a/fluxer_desktop/src/main/LinuxInputAccess.test.mjs b/fluxer_desktop/src/main/LinuxInputAccess.test.mjs new file mode 100644 index 000000000..d44e1fd7a --- /dev/null +++ b/fluxer_desktop/src/main/LinuxInputAccess.test.mjs @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {createRequire} from 'node:module'; +import {describe, test} from 'node:test'; +import {fileURLToPath} from 'node:url'; +import vm from 'node:vm'; + +const require = createRequire(import.meta.url); +const esbuild = require('esbuild'); + +const sourcePath = fileURLToPath(new URL('./LinuxInputAccess.ts', import.meta.url)); +const source = readFileSync(sourcePath, 'utf8'); +const transformedSource = esbuild.transformSync(source, { + loader: 'ts', + format: 'cjs', + platform: 'node', + target: 'node20', +}).code; + +function loadLinuxInputAccess() { + const module = {exports: {}}; + const context = vm.createContext({module, exports: module.exports}); + vm.runInContext(transformedSource, context, {filename: sourcePath}); + return module.exports; +} + +const {isFullKeyboardKeyCapabilities, hasLinuxEvdevAccess} = loadLinuxInputAccess(); + +function probe(overrides) { + return { + totalEventDevices: 4, + readableEventDevices: 0, + keyboardDevices: 0, + readableKeyboardDevices: 0, + inInputGroup: false, + ...overrides, + }; +} + +describe('isFullKeyboardKeyCapabilities', () => { + test('accepts a real keyboard bitmap', () => { + assert.equal( + isFullKeyboardKeyCapabilities( + '120013 0 0 0 0 0 0 0 0 1000000000007 ff9f207ac14057ff febeffdfffefffff fffffffffffffffe', + ), + true, + ); + }); + + test('accepts a keyboard bitmap from a 32-bit kernel', () => { + assert.equal(isFullKeyboardKeyCapabilities('ffefffff fffffffe'), true); + }); + + test('rejects a mouse bitmap', () => { + assert.equal(isFullKeyboardKeyCapabilities('1f0000 0 0 0 0'), false); + }); + + test('rejects a gamepad bitmap', () => { + assert.equal(isFullKeyboardKeyCapabilities('7fdb000000000000 0 0 0 0'), false); + }); + + test('rejects a media-key-only bitmap', () => { + assert.equal(isFullKeyboardKeyCapabilities('10000 0 0 0 1c000000000000 0'), false); + }); + + test('rejects empty and malformed input', () => { + assert.equal(isFullKeyboardKeyCapabilities(''), false); + assert.equal(isFullKeyboardKeyCapabilities('zz'), false); + }); +}); + +describe('hasLinuxEvdevAccess', () => { + test('a readable gamepad does not count when the keyboard is unreadable', () => { + assert.equal(hasLinuxEvdevAccess(probe({readableEventDevices: 1, keyboardDevices: 1})), false); + }); + + test('every keyboard must be readable', () => { + assert.equal( + hasLinuxEvdevAccess(probe({readableEventDevices: 3, keyboardDevices: 2, readableKeyboardDevices: 1})), + false, + ); + assert.equal( + hasLinuxEvdevAccess(probe({readableEventDevices: 4, keyboardDevices: 2, readableKeyboardDevices: 2})), + true, + ); + }); + + test('input group membership grants access', () => { + assert.equal(hasLinuxEvdevAccess(probe({keyboardDevices: 1, inInputGroup: true})), true); + }); + + test('falls back to any readable device when no keyboard is detected', () => { + assert.equal(hasLinuxEvdevAccess(probe({readableEventDevices: 1})), true); + assert.equal(hasLinuxEvdevAccess(probe({})), false); + }); +}); diff --git a/fluxer_desktop/src/main/LinuxInputAccess.ts b/fluxer_desktop/src/main/LinuxInputAccess.ts new file mode 100644 index 000000000..e2686a04d --- /dev/null +++ b/fluxer_desktop/src/main/LinuxInputAccess.ts @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +const FULL_KEYBOARD_MASK = 0xfffffffe; + +export interface LinuxEvdevAccessProbe { + totalEventDevices: number; + readableEventDevices: number; + keyboardDevices: number; + readableKeyboardDevices: number; + inInputGroup: boolean; +} + +export function isFullKeyboardKeyCapabilities(keyCapabilities: string): boolean { + const words = keyCapabilities.trim().split(/\s+/); + const lowestWord = words[words.length - 1] ?? ''; + const lowestBits = Number.parseInt(lowestWord.slice(-8), 16); + if (!Number.isFinite(lowestBits)) return false; + return (lowestBits & FULL_KEYBOARD_MASK) >>> 0 === FULL_KEYBOARD_MASK; +} + +export function hasLinuxEvdevAccess(probe: LinuxEvdevAccessProbe): boolean { + if (probe.inInputGroup) return true; + if (probe.keyboardDevices > 0) return probe.readableKeyboardDevices === probe.keyboardDevices; + return probe.readableEventDevices > 0; +}