diff --git a/README.md b/README.md index 06026d7..63a6d88 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ using only Node built-ins. For a windows installation, see [docs/windows_installation](docs/windows_installation.md) or for a developer/more in depth walkthrough, see [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md). -On **Linux** (⚠️ work in progress — X11 vs Wayland, enabling per-click capture, the screen-share prompt), see [GETTING_STARTED_WITH_LINUX.md](GETTING_STARTED_WITH_LINUX.md). +On **Linux** (⚠️ work in progress — X11 vs Wayland, enabling per-click capture, the screen-share prompt), see [docs/GETTING_STARTED_WITH_LINUX.md](docs/GETTING_STARTED_WITH_LINUX.md). Requirements: Node.js 20+ and npm (Electron is the only dependency). diff --git a/app/capture.js b/app/capture.js index 771308c..31e8379 100644 --- a/app/capture.js +++ b/app/capture.js @@ -113,8 +113,13 @@ function hasBinary(name) { // Treating it as "available" would leave the session with no click capture AND // no interval fallback, so zero steps get captured. function isWayland() { - return process.platform === 'linux' - && (process.env.XDG_SESSION_TYPE === 'wayland' || Boolean(process.env.WAYLAND_DISPLAY)); + if (process.platform !== 'linux') return false; + // XDG_SESSION_TYPE is the authoritative session hint when present. Some + // desktops still export WAYLAND_DISPLAY even when the active session is X11, + // so only fall back to it when XDG_SESSION_TYPE is unavailable. + const sessionType = String(process.env.XDG_SESSION_TYPE || '').toLowerCase(); + if (sessionType) return sessionType === 'wayland'; + return Boolean(process.env.WAYLAND_DISPLAY); } // ---- evdev (Linux kernel input) click reader -------------------------------- @@ -263,6 +268,16 @@ class CaptureService { return this.settings.get('capture.strictClickFrames') !== false; } + fallbackCaptureTrigger() { + const raw = String(this.settings.get('capture.fallbackTrigger') || 'interval').toLowerCase(); + return raw === 'hotkey' ? 'hotkey' : 'interval'; + } + + fallbackIntervalSec() { + const raw = Number(this.settings.get('capture.autoIntervalSec')); + return Number.isFinite(raw) && raw > 0 ? raw : 5; + } + clickCaptureAvailable() { if (this._clickAvail === undefined) { // Three click sources, in order of fidelity: @@ -296,12 +311,19 @@ class CaptureService { startSession(guideId, { intervalSec = null } = {}) { this.finishSession(); - // Default trigger: clicks when the platform supports it, otherwise an - // interval so a session always produces steps even if the global hotkey - // never fires (common under Wayland/WSLg). + // Default trigger: clicks when the platform supports it, otherwise the + // user-selected fallback (timer or hotkey-only). That keeps Linux from + // silently dropping into an unwanted 5-second timer when click capture + // is unavailable. let interval = intervalSec; if (interval == null) { - interval = this.clickCaptureAvailable() ? 0 : (this.settings.get('capture.autoIntervalSec') || 5); + if (this.clickCaptureAvailable()) { + interval = 0; + } else if (this.fallbackCaptureTrigger() === 'hotkey') { + interval = 0; + } else { + interval = this.fallbackIntervalSec(); + } } // Sessions start paused: nothing hides and no capturing happens until // the user explicitly presses "Start recording" in the capture bar, so @@ -1381,7 +1403,9 @@ public static class SFHook { console.error(`[stepforge] click watcher stopped${detail ? `: ${detail}` : ''}`); if (!this.session) return; if (!this.session.intervalSec) { - this.session.intervalSec = this.settings.get('capture.autoIntervalSec') || 5; + this.session.intervalSec = this.fallbackCaptureTrigger() === 'hotkey' + ? 0 + : this.fallbackIntervalSec(); this.applyInterval(); } this.notify('capture:state', this.state()); diff --git a/app/renderer/app.js b/app/renderer/app.js index 738ede7..f0842da 100644 --- a/app/renderer/app.js +++ b/app/renderer/app.js @@ -122,6 +122,7 @@ class StepForgeApp { api.library.trashList(), ]); this.state.info = info; + document.body.classList.toggle('platform-linux', info.platform === 'linux'); this.state.settings = settings; this.state.library = { guides: library.guides || [], diff --git a/app/renderer/dialogs.js b/app/renderer/dialogs.js index ababa68..7b72745 100644 --- a/app/renderer/dialogs.js +++ b/app/renderer/dialogs.js @@ -312,6 +312,11 @@ function showSettingsDialog({ const previewCount = makeInput(settings.exports?.previewStepCount ?? 3, 'number', { min: 1, step: 1 }); const openFolder = el('input', { type: 'checkbox', checked: Boolean(settings.exports?.openFolderAfterExport) }); const captureOutside = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.captureOutsideClicks) }); + const fallbackTrigger = makeSelect(settings.capture?.fallbackTrigger || 'interval', [ + { value: 'interval', label: 'Timed interval' }, + { value: 'hotkey', label: 'Hotkey only' }, + ]); + const autoIntervalSec = makeInput(settings.capture?.autoIntervalSec ?? 5, 'number', { min: 1, step: 1 }); const confirmSimple = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.confirmSimpleCapture) }); const keepLast = makeInput(settings.backups?.keepLast ?? 10, 'number', { min: 0, step: 1 }); const aiEnabled = el('input', { type: 'checkbox', checked: Boolean(settings.ai?.enabled) }); @@ -326,6 +331,12 @@ function showSettingsDialog({ void api.settings.set({ keyPath: 'ai.ollama.model', value: model }).catch(() => {}); }, 250); + const syncFallbackUi = () => { + autoIntervalSec.disabled = fallbackTrigger.value === 'hotkey'; + }; + fallbackTrigger.addEventListener('change', syncFallbackUi); + syncFallbackUi(); + const updateAiStatus = (message, { error = false } = {}) => { aiStatus.textContent = message; aiStatus.classList.toggle('error', Boolean(error)); @@ -403,9 +414,14 @@ function showSettingsDialog({ labeledRow('Delay (ms)', delayMs), labeledRow('Click marker', clickMarker), labeledRow('Capture outside clicks', captureOutside), + labeledRow('When clicks are unavailable', fallbackTrigger), + labeledRow('Timer interval (seconds)', autoIntervalSec), labeledRow('Confirm simple capture', confirmSimple), labeledRow('Capture hotkey', captureHotkey), labeledRow('Pause / resume hotkey', pauseHotkey), + el('div.muted', {}, + 'Hotkey fallback uses the Capture hotkey. Timer fallback uses the interval above.', + ), ), el('fieldset', {}, el('legend', {}, 'Editor'), @@ -455,6 +471,8 @@ function showSettingsDialog({ delayMs: Number(delayMs.value || 0), mode: captureMode.value, clickMarker: clickMarker.checked, + fallbackTrigger: fallbackTrigger.value === 'hotkey' ? 'hotkey' : 'interval', + autoIntervalSec: Math.max(1, Number(autoIntervalSec.value || 5)), hotkeyCapture: captureHotkey.value.trim(), hotkeyPauseResume: pauseHotkey.value.trim(), captureOutsideClicks: captureOutside.checked, diff --git a/app/renderer/style.css b/app/renderer/style.css index 5ea2b5a..833c9e8 100644 --- a/app/renderer/style.css +++ b/app/renderer/style.css @@ -49,6 +49,16 @@ body { overflow: hidden; } +body.platform-linux button { + padding: 5px 11px; +} + +body.platform-linux input, +body.platform-linux select, +body.platform-linux textarea { + padding: 6px 9px; +} + *::selection { background: rgba(0, 104, 255, 0.2); } #app { display: flex; flex-direction: column; height: 100vh; } @@ -183,6 +193,21 @@ kbd { color: #fff; } +body.platform-linux #topbar { + height: 52px; + gap: 10px; + padding: 0 14px; +} + +body.platform-linux #capture-status { + padding: 5px 9px; + gap: 7px; +} + +body.platform-linux #capture-status button { + padding: 2px 7px; +} + .library, .editor { flex: 1; min-height: 0; display: flex; } .lib-side { diff --git a/core/settings.js b/core/settings.js index 02b1552..e21b483 100644 --- a/core/settings.js +++ b/core/settings.js @@ -18,6 +18,9 @@ const DEFAULT_SETTINGS = { hotkeyPauseResume: 'CommandOrControl+Shift+2', captureOutsideClicks: true, confirmSimpleCapture: false, + // Fallback trigger when click capture is unavailable: keep the old timer + // fallback by default, but let users switch to hotkey-only recordings. + fallbackTrigger: 'interval', // interval | hotkey // Leading-edge click debounce (ms): clicks of the same button closer // together than this collapse into one step, so accidental fast/double // clicks don't each become a step. Clicks spaced further apart always diff --git a/GETTING_STARTED_WITH_LINUX.md b/docs/GETTING_STARTED_WITH_LINUX.md similarity index 94% rename from GETTING_STARTED_WITH_LINUX.md rename to docs/GETTING_STARTED_WITH_LINUX.md index 51e787b..1128177 100644 --- a/GETTING_STARTED_WITH_LINUX.md +++ b/docs/GETTING_STARTED_WITH_LINUX.md @@ -97,8 +97,13 @@ captures a screenshot on each click. ### Adjusting the timed-capture interval If you don't enable the `input` group, StepForge captures on a timer. Change the -interval in **Settings → Capture** (`capture.autoIntervalSec`, default 5 -seconds). +fallback in **Settings → Capture**: + +- `When clicks are unavailable` -> `Hotkey only` to use the Capture hotkey + instead of a timer. +- `When clicks are unavailable` -> `Timed interval`, then set + `Timer interval (seconds)` (`capture.autoIntervalSec`, default 5 seconds) + if you want timed captures. --- diff --git a/package.json b/package.json index 41fa808..cbf0d2c 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "private": true, "scripts": { "start": "node scripts/start-electron.js", - "test": "node --test tests/unit/", + "test": "node --test \"tests/unit/*.test.js\"", "sample": "node scripts/make-sample-guide.js", "package:windows": "node scripts/package-windows.js", "build": "bash scripts/build-release.sh", diff --git a/scripts/electron-launcher.js b/scripts/electron-launcher.js index a23a993..518c166 100644 --- a/scripts/electron-launcher.js +++ b/scripts/electron-launcher.js @@ -60,6 +60,26 @@ function sanitizeElectronEnv(baseEnv = process.env) { return env; } +function linuxSandboxLaunchArgs({ + electronPath, + platform = process.platform, + statSync = fs.statSync, +} = {}) { + if (platform !== 'linux') return []; + if (!electronPath) return ['--no-sandbox']; + + const helperPath = path.join(path.dirname(electronPath), 'chrome-sandbox'); + try { + const stat = statSync(helperPath); + const ownedByRoot = stat.uid === 0; + const hasSetuid = Boolean(stat.mode & 0o4000); + if (ownedByRoot && hasSetuid) return []; + } catch { + // Missing or unreadable helper: fall back to the unsandboxed launcher. + } + return ['--no-sandbox']; +} + function electronBinaryCandidates({ packageRoot, distDir, platform }) { const candidatePaths = []; const pathHint = packageRoot ? readElectronPathHint(packageRoot) : null; @@ -292,6 +312,7 @@ module.exports = { runNpmRebuild, runNpmInstall, sanitizeElectronEnv, + linuxSandboxLaunchArgs, resolveElectronBinary, resolveElectronPackageRoot, platformBinaryCandidates, diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh index 1be9c66..a96f907 100644 --- a/scripts/package-linux.sh +++ b/scripts/package-linux.sh @@ -46,8 +46,21 @@ fi cat > "$WORK_DIR/usr/bin/stepforge" <<'EOF' #!/usr/bin/env sh APP_DIR=/opt/stepforge +ELECTRON="$APP_DIR/node_modules/.bin/electron" +SANDBOX_HELPER="$APP_DIR/node_modules/electron/dist/chrome-sandbox" cd "$APP_DIR" || exit 1 -exec "$APP_DIR/node_modules/.bin/electron" "$APP_DIR" "$@" +if command -v stat >/dev/null 2>&1 && [ -e "$SANDBOX_HELPER" ]; then + helper_uid="$(stat -c '%u' "$SANDBOX_HELPER" 2>/dev/null || echo '')" + helper_mode="$(stat -c '%a' "$SANDBOX_HELPER" 2>/dev/null || echo '')" + if [ "$helper_uid" = "0" ] && [ -n "$helper_mode" ]; then + helper_mode_num=$((8#$helper_mode)) + if [ $((helper_mode_num & 04000)) -ne 0 ]; then + exec "$ELECTRON" "$APP_DIR" "$@" + fi + fi +fi +printf '%s\n' '[stepforge] Electron sandbox helper is not configured for this install; starting with --no-sandbox' >&2 +exec "$ELECTRON" --no-sandbox "$APP_DIR" "$@" EOF chmod 0755 "$WORK_DIR/usr/bin/stepforge" diff --git a/scripts/start-electron.js b/scripts/start-electron.js index 0065906..c4c004a 100644 --- a/scripts/start-electron.js +++ b/scripts/start-electron.js @@ -3,7 +3,11 @@ const { spawn } = require('node:child_process'); -const { resolveElectronBinary, sanitizeElectronEnv } = require('./electron-launcher'); +const { + linuxSandboxLaunchArgs, + resolveElectronBinary, + sanitizeElectronEnv, +} = require('./electron-launcher'); let electronPath; try { @@ -13,11 +17,16 @@ try { process.exit(1); } const env = sanitizeElectronEnv(); +const sandboxArgs = linuxSandboxLaunchArgs({ electronPath }); +if (sandboxArgs.includes('--no-sandbox')) { + console.warn('[stepforge] Electron sandbox helper is not configured for this install; starting with --no-sandbox'); +} -// On Linux/Wayland, enable PipeWire-based screen capture so desktopCapturer -// can go through the XDG Desktop Portal when XWayland isn't the only option. +// On Linux, prefer the native Ozone path when available and enable PipeWire- +// based screen capture so desktopCapturer can go through the XDG Desktop +// Portal on Wayland without affecting X11. const extraArgs = process.platform === 'linux' - ? ['--enable-features=WebRTCPipeWireCapturer'] + ? ['--enable-features=WebRTCPipeWireCapturer', '--ozone-platform-hint=auto', ...sandboxArgs] : []; const child = spawn(electronPath, [...extraArgs, '.'], { diff --git a/tests/unit/capture.test.js b/tests/unit/capture.test.js index cbb7067..e2e990f 100644 --- a/tests/unit/capture.test.js +++ b/tests/unit/capture.test.js @@ -33,6 +33,22 @@ function makeService({ settings: settingsOverrides, screenApi } = {}) { }); } +test('XDG_SESSION_TYPE=x11 wins over a stray WAYLAND_DISPLAY value', () => { + const prevSessionType = process.env.XDG_SESSION_TYPE; + const prevWaylandDisplay = process.env.WAYLAND_DISPLAY; + try { + process.env.XDG_SESSION_TYPE = 'x11'; + process.env.WAYLAND_DISPLAY = 'wayland-0'; + const service = makeService(); + assert.equal(service.onWayland(), false); + } finally { + if (prevSessionType === undefined) delete process.env.XDG_SESSION_TYPE; + else process.env.XDG_SESSION_TYPE = prevSessionType; + if (prevWaylandDisplay === undefined) delete process.env.WAYLAND_DISPLAY; + else process.env.WAYLAND_DISPLAY = prevWaylandDisplay; + } +}); + // The raw/regular twin window plus margin: how long a test must wait for a // held Linux raw press to fire when no coordinate twin arrives. const TWIN_FLUSH_MS = 60; @@ -840,6 +856,49 @@ test('losing the click watcher mid-session falls back to interval capture', () = } }); +test('losing the click watcher mid-session can fall back to hotkey-only', () => { + const service = makeService(); + service.settings.get = (key) => { + if (key === 'capture.fallbackTrigger') return 'hotkey'; + if (key === 'capture.autoIntervalSec') return 3; + return null; + }; + service.session = { guideId: 'guide-loss-hotkey', paused: false, count: 0, intervalSec: 0 }; + const states = []; + service.notify = (channel, payload) => { + states.push({ channel, payload }); + }; + + try { + service.handleClickWatcherLoss('exited with code 1'); + + assert.equal(service.session.intervalSec, 0, + 'hotkey fallback must not silently start the 5-second timer'); + assert.equal(service.intervalTimer, null); + assert.ok(states.some((s) => s.channel === 'capture:state')); + } finally { + service.finishSession(); + } +}); + +test('starting a session with hotkey fallback keeps the timer off when clicks are unavailable', () => { + const service = makeService(); + service.clickCaptureAvailable = () => false; + service.settings.get = (key) => { + if (key === 'capture.fallbackTrigger') return 'hotkey'; + if (key === 'capture.autoIntervalSec') return 7; + if (key === 'capture.captureOutsideClicks') return false; + return null; + }; + + service.startSession('guide-hotkey-fallback'); + + assert.equal(service.session.intervalSec, 0); + assert.equal(service.state().clickCaptureAvailable, false); + assert.equal(service.state().clickCapture, false); + service.finishSession(); +}); + // ---- strict frame selection ----------------------------------------------------- test('a click is served instantly from the freshly buffered frame', async () => { diff --git a/tests/unit/electron-launcher.test.js b/tests/unit/electron-launcher.test.js index 3cefaa1..3044d87 100644 --- a/tests/unit/electron-launcher.test.js +++ b/tests/unit/electron-launcher.test.js @@ -7,6 +7,7 @@ const path = require('node:path'); const { buildMissingElectronError, + linuxSandboxLaunchArgs, repairElectronInstall, resolveElectronBinary, } = require('../../scripts/electron-launcher'); @@ -171,3 +172,21 @@ test('reports a helpful error when the runtime is missing', (t) => { assert.match(message, /Electron could not be started/); assert.match(message, /Expected the binary in:/); }); + +test('uses --no-sandbox when the Linux sandbox helper is not root-owned and setuid', () => { + const args = linuxSandboxLaunchArgs({ + electronPath: '/tmp/stepforge/node_modules/electron/dist/electron', + platform: 'linux', + statSync: () => ({ uid: 1000, mode: 0o100755 }), + }); + assert.deepEqual(args, ['--no-sandbox']); +}); + +test('keeps the sandbox enabled when the Linux helper is root-owned and setuid', () => { + const args = linuxSandboxLaunchArgs({ + electronPath: '/tmp/stepforge/node_modules/electron/dist/electron', + platform: 'linux', + statSync: () => ({ uid: 0, mode: 0o104755 }), + }); + assert.deepEqual(args, []); +}); diff --git a/tests/unit/placeholders.test.js b/tests/unit/placeholders.test.js index 62c93d6..6b700d8 100644 --- a/tests/unit/placeholders.test.js +++ b/tests/unit/placeholders.test.js @@ -65,6 +65,7 @@ test('settings persist, deep-merge with defaults, and store global placeholders' assert.equal(s2.get('capture.delayMs'), 1500); assert.equal(s2.get('ai.ollama.model'), 'qwen3:0.6b'); assert.equal(s2.get('capture.clickMarker'), DEFAULT_SETTINGS.capture.clickMarker); + assert.equal(s2.get('capture.fallbackTrigger'), DEFAULT_SETTINGS.capture.fallbackTrigger); assert.deepEqual(s2.getGlobalPlaceholders(), { Company: 'Acme', Author: 'Tyler' }); });