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
+7
View File
@@ -16,6 +16,13 @@ Initial release.
- Capture menu in the editor topbar: full screen / window / region /
3-second delay, paste image as step, import images, and capture
session start/finish — capture no longer requires the global hotkey.
- Continuous capture sessions: steps are grabbed on every OS click where
the platform supports it (xinput on X11, PowerShell on Windows), with
interval auto-capture (3/5/10 s) as the always-works fallback when
click detection or global hotkeys are unavailable (e.g. WSLg/Wayland).
The REC bar shows the live count and trigger, with Shoot / Auto /
Pause / Finish controls.
- New captures and newly added steps are now selected in the editor.
- The app hides its own window during capture so screenshots show your
work, not StepForge; hotkey captures restore the window without
stealing focus.
+7 -4
View File
@@ -48,10 +48,13 @@ The core workflow:
- **Guide library** with folders, favorites, title search, full-text search,
duplicate/move/delete, and a quick-actions palette (`Ctrl+/`).
- **Capture engine** — the editor's **Capture ▾** button offers full screen,
active window, and region capture (the app hides itself during the shot)
plus delay, pause/resume sessions with global hotkeys, click markers,
clipboard paste, and PNG/JPEG/GIF import. The full keyboard shortcut list
lives under **More ▾ → Keyboard shortcuts** in the editor.
active window, and region capture (the app hides itself during the shot),
plus continuous capture sessions that grab a step on every click where the
OS allows it, or on a 3/5/10 s auto-interval everywhere else (the REC bar
shows the live count with Shoot/Auto/Pause/Finish controls). Delay, global
hotkeys, click markers, clipboard paste, and PNG/JPEG/GIF import included.
The full keyboard shortcut list lives under **More ▾ → Keyboard
shortcuts** in the editor.
- **Three-pane editor** — step tree with substeps, statuses
(todo/in-progress/done), hidden/skipped steps, focused view (zoom/pan that
never mutates the original image), autosave, and command-stack undo/redo.
+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) {
+33 -2
View File
@@ -98,6 +98,35 @@ function createWindow() {
}
}, 1500);
}
// Dev-only self-test: exercise the exact hotkey-session capture path
// (hide window -> grab -> showInactive) several times, then exit.
if (process.env.STEPFORGE_CAPTURE_SELFTEST) {
setTimeout(async () => {
try {
const guide = store.createGuide({ title: 'hotkey selftest' });
capture.startSession(guide.guideId, { intervalSec: 0 });
const results = [];
for (let i = 0; i < 3; i++) {
const r = await capture.hotkeyCapture();
results.push(r.ok ? 'OK' : `FAIL:${r.reason}`);
await new Promise((res) => setTimeout(res, 500));
}
console.log('HOTKEY-SELFTEST', JSON.stringify(results),
'steps:', store.getGuide(guide.guideId).stepsOrder.length);
// Interval auto-capture: 1s timer should add ~3 steps in 3.6s.
const guide2 = store.createGuide({ title: 'interval selftest' });
capture.startSession(guide2.guideId, { intervalSec: 1 });
await new Promise((res) => setTimeout(res, 3600));
capture.finishSession();
console.log('INTERVAL-SELFTEST steps:', store.getGuide(guide2.guideId).stepsOrder.length);
} catch (err) {
console.log('SELFTEST ERROR', err.message);
} finally {
app.quit();
}
}, 1500);
}
});
mainWindow.on('closed', () => { mainWindow = null; });
}
@@ -267,11 +296,13 @@ function setupIpc() {
if (result.ok) reindex(guideId);
return result;
});
h('capture:session', ({ action, guideId }) => {
if (action === 'start') capture.startSession(guideId);
h('capture:session', async ({ action, guideId, intervalSec }) => {
if (action === 'start') capture.startSession(guideId, { intervalSec: intervalSec ?? null });
else if (action === 'pause') capture.togglePause(true);
else if (action === 'resume') capture.togglePause(false);
else if (action === 'finish') capture.finishSession();
else if (action === 'interval') capture.setInterval(intervalSec);
else if (action === 'shoot') await capture.sessionCapture('manual');
const state = capture.state();
sendToRenderer('capture:state', state);
return state;
+39 -10
View File
@@ -81,6 +81,7 @@ class StepForgeApp {
async onCaptureAdded(payload) {
if (!payload || !payload.guideId) return;
this.updateCaptureState(await api.capture.state());
if (this.state.view === 'editor' && this.editor.guideId === payload.guideId) {
await this.editor.reload(payload.step && payload.step.stepId ? payload.step.stepId : this.editor.selectedStepId);
return;
@@ -187,7 +188,13 @@ class StepForgeApp {
const state = await api.capture.session({ action: 'start', guideId: guide.guideId });
this.updateCaptureState(state);
const hotkey = this.state.settings?.capture?.hotkeyCapture;
toast(hotkey ? `Capture session started — press ${hotkey} to grab a step.` : 'Capture session started.');
if (state.clickCapture) {
toast('Capture session started — every click outside StepForge grabs a step.');
} else if (state.intervalSec > 0) {
toast(`Capture session started — auto-capturing every ${state.intervalSec}s (use the REC bar to pause or change).`);
} else {
toast(hotkey ? `Capture session started — press ${hotkey} or use Shoot in the REC bar.` : 'Capture session started.');
}
}
async openExistingWorkspace() {
@@ -229,21 +236,43 @@ class StepForgeApp {
return;
}
this.captureStatus.classList.remove('hidden');
const s = this.captureState;
const send = (payload) => api.capture.session(payload).then((next) => this.updateCaptureState(next));
// What is currently triggering captures, so the user knows what to do.
const trigger = s.paused ? 'paused'
: s.clickCapture ? 'on click'
: s.intervalSec > 0 ? `every ${s.intervalSec}s`
: 'hotkey only';
const shootBtn = el('button', {
type: 'button',
title: 'Capture a step now (the app hides itself for the shot)',
onClick: () => send({ action: 'shoot' }),
}, 'Shoot');
// Cycle interval auto-capture: off -> 3s -> 5s -> 10s -> off.
const nextInterval = { 0: 3, 3: 5, 5: 10, 10: 0 }[s.intervalSec ?? 0] ?? 3;
const autoBtn = el('button', {
type: 'button',
title: 'Automatically capture a step on a timer',
onClick: () => send({ action: 'interval', intervalSec: nextInterval }),
}, s.intervalSec > 0 ? `Auto ${s.intervalSec}s` : 'Auto off');
const pauseBtn = el('button', {
type: 'button',
onClick: () => {
const action = this.captureState.paused ? 'resume' : 'pause';
api.capture.session({ action, guideId: this.editorMeta?.guide?.id || this.editorMeta?.guide?.guideId || null })
.then((next) => this.updateCaptureState(next));
},
}, this.captureState.paused ? 'Resume' : 'Pause');
onClick: () => send({ action: s.paused ? 'resume' : 'pause' }),
}, s.paused ? 'Resume' : 'Pause');
const finishBtn = el('button', {
type: 'button',
onClick: () => api.capture.session({ action: 'finish', guideId: this.editorMeta?.guide?.id || this.editorMeta?.guide?.guideId || null })
.then((next) => this.updateCaptureState(next)),
onClick: () => send({ action: 'finish' }),
}, 'Finish');
this.captureStatus.append(
el('span', {}, `Capture ${this.captureState.count || 0}`),
el('span', { title: `Capture session — ${trigger}` }, `REC ${s.count || 0} · ${trigger}`),
shootBtn,
autoBtn,
pauseBtn,
finishBtn,
);
+6 -2
View File
@@ -125,8 +125,12 @@ class GuideEditor {
this.steps = steps;
this.stepMap = new Map(steps.map((step) => [step.stepId, step]));
if (!this.shellMounted) this.mountShell();
if (!this.selectedStepId || !this.stepMap.has(this.selectedStepId)) {
this.selectedStepId = stepId && this.stepMap.has(stepId) ? stepId : (steps[0] && steps[0].stepId) || null;
// An explicitly requested step (new capture, added step, restored
// neighbour) wins; otherwise keep the current selection if it survived.
if (stepId && this.stepMap.has(stepId)) {
this.selectedStepId = stepId;
} else if (!this.selectedStepId || !this.stepMap.has(this.selectedStepId)) {
this.selectedStepId = (steps[0] && steps[0].stepId) || null;
}
this.selectedAnnotationId = null;
this.renderAll();
+1
View File
@@ -18,6 +18,7 @@ const DEFAULT_SETTINGS = {
hotkeyPauseResume: 'CommandOrControl+Shift+2',
captureOutsideClicks: true,
confirmSimpleCapture: false,
autoIntervalSec: 5, // session fallback when click capture is unavailable
},
editor: {
focusedViewDefaultForNewSteps: false,