Session UX: tray-controlled recording, no per-shot window hiding
Template tests / tests (push) Failing after 24s

Users couldn't click into the app mid-session: every automatic capture
hid the window for the shot, so it vanished under the cursor. Under
WSLg minimize() is a no-op and isFocused() sticks true, so neither can
be used for control.

- Sessions now hide the window once at start and show a red tray icon
  with Capture now / Pause-Resume / Open StepForge (auto-pauses) /
  Finish; finishing or quitting restores/cleans up properly
- Opening the app from the tray pauses capture; resuming tucks the
  window away again
- Automatic captures skip while the cursor is over a visible StepForge
  window (cursor-based, not focus-based, due to WSLg sticky focus)
- Per-shot latency reduced: with the window already hidden the 350 ms
  hide-repaint wait is skipped entirely
- OS notification announces the session; self-tests updated and green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Iisyourdad
2026-06-10 22:51:01 -05:00
parent 52fd516a5d
commit 25bca7c3de
4 changed files with 134 additions and 4 deletions
+116 -1
View File
@@ -2,8 +2,10 @@
const path = require('node:path');
const { spawn, execFileSync } = require('node:child_process');
const { desktopCapturer, screen, BrowserWindow, nativeImage } = require('electron');
const { desktopCapturer, screen, BrowserWindow, nativeImage, Tray, Menu, Notification } = require('electron');
const { expandPlaceholders } = require('../core/placeholders');
const raster = require('../core/raster');
const { encodePng } = require('../core/png');
/**
* Capture service: full-screen, active-window, and region capture via
@@ -78,6 +80,84 @@ class CaptureService {
if (this.settings.get('capture.captureOutsideClicks') !== false) this.startClickWatcher();
this.applyInterval();
this.notify('capture:state', this.state());
// Tuck the app away once instead of hiding it for every shot — the
// hide/show flicker made the window impossible to click mid-session.
// A tray icon controls the session while the window is hidden.
// (Skipped for the dev screenshot hook, which needs a visible page.)
if (!process.env.STEPFORGE_SCREENSHOT) {
this.createSessionTray();
const win = this.getWindow();
if (win && !win.isDestroyed() && win.isVisible()) {
this.hiddenForSession = true;
setTimeout(() => {
// Re-check: the session may have been finished within the delay.
if (this.session && this.hiddenForSession && !win.isDestroyed()) win.hide();
}, 1200); // let the user read the "session started" toast first
}
try {
new Notification({
title: 'StepForge is capturing',
body: 'The window tucks away while recording. Use the red tray icon to pause, capture, or finish.',
}).show();
} catch { /* notifications unavailable on this desktop */ }
}
}
/** Red-dot tray icon with session controls, shown while recording. */
createSessionTray() {
this.destroySessionTray();
try {
const img = raster.createImage(16, 16, [0, 0, 0, 0]);
raster.fillOval(img, 2, 2, 12, 12, [229, 72, 77, 255]);
this.tray = new Tray(nativeImage.createFromBuffer(encodePng(img)));
this.tray.setToolTip('StepForge — capture session running');
const rebuild = () => {
if (!this.tray || this.tray.isDestroyed()) return;
this.tray.setContextMenu(Menu.buildFromTemplate([
{ label: `Captured ${this.session ? this.session.count : 0} steps`, enabled: false },
{ type: 'separator' },
{ label: 'Capture now', click: () => this.sessionCapture('manual').then(rebuild).catch(() => {}) },
{
label: this.session && this.session.paused ? 'Resume capturing' : 'Pause capturing',
click: () => { this.togglePause(); rebuild(); },
},
{
label: 'Open StepForge (pauses capture)',
click: () => {
this.togglePause(true);
this.showWindow();
rebuild();
},
},
{ type: 'separator' },
{ label: 'Finish session', click: () => this.finishSession() },
]));
};
rebuild();
this.rebuildTrayMenu = rebuild;
this.tray.on('click', () => {
this.togglePause(true);
this.showWindow();
rebuild();
});
} catch {
this.tray = null; // no tray on this desktop; cursor-over skip still protects clicks
}
}
destroySessionTray() {
if (this.tray && !this.tray.isDestroyed()) this.tray.destroy();
this.tray = null;
this.rebuildTrayMenu = null;
}
showWindow() {
const win = this.getWindow();
if (win && !win.isDestroyed()) {
win.show();
win.focus();
}
}
setInterval(intervalSec) {
@@ -103,7 +183,16 @@ class CaptureService {
togglePause(force) {
if (!this.session) return;
const wasPaused = this.session.paused;
this.session.paused = typeof force === 'boolean' ? force : !this.session.paused;
// Resuming from the app tucks the window away again for clean shots.
if (wasPaused && !this.session.paused && this.hiddenForSession) {
const win = this.getWindow();
if (win && !win.isDestroyed()) setTimeout(() => {
if (this.session && !this.session.paused && !win.isDestroyed()) win.hide();
}, 400);
}
if (this.rebuildTrayMenu) this.rebuildTrayMenu();
this.notify('capture:state', this.state());
}
@@ -113,13 +202,38 @@ class CaptureService {
this.intervalTimer = null;
}
this.stopClickWatcher();
this.destroySessionTray();
this.session = null;
if (this.hiddenForSession) {
this.hiddenForSession = false;
this.showWindow();
}
this.notify('capture:state', this.state());
}
/**
* True when the user is interacting with StepForge itself. Deliberately
* based on cursor position over the visible window, not isFocused():
* some compositors (WSLg) report focus as stuck-true, which would block
* every automatic capture forever.
*/
userIsInApp() {
const win = this.getWindow();
if (!win || win.isDestroyed() || !win.isVisible() || win.isMinimized()) return false;
const cur = screen.getCursorScreenPoint();
const b = win.getBounds();
return cur.x >= b.x && cur.x <= b.x + b.width && cur.y >= b.y && cur.y <= b.y + b.height;
}
/** 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.shooting) return { ok: false, reason: 'capture already in progress' };
// Automatic triggers stand down while the user is in StepForge, so the
// app stays clickable mid-session and never screenshots itself.
if (trigger !== 'manual' && this.userIsInApp()) {
return { ok: false, reason: 'skipped — StepForge is focused' };
}
this.shooting = true;
try {
const mode = this.settings.get('capture.mode') || 'fullscreen';
@@ -133,6 +247,7 @@ class CaptureService {
this.session.count += 1;
this.notify('capture:added', { guideId: this.session.guideId, step: result.step, trigger });
this.notify('capture:state', this.state());
if (this.rebuildTrayMenu) this.rebuildTrayMenu(); // refresh step counter
}
return result;
} finally {
+9
View File
@@ -105,6 +105,10 @@ function createWindow() {
try {
const guide = store.createGuide({ title: 'hotkey selftest' });
capture.startSession(guide.guideId, { intervalSec: 0 });
// Sessions hide the window while recording; do it immediately here
// instead of waiting out the toast-grace delay.
mainWindow.hide();
await new Promise((res) => setTimeout(res, 400));
const results = [];
for (let i = 0; i < 3; i++) {
const r = await capture.hotkeyCapture();
@@ -486,6 +490,11 @@ if (!gotLock) {
app.on('will-quit', () => {
globalShortcut.unregisterAll();
if (capture) {
// Targeted cleanup (not finishSession — that re-shows the window).
capture.stopClickWatcher();
capture.destroySessionTray();
}
// clean preview temp files on close
try {
for (const entry of fs.readdirSync(store.tempDir)) {
+3 -3
View File
@@ -189,11 +189,11 @@ class StepForgeApp {
this.updateCaptureState(state);
const hotkey = this.state.settings?.capture?.hotkeyCapture;
if (state.clickCapture) {
toast('Capture session started — every click outside StepForge grabs a step.');
toast('Recording — every click grabs a step. StepForge tucks away; use the red tray icon to pause or finish.');
} else if (state.intervalSec > 0) {
toast(`Capture session started — auto-capturing every ${state.intervalSec}s (use the REC bar to pause or change).`);
toast(`Recording — a step every ${state.intervalSec}s. StepForge tucks away; use the red tray icon to pause or finish.`);
} else {
toast(hotkey ? `Capture session started — press ${hotkey} or use Shoot in the REC bar.` : 'Capture session started.');
toast(hotkey ? `Recording — press ${hotkey} to grab steps. Use the red tray icon to pause or finish.` : 'Capture session started.');
}
}