mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(desktop): require readable keyboards for Linux input access (#3026)
This commit is contained in:
1 parent
3736d94d73
commit
39c72f0fb0
4 files changed
+186
-20
No files matched your search
@@ -1156,6 +1156,11 @@ class KeybindManager {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
this.globalKeyHookStarted = true;
|
this.globalKeyHookStarted = true;
|
||||||
|
if (NativePermission.isLinuxWaylandDesktop) {
|
||||||
|
void NativePermission.recheckLinuxInputAccess().then((status) => {
|
||||||
|
if (status === 'blocked') NativePermission.requestLinuxInputAccessNagbar(reason);
|
||||||
|
});
|
||||||
|
}
|
||||||
const keyEventUnsub = electronApi.onGlobalKeyEvent?.((event) => {
|
const keyEventUnsub = electronApi.onGlobalKeyEvent?.((event) => {
|
||||||
this.handleGlobalKeyEvent(
|
this.handleGlobalKeyEvent(
|
||||||
event as {
|
event as {
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import {createChildLogger} from '@electron/common/Logger';
|
|||||||
import {type EvdevKeyEvent, type EvdevMouseEvent, getEvdevHook, nameToEvdevKeycode} from '@electron/main/EvdevHook';
|
import {type EvdevKeyEvent, type EvdevMouseEvent, getEvdevHook, nameToEvdevKeycode} from '@electron/main/EvdevHook';
|
||||||
import {GlobalKeyHookLifecycle} from '@electron/main/GlobalKeyHookLifecycle';
|
import {GlobalKeyHookLifecycle} from '@electron/main/GlobalKeyHookLifecycle';
|
||||||
import {getLinuxInputHookMode} from '@electron/main/LaunchOptions';
|
import {getLinuxInputHookMode} from '@electron/main/LaunchOptions';
|
||||||
|
import {
|
||||||
|
hasLinuxEvdevAccess,
|
||||||
|
isFullKeyboardKeyCapabilities,
|
||||||
|
type LinuxEvdevAccessProbe,
|
||||||
|
} from '@electron/main/LinuxInputAccess';
|
||||||
import {isFlatpakRuntime} from '@electron/main/LinuxSandbox';
|
import {isFlatpakRuntime} from '@electron/main/LinuxSandbox';
|
||||||
import {getTccStatus} from '@electron/main/MacTcc';
|
import {getTccStatus} from '@electron/main/MacTcc';
|
||||||
import {getMainWindow} from '@electron/main/Window';
|
import {getMainWindow} from '@electron/main/Window';
|
||||||
@@ -315,6 +320,15 @@ async function startEvdevBackend(): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function reopenEvdevDevices(): Promise<void> {
|
||||||
|
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 {
|
function startNativeBackend(): boolean {
|
||||||
const moduleName = nativeModuleNameForPlatform();
|
const moduleName = nativeModuleNameForPlatform();
|
||||||
if (moduleName === null) return false;
|
if (moduleName === null) return false;
|
||||||
@@ -374,7 +388,16 @@ async function startHook(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
return false;
|
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';
|
activeBackend = 'evdev';
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -495,34 +518,46 @@ async function checkInputMonitoringAccess(): Promise<boolean> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LinuxEvdevAccessProbe {
|
|
||||||
totalEventDevices: number;
|
|
||||||
readableEventDevices: number;
|
|
||||||
inInputGroup: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function probeLinuxEvdevAccess(): Promise<LinuxEvdevAccessProbe> {
|
async function probeLinuxEvdevAccess(): Promise<LinuxEvdevAccessProbe> {
|
||||||
if (process.platform !== 'linux') {
|
const probe: LinuxEvdevAccessProbe = {
|
||||||
return {totalEventDevices: 0, readableEventDevices: 0, inInputGroup: true};
|
totalEventDevices: 0,
|
||||||
}
|
readableEventDevices: 0,
|
||||||
const inInputGroup = isFlatpakRuntime() ? false : await isUserInInputGroup();
|
keyboardDevices: 0,
|
||||||
|
readableKeyboardDevices: 0,
|
||||||
|
inInputGroup: true,
|
||||||
|
};
|
||||||
|
if (process.platform !== 'linux') return probe;
|
||||||
|
probe.inInputGroup = isFlatpakRuntime() ? false : await isUserInInputGroup();
|
||||||
let entries: Array<string>;
|
let entries: Array<string>;
|
||||||
try {
|
try {
|
||||||
entries = await readdir('/dev/input');
|
entries = await readdir('/dev/input');
|
||||||
} catch {
|
} catch {
|
||||||
return {totalEventDevices: 0, readableEventDevices: 0, inInputGroup};
|
return probe;
|
||||||
}
|
}
|
||||||
let total = 0;
|
|
||||||
let readable = 0;
|
|
||||||
for (const name of entries) {
|
for (const name of entries) {
|
||||||
if (!name.startsWith('event')) continue;
|
if (!name.startsWith('event')) continue;
|
||||||
total += 1;
|
probe.totalEventDevices += 1;
|
||||||
|
let readable = false;
|
||||||
try {
|
try {
|
||||||
accessSync(path.join('/dev/input', name), fsConstants.R_OK);
|
accessSync(path.join('/dev/input', name), fsConstants.R_OK);
|
||||||
readable += 1;
|
readable = true;
|
||||||
} catch {}
|
} 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<boolean> {
|
||||||
|
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<boolean> {
|
async function isUserInInputGroup(): Promise<boolean> {
|
||||||
@@ -563,6 +598,8 @@ async function _collectLinuxEvdevDiagnostics(): Promise<Record<string, unknown>>
|
|||||||
flatpak: isFlatpakRuntime(),
|
flatpak: isFlatpakRuntime(),
|
||||||
totalEventDevices: probe.totalEventDevices,
|
totalEventDevices: probe.totalEventDevices,
|
||||||
readableEventDevices: probe.readableEventDevices,
|
readableEventDevices: probe.readableEventDevices,
|
||||||
|
keyboardDevices: probe.keyboardDevices,
|
||||||
|
readableKeyboardDevices: probe.readableKeyboardDevices,
|
||||||
inInputGroup: probe.inInputGroup,
|
inInputGroup: probe.inInputGroup,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -604,7 +641,7 @@ async function getLinuxEvdevStatus(): Promise<LinuxEvdevStatus> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const probe = await probeLinuxEvdevAccess();
|
const probe = await probeLinuxEvdevAccess();
|
||||||
const hasAccess = probe.inInputGroup || probe.readableEventDevices > 0;
|
const hasAccess = hasLinuxEvdevAccess(probe);
|
||||||
let username: string | null = null;
|
let username: string | null = null;
|
||||||
try {
|
try {
|
||||||
username = userInfo().username;
|
username = userInfo().username;
|
||||||
@@ -701,7 +738,7 @@ async function grantLinuxEvdevAccess(): Promise<LinuxEvdevGrantResult> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const before = await probeLinuxEvdevAccess();
|
const before = await probeLinuxEvdevAccess();
|
||||||
if (before.inInputGroup || before.readableEventDevices > 0) {
|
if (hasLinuxEvdevAccess(before)) {
|
||||||
return {success: true, needsRelogin: false};
|
return {success: true, needsRelogin: false};
|
||||||
}
|
}
|
||||||
const username = (() => {
|
const username = (() => {
|
||||||
@@ -719,7 +756,8 @@ async function grantLinuxEvdevAccess(): Promise<LinuxEvdevGrantResult> {
|
|||||||
return {success: false, needsRelogin: false, error: uaccess.error};
|
return {success: false, needsRelogin: false, error: uaccess.error};
|
||||||
}
|
}
|
||||||
const afterUaccess = await waitForLinuxEvdevAccessRefresh();
|
const afterUaccess = await waitForLinuxEvdevAccessRefresh();
|
||||||
if (afterUaccess.inInputGroup || afterUaccess.readableEventDevices > 0) {
|
if (hasLinuxEvdevAccess(afterUaccess)) {
|
||||||
|
await reopenEvdevDevices();
|
||||||
return {success: true, needsRelogin: false};
|
return {success: true, needsRelogin: false};
|
||||||
}
|
}
|
||||||
const groupFallback = await runPkexec(['usermod', '-aG', 'input', username]);
|
const groupFallback = await runPkexec(['usermod', '-aG', 'input', username]);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user