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 / - Capture menu in the editor topbar: full screen / window / region /
3-second delay, paste image as step, import images, and capture 3-second delay, paste image as step, import images, and capture
session start/finish — capture no longer requires the global hotkey. 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 - The app hides its own window during capture so screenshots show your
work, not StepForge; hotkey captures restore the window without work, not StepForge; hotkey captures restore the window without
stealing focus. stealing focus.
+7 -4
View File
@@ -48,10 +48,13 @@ The core workflow:
- **Guide library** with folders, favorites, title search, full-text search, - **Guide library** with folders, favorites, title search, full-text search,
duplicate/move/delete, and a quick-actions palette (`Ctrl+/`). duplicate/move/delete, and a quick-actions palette (`Ctrl+/`).
- **Capture engine** — the editor's **Capture ▾** button offers full screen, - **Capture engine** — the editor's **Capture ▾** button offers full screen,
active window, and region capture (the app hides itself during the shot) active window, and region capture (the app hides itself during the shot),
plus delay, pause/resume sessions with global hotkeys, click markers, plus continuous capture sessions that grab a step on every click where the
clipboard paste, and PNG/JPEG/GIF import. The full keyboard shortcut list OS allows it, or on a 3/5/10 s auto-interval everywhere else (the REC bar
lives under **More ▾ → Keyboard shortcuts** in the editor. 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 - **Three-pane editor** — step tree with substeps, statuses
(todo/in-progress/done), hidden/skipped steps, focused view (zoom/pan that (todo/in-progress/done), hidden/skipped steps, focused view (zoom/pan that
never mutates the original image), autosave, and command-stack undo/redo. never mutates the original image), autosave, and command-stack undo/redo.
+154 -9
View File
@@ -1,49 +1,127 @@
'use strict'; 'use strict';
const path = require('node:path'); const path = require('node:path');
const { spawn, execFileSync } = require('node:child_process');
const { desktopCapturer, screen, BrowserWindow, nativeImage } = require('electron'); const { desktopCapturer, screen, BrowserWindow, nativeImage } = require('electron');
const { expandPlaceholders } = require('../core/placeholders'); const { expandPlaceholders } = require('../core/placeholders');
/** /**
* Capture service: full-screen, active-window, and region capture via * Capture service: full-screen, active-window, and region capture via
* Electron's desktopCapturer, plus a click-marker annotation at the cursor * Electron's desktopCapturer, plus a click-marker annotation at the cursor
* position and a capture session (start/pause/resume/finish) driven by the * position and a capture session (start/pause/resume/finish).
* global hotkey. *
* 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 * Note: under Wayland/WSLg, screen capture may require portal support; all
* failures surface as { ok: false, reason } instead of crashing. * 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 { class CaptureService {
constructor({ store, settings, getWindow, notify }) { constructor({ store, settings, getWindow, notify }) {
this.store = store; this.store = store;
this.settings = settings; this.settings = settings;
this.getWindow = getWindow; this.getWindow = getWindow;
this.notify = notify; 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() { state() {
return this.session 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) { clickCaptureAvailable() {
this.session = { guideId, paused: false, count: 0 }; 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) { togglePause(force) {
if (!this.session) return; if (!this.session) return;
this.session.paused = typeof force === 'boolean' ? force : !this.session.paused; this.session.paused = typeof force === 'boolean' ? force : !this.session.paused;
this.notify('capture:state', this.state());
} }
finishSession() { finishSession() {
if (this.intervalTimer) {
clearInterval(this.intervalTimer);
this.intervalTimer = null;
}
this.stopClickWatcher();
this.session = null; 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' }; if (!this.session || this.session.paused) return { ok: false, reason: 'no active capture session' };
if (this.shooting) return { ok: false, reason: 'capture already in progress' };
this.shooting = true;
try {
const mode = this.settings.get('capture.mode') || 'fullscreen'; const mode = this.settings.get('capture.mode') || 'fullscreen';
const result = await this.shoot({ const result = await this.shoot({
guideId: this.session.guideId, guideId: this.session.guideId,
@@ -53,9 +131,76 @@ class CaptureService {
}); });
if (result.ok) { if (result.ok) {
this.session.count += 1; this.session.count += 1;
this.notify('capture:added', { guideId: this.session.guideId, step: result.step }); this.notify('capture:added', { guideId: this.session.guideId, step: result.step, trigger });
this.notify('capture:state', this.state());
} }
return result; return result;
} finally {
this.shooting = false;
}
}
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) { autoTitle(mode) {
+33 -2
View File
@@ -98,6 +98,35 @@ function createWindow() {
} }
}, 1500); }, 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; }); mainWindow.on('closed', () => { mainWindow = null; });
} }
@@ -267,11 +296,13 @@ function setupIpc() {
if (result.ok) reindex(guideId); if (result.ok) reindex(guideId);
return result; return result;
}); });
h('capture:session', ({ action, guideId }) => { h('capture:session', async ({ action, guideId, intervalSec }) => {
if (action === 'start') capture.startSession(guideId); if (action === 'start') capture.startSession(guideId, { intervalSec: intervalSec ?? null });
else if (action === 'pause') capture.togglePause(true); else if (action === 'pause') capture.togglePause(true);
else if (action === 'resume') capture.togglePause(false); else if (action === 'resume') capture.togglePause(false);
else if (action === 'finish') capture.finishSession(); 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(); const state = capture.state();
sendToRenderer('capture:state', state); sendToRenderer('capture:state', state);
return state; return state;
+39 -10
View File
@@ -81,6 +81,7 @@ class StepForgeApp {
async onCaptureAdded(payload) { async onCaptureAdded(payload) {
if (!payload || !payload.guideId) return; if (!payload || !payload.guideId) return;
this.updateCaptureState(await api.capture.state());
if (this.state.view === 'editor' && this.editor.guideId === payload.guideId) { 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); await this.editor.reload(payload.step && payload.step.stepId ? payload.step.stepId : this.editor.selectedStepId);
return; return;
@@ -187,7 +188,13 @@ class StepForgeApp {
const state = await api.capture.session({ action: 'start', guideId: guide.guideId }); const state = await api.capture.session({ action: 'start', guideId: guide.guideId });
this.updateCaptureState(state); this.updateCaptureState(state);
const hotkey = this.state.settings?.capture?.hotkeyCapture; 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() { async openExistingWorkspace() {
@@ -229,21 +236,43 @@ class StepForgeApp {
return; return;
} }
this.captureStatus.classList.remove('hidden'); 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', { const pauseBtn = el('button', {
type: 'button', type: 'button',
onClick: () => { onClick: () => send({ action: s.paused ? 'resume' : 'pause' }),
const action = this.captureState.paused ? 'resume' : 'pause'; }, s.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');
const finishBtn = el('button', { const finishBtn = el('button', {
type: 'button', type: 'button',
onClick: () => api.capture.session({ action: 'finish', guideId: this.editorMeta?.guide?.id || this.editorMeta?.guide?.guideId || null }) onClick: () => send({ action: 'finish' }),
.then((next) => this.updateCaptureState(next)),
}, 'Finish'); }, 'Finish');
this.captureStatus.append( this.captureStatus.append(
el('span', {}, `Capture ${this.captureState.count || 0}`), el('span', { title: `Capture session — ${trigger}` }, `REC ${s.count || 0} · ${trigger}`),
shootBtn,
autoBtn,
pauseBtn, pauseBtn,
finishBtn, finishBtn,
); );
+6 -2
View File
@@ -125,8 +125,12 @@ class GuideEditor {
this.steps = steps; this.steps = steps;
this.stepMap = new Map(steps.map((step) => [step.stepId, step])); this.stepMap = new Map(steps.map((step) => [step.stepId, step]));
if (!this.shellMounted) this.mountShell(); if (!this.shellMounted) this.mountShell();
if (!this.selectedStepId || !this.stepMap.has(this.selectedStepId)) { // An explicitly requested step (new capture, added step, restored
this.selectedStepId = stepId && this.stepMap.has(stepId) ? stepId : (steps[0] && steps[0].stepId) || null; // 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.selectedAnnotationId = null;
this.renderAll(); this.renderAll();
+1
View File
@@ -18,6 +18,7 @@ const DEFAULT_SETTINGS = {
hotkeyPauseResume: 'CommandOrControl+Shift+2', hotkeyPauseResume: 'CommandOrControl+Shift+2',
captureOutsideClicks: true, captureOutsideClicks: true,
confirmSimpleCapture: false, confirmSimpleCapture: false,
autoIntervalSec: 5, // session fallback when click capture is unavailable
}, },
editor: { editor: {
focusedViewDefaultForNewSteps: false, focusedViewDefaultForNewSteps: false,