Template tests / tests (pull_request) Failing after 33s
Phase 3 of the improvement plan (PR 10 of the sequence): honest Wayland behavior and a least-privilege alternative to the broad `input` group the audit flagged as a keylogging surface. Least-privilege input access: - packaging/linux/common/60-stepforge-input.rules grants the ACTIVE session read access to MOUSE devices only (ID_INPUT_MOUSE, excluding ID_INPUT_KEYBOARD) via a systemd uaccess ACL — session-scoped, device- scoped, and never keyboards. This replaces `usermod -aG input`, which grants every input device (keyboards included) to the user permanently. - scripts/linux/enable-click-capture.sh installs it opt-in: it prints the exact rule, requires confirmation, and documents the security tradeoff. It never runs the broad-group command. Honest trigger reporting: - New chooseCaptureTrigger() (app/platform/linux/diagnostics.js, re-exported from app/platform/index.js) maps a capability profile to the real trigger: per-click with a marker on X11+xinput; per-click WITHOUT coordinates or a marker on Wayland evdev (the platform exposes no pointer position); and an honest hotkey/interval fallback otherwise. It never promises per-click capture with coordinates on Wayland. - platform:capabilities now includes the active trigger for the machine and the user's fallback setting, for the diagnostics UI. Docs: - GETTING_STARTED_WITH_LINUX.md no longer instructs every Wayland user to join the `input` group; it presents that as a warning, documents the least- privilege script, corrects the capability table (per-click is opt-in, mice only; hotkey/interval is the default Wayland trigger), and points at Settings → Diagnostics. Tests: trigger decisions for X11/xinput, Wayland evdev (no coordinates/marker), Wayland fallback (interval/hotkey with an honest note), the platform facade wiring, Windows; the udev rule (mouse-only, excludes keyboards, uaccess); the opt-in enable script (confirms, installs the rule, never runs usermod -aG input as a command); and docs guards. 289 unit tests pass; startup smoke and click self-test unchanged (stream, markers 3/3, burst 8/8). Co-Authored-By: Claude Fable 5 <[email protected]>
157 lines
5.3 KiB
JavaScript
157 lines
5.3 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('node:fs');
|
|
const { execFileSync } = require('node:child_process');
|
|
|
|
/**
|
|
* Linux capture-capability diagnostics. Detects the session type, portal /
|
|
* PipeWire availability, xinput, readable input devices, and the sandbox
|
|
* situation, and turns them into an actionable capability profile the UI can
|
|
* show instead of console-only failures.
|
|
*
|
|
* Pure detection with injectable probes so it is unit-testable without a real
|
|
* desktop session.
|
|
*/
|
|
|
|
function defaultHasBinary(name) {
|
|
try {
|
|
execFileSync('which', [name], { stdio: 'pipe' });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function detectSessionType(env = process.env) {
|
|
const t = String(env.XDG_SESSION_TYPE || '').toLowerCase();
|
|
if (t === 'wayland' || t === 'x11') return t;
|
|
if (env.WAYLAND_DISPLAY) return 'wayland';
|
|
if (env.DISPLAY) return 'x11';
|
|
return 'unknown';
|
|
}
|
|
|
|
function detectLinuxCapabilities({
|
|
env = process.env,
|
|
hasBinary = defaultHasBinary,
|
|
existsSync = fs.existsSync,
|
|
readdirSync = fs.readdirSync,
|
|
} = {}) {
|
|
const sessionType = detectSessionType(env);
|
|
const isWayland = sessionType === 'wayland';
|
|
|
|
// XDG Desktop Portal + PipeWire are how Wayland screen capture works.
|
|
const hasPortalBus = Boolean(env.DBUS_SESSION_BUS_ADDRESS);
|
|
let hasPipeWire = false;
|
|
try {
|
|
hasPipeWire = hasBinary('pipewire') || existsSync(`/run/user/${process.getuid ? process.getuid() : ''}/pipewire-0`);
|
|
} catch {
|
|
hasPipeWire = hasBinary('pipewire');
|
|
}
|
|
|
|
const hasXinput = hasBinary('xinput');
|
|
const hasXprop = hasBinary('xprop');
|
|
|
|
// Readable /dev/input event nodes gate the evdev click fallback.
|
|
let readableInputDevices = 0;
|
|
try {
|
|
for (const name of readdirSync('/dev/input')) {
|
|
if (!/^event\d+$/.test(name)) continue;
|
|
try { fs.accessSync(`/dev/input/${name}`, fs.constants.R_OK); readableInputDevices += 1; } catch { /* not readable */ }
|
|
}
|
|
} catch { /* /dev/input not present */ }
|
|
|
|
// Determine the click-capture profile for this session.
|
|
let clickCapture;
|
|
if (!isWayland && hasXinput) clickCapture = 'x11-xinput';
|
|
else if (readableInputDevices > 0) clickCapture = isWayland ? 'evdev-wayland' : 'evdev-x11';
|
|
else clickCapture = 'hotkey-or-interval-only';
|
|
|
|
const messages = [];
|
|
if (isWayland && !hasPipeWire) {
|
|
messages.push('Wayland screen capture needs PipeWire and the XDG Desktop Portal. Install pipewire and xdg-desktop-portal.');
|
|
}
|
|
if (isWayland && !hasPortalBus) {
|
|
messages.push('No D-Bus session bus detected; the screen-share portal cannot be reached.');
|
|
}
|
|
if (!isWayland && !hasXinput) {
|
|
messages.push('xinput not found: per-click capture with a marker is unavailable on X11 without it.');
|
|
}
|
|
if (clickCapture === 'hotkey-or-interval-only') {
|
|
messages.push('No global click source available. Recording falls back to a hotkey or interval trigger.');
|
|
}
|
|
|
|
return {
|
|
os: 'linux',
|
|
sessionType,
|
|
isWayland,
|
|
hasPortalBus,
|
|
hasPipeWire,
|
|
hasXinput,
|
|
hasXprop,
|
|
readableInputDevices,
|
|
clickCapture,
|
|
// Portal capture is the safe Wayland baseline; X11 can grab directly.
|
|
screenCapture: isWayland ? 'wayland-portal' : 'x11-direct',
|
|
messages,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Decide the honest capture trigger for a Linux capability profile. StepForge
|
|
* must never *promise* per-click capture with coordinates on Wayland, because
|
|
* the platform does not expose pointer position to apps. Returns the trigger,
|
|
* whether clicks carry coordinates, whether a marker can be drawn, and a
|
|
* user-facing note. `userTriggerPreference` is the capture.fallbackTrigger
|
|
* setting ('interval' | 'hotkey') used only when no click source exists.
|
|
*/
|
|
function chooseCaptureTrigger(capabilities, userTriggerPreference = 'interval') {
|
|
const caps = capabilities || {};
|
|
const click = caps.clickCapture;
|
|
|
|
if (click === 'x11-xinput') {
|
|
return {
|
|
trigger: 'click',
|
|
clickSource: 'x11',
|
|
coordinates: true,
|
|
marker: true,
|
|
note: 'Per-click capture with an accurate marker (X11 + xinput).',
|
|
};
|
|
}
|
|
if (click === 'evdev-x11') {
|
|
return {
|
|
trigger: 'click',
|
|
clickSource: 'evdev-x11',
|
|
coordinates: true,
|
|
marker: true,
|
|
note: 'Per-click capture via kernel input devices (X11, no xinput).',
|
|
};
|
|
}
|
|
if (click === 'evdev-wayland') {
|
|
// Wayland exposes button presses (via evdev, if permitted) but NOT pointer
|
|
// position, so a step is captured per click but without a marker. This is
|
|
// only reached when the user opted into the least-privilege device rule.
|
|
return {
|
|
trigger: 'click',
|
|
clickSource: 'evdev-wayland',
|
|
coordinates: false,
|
|
marker: false,
|
|
note: 'Per-click capture on Wayland has no pointer position, so no marker is drawn.',
|
|
};
|
|
}
|
|
|
|
// No global click source: the safe baseline is the user's chosen fallback.
|
|
const trigger = userTriggerPreference === 'hotkey' ? 'hotkey' : 'interval';
|
|
return {
|
|
trigger,
|
|
clickSource: trigger,
|
|
coordinates: false,
|
|
marker: false,
|
|
note: caps.isWayland
|
|
? 'Wayland does not expose global clicks; recording uses your ' + trigger + ' trigger. '
|
|
+ 'Screen sharing is requested once per recording via the portal.'
|
|
: 'No global click source available; recording uses your ' + trigger + ' trigger.',
|
|
};
|
|
}
|
|
|
|
module.exports = { detectLinuxCapabilities, detectSessionType, chooseCaptureTrigger };
|