Compare commits

..
4 changed files with 186 additions and 20 deletions
@@ -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 {
+58 -20
View File
@@ -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<boolean> {
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 {
const moduleName = nativeModuleNameForPlatform();
if (moduleName === null) return false;
@@ -374,7 +388,16 @@ async function startHook(): Promise<boolean> {
}
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<boolean> {
return false;
}
interface LinuxEvdevAccessProbe {
totalEventDevices: number;
readableEventDevices: number;
inInputGroup: boolean;
}
async function probeLinuxEvdevAccess(): Promise<LinuxEvdevAccessProbe> {
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<string>;
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<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> {
@@ -563,6 +598,8 @@ async function _collectLinuxEvdevDiagnostics(): Promise<Record<string, unknown>>
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<LinuxEvdevStatus> {
};
}
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<LinuxEvdevGrantResult> {
};
}
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<LinuxEvdevGrantResult> {
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]);
@@ -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;
}