Make capture sessions continuous: click-capture + interval auto-capture
Template tests / tests (push) Failing after 29s

The session previously only listened for the global hotkey, which is
unreliable under WSLg/Wayland — users got one screenshot and nothing
more. Sessions now layer three triggers:

- click-capture via OS adapters (xinput test-xi2 on X11, PowerShell
  GetAsyncKeyState polling on Windows), debounced, ignoring clicks on
  StepForge itself
- interval auto-capture (3/5/10 s) as the always-works fallback,
  enabled by default when click detection is unavailable
- the existing global hotkey, plus a manual Shoot button

The REC bar now shows live count + active trigger with Shoot / Auto /
Pause / Finish. New captures and added steps are selected in the
editor (explicit reload(stepId) wins over a surviving selection).
Capture self-test hook (STEPFORGE_CAPTURE_SELFTEST) verifies 3x
hotkey-path captures and interval capture end-to-end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Iisyourdad
2026-06-10 22:33:12 -05:00
parent 6e790832f5
commit 52fd516a5d
7 changed files with 257 additions and 37 deletions
+164 -19
View File
@@ -1,61 +1,206 @@
'use strict';
const path = require('node:path');
const { spawn, execFileSync } = require('node:child_process');
const { desktopCapturer, screen, BrowserWindow, nativeImage } = require('electron');
const { expandPlaceholders } = require('../core/placeholders');
/**
* Capture service: full-screen, active-window, and region capture via
* Electron's desktopCapturer, plus a click-marker annotation at the cursor
* position and a capture session (start/pause/resume/finish) driven by the
* global hotkey.
* position and a capture session (start/pause/resume/finish).
*
* A session captures continuously, with three triggers layered by what the
* platform supports:
* - click-capture via an OS adapter (xinput on X11, PowerShell on Windows),
* - a global hotkey (unreliable on some Wayland compositors),
* - interval auto-capture as the always-works fallback.
*
* Note: under Wayland/WSLg, screen capture may require portal support; all
* failures surface as { ok: false, reason } instead of crashing.
*/
const CLICK_DEBOUNCE_MS = 700;
function hasBinary(name) {
try {
execFileSync('which', [name], { stdio: 'pipe' });
return true;
} catch {
return false;
}
}
class CaptureService {
constructor({ store, settings, getWindow, notify }) {
this.store = store;
this.settings = settings;
this.getWindow = getWindow;
this.notify = notify;
this.session = null; // { guideId, paused, count }
this.session = null; // { guideId, paused, count, intervalSec }
this.intervalTimer = null;
this.clickWatcher = null;
this.lastClickCapture = 0;
this.shooting = false;
}
state() {
return this.session
? { active: true, paused: this.session.paused, guideId: this.session.guideId, count: this.session.count }
: { active: false };
? {
active: true,
paused: this.session.paused,
guideId: this.session.guideId,
count: this.session.count,
intervalSec: this.session.intervalSec || 0,
clickCapture: Boolean(this.clickWatcher),
clickCaptureAvailable: this.clickCaptureAvailable(),
}
: { active: false, clickCaptureAvailable: this.clickCaptureAvailable() };
}
startSession(guideId) {
this.session = { guideId, paused: false, count: 0 };
clickCaptureAvailable() {
if (this._clickAvail === undefined) {
this._clickAvail = process.platform === 'win32' || (process.platform === 'linux' && hasBinary('xinput'));
}
return this._clickAvail;
}
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).
let interval = intervalSec;
if (interval == null) {
interval = this.clickCaptureAvailable() ? 0 : (this.settings.get('capture.autoIntervalSec') || 5);
}
this.session = { guideId, paused: false, count: 0, intervalSec: interval };
if (this.settings.get('capture.captureOutsideClicks') !== false) this.startClickWatcher();
this.applyInterval();
this.notify('capture:state', this.state());
}
setInterval(intervalSec) {
if (!this.session) return this.state();
this.session.intervalSec = Math.max(0, Number(intervalSec) || 0);
this.applyInterval();
this.notify('capture:state', this.state());
return this.state();
}
applyInterval() {
if (this.intervalTimer) {
clearInterval(this.intervalTimer);
this.intervalTimer = null;
}
const sec = this.session && this.session.intervalSec;
if (sec > 0) {
this.intervalTimer = setInterval(() => {
this.sessionCapture('interval').catch(() => {});
}, sec * 1000);
}
}
togglePause(force) {
if (!this.session) return;
this.session.paused = typeof force === 'boolean' ? force : !this.session.paused;
this.notify('capture:state', this.state());
}
finishSession() {
if (this.intervalTimer) {
clearInterval(this.intervalTimer);
this.intervalTimer = null;
}
this.stopClickWatcher();
this.session = null;
}
async hotkeyCapture() {
/** One capture inside the active session (hotkey/click/interval/manual). */
async sessionCapture(trigger = 'hotkey') {
if (!this.session || this.session.paused) return { ok: false, reason: 'no active capture session' };
const mode = this.settings.get('capture.mode') || 'fullscreen';
const result = await this.shoot({
guideId: this.session.guideId,
mode: mode === 'region' ? 'fullscreen' : mode,
delayMs: 0,
refocus: false, // don't steal focus from the app the user is documenting
});
if (result.ok) {
this.session.count += 1;
this.notify('capture:added', { guideId: this.session.guideId, step: result.step });
if (this.shooting) return { ok: false, reason: 'capture already in progress' };
this.shooting = true;
try {
const mode = this.settings.get('capture.mode') || 'fullscreen';
const result = await this.shoot({
guideId: this.session.guideId,
mode: mode === 'region' ? 'fullscreen' : mode,
delayMs: 0,
refocus: false, // don't steal focus from the app the user is documenting
});
if (result.ok) {
this.session.count += 1;
this.notify('capture:added', { guideId: this.session.guideId, step: result.step, trigger });
this.notify('capture:state', this.state());
}
return result;
} finally {
this.shooting = false;
}
return result;
}
hotkeyCapture() {
return this.sessionCapture('hotkey');
}
// ---- click-triggered capture --------------------------------------------
startClickWatcher() {
this.stopClickWatcher();
try {
if (process.platform === 'linux' && hasBinary('xinput')) {
// Stream raw button events from the X server; one capture per press.
this.clickWatcher = spawn('xinput', ['test-xi2', '--root'], { stdio: ['ignore', 'pipe', 'ignore'] });
let sawPress = false;
this.clickWatcher.stdout.on('data', (chunk) => {
const text = chunk.toString();
if (/RawButtonPress|ButtonPress/.test(text)) sawPress = true;
if (sawPress) {
sawPress = false;
this.onOsClick();
}
});
} else if (process.platform === 'win32') {
// Poll the left mouse button via GetAsyncKeyState; print one line per click.
const ps = `
Add-Type -Namespace W -Name U -MemberDefinition '[DllImport("user32.dll")] public static extern short GetAsyncKeyState(int k);'
$down = $false
while ($true) {
$s = [W.U]::GetAsyncKeyState(0x01) -band 0x8000
if ($s -and -not $down) { Write-Output CLICK }
$down = [bool]$s
Start-Sleep -Milliseconds 40
}`;
this.clickWatcher = spawn('powershell.exe', ['-NoProfile', '-Command', ps], { stdio: ['ignore', 'pipe', 'ignore'] });
this.clickWatcher.stdout.on('data', (chunk) => {
if (chunk.toString().includes('CLICK')) this.onOsClick();
});
}
if (this.clickWatcher) {
this.clickWatcher.on('error', () => { this.clickWatcher = null; });
this.clickWatcher.on('exit', () => { this.clickWatcher = null; });
}
} catch {
this.clickWatcher = null;
}
}
stopClickWatcher() {
if (this.clickWatcher) {
try { this.clickWatcher.kill(); } catch { /* already gone */ }
this.clickWatcher = null;
}
}
onOsClick() {
if (!this.session || this.session.paused) return;
// Ignore clicks on StepForge itself (pausing, finishing, editing).
if (BrowserWindow.getFocusedWindow()) return;
const now = Date.now();
if (now - this.lastClickCapture < CLICK_DEBOUNCE_MS) return;
this.lastClickCapture = now;
this.sessionCapture('click').catch(() => {});
}
autoTitle(mode) {