Session UX: tray-controlled recording, no per-shot window hiding
Template tests / tests (push) Failing after 24s
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:
@@ -22,6 +22,12 @@ Initial release.
|
|||||||
click detection or global hotkeys are unavailable (e.g. WSLg/Wayland).
|
click detection or global hotkeys are unavailable (e.g. WSLg/Wayland).
|
||||||
The REC bar shows the live count and trigger, with Shoot / Auto /
|
The REC bar shows the live count and trigger, with Shoot / Auto /
|
||||||
Pause / Finish controls.
|
Pause / Finish controls.
|
||||||
|
- Recording sessions tuck the window away once and control everything
|
||||||
|
from a red tray icon (capture now / pause / open / finish) instead of
|
||||||
|
hiding the window for every shot — the app stays reachable
|
||||||
|
mid-session, opening it auto-pauses capture, and per-shot latency
|
||||||
|
drops because the hide-repaint wait is gone. Automatic captures also
|
||||||
|
stand down whenever the cursor is over a visible StepForge window.
|
||||||
- New captures and newly added steps are now selected in the editor.
|
- 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
|
||||||
|
|||||||
+116
-1
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const { spawn, execFileSync } = require('node:child_process');
|
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 { expandPlaceholders } = require('../core/placeholders');
|
||||||
|
const raster = require('../core/raster');
|
||||||
|
const { encodePng } = require('../core/png');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Capture service: full-screen, active-window, and region capture via
|
* 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();
|
if (this.settings.get('capture.captureOutsideClicks') !== false) this.startClickWatcher();
|
||||||
this.applyInterval();
|
this.applyInterval();
|
||||||
this.notify('capture:state', this.state());
|
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) {
|
setInterval(intervalSec) {
|
||||||
@@ -103,7 +183,16 @@ class CaptureService {
|
|||||||
|
|
||||||
togglePause(force) {
|
togglePause(force) {
|
||||||
if (!this.session) return;
|
if (!this.session) return;
|
||||||
|
const wasPaused = this.session.paused;
|
||||||
this.session.paused = typeof force === 'boolean' ? force : !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());
|
this.notify('capture:state', this.state());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,13 +202,38 @@ class CaptureService {
|
|||||||
this.intervalTimer = null;
|
this.intervalTimer = null;
|
||||||
}
|
}
|
||||||
this.stopClickWatcher();
|
this.stopClickWatcher();
|
||||||
|
this.destroySessionTray();
|
||||||
this.session = null;
|
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). */
|
/** One capture inside the active session (hotkey/click/interval/manual). */
|
||||||
async sessionCapture(trigger = 'hotkey') {
|
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' };
|
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;
|
this.shooting = true;
|
||||||
try {
|
try {
|
||||||
const mode = this.settings.get('capture.mode') || 'fullscreen';
|
const mode = this.settings.get('capture.mode') || 'fullscreen';
|
||||||
@@ -133,6 +247,7 @@ class CaptureService {
|
|||||||
this.session.count += 1;
|
this.session.count += 1;
|
||||||
this.notify('capture:added', { guideId: this.session.guideId, step: result.step, trigger });
|
this.notify('capture:added', { guideId: this.session.guideId, step: result.step, trigger });
|
||||||
this.notify('capture:state', this.state());
|
this.notify('capture:state', this.state());
|
||||||
|
if (this.rebuildTrayMenu) this.rebuildTrayMenu(); // refresh step counter
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -105,6 +105,10 @@ function createWindow() {
|
|||||||
try {
|
try {
|
||||||
const guide = store.createGuide({ title: 'hotkey selftest' });
|
const guide = store.createGuide({ title: 'hotkey selftest' });
|
||||||
capture.startSession(guide.guideId, { intervalSec: 0 });
|
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 = [];
|
const results = [];
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
const r = await capture.hotkeyCapture();
|
const r = await capture.hotkeyCapture();
|
||||||
@@ -486,6 +490,11 @@ if (!gotLock) {
|
|||||||
|
|
||||||
app.on('will-quit', () => {
|
app.on('will-quit', () => {
|
||||||
globalShortcut.unregisterAll();
|
globalShortcut.unregisterAll();
|
||||||
|
if (capture) {
|
||||||
|
// Targeted cleanup (not finishSession — that re-shows the window).
|
||||||
|
capture.stopClickWatcher();
|
||||||
|
capture.destroySessionTray();
|
||||||
|
}
|
||||||
// clean preview temp files on close
|
// clean preview temp files on close
|
||||||
try {
|
try {
|
||||||
for (const entry of fs.readdirSync(store.tempDir)) {
|
for (const entry of fs.readdirSync(store.tempDir)) {
|
||||||
|
|||||||
+3
-3
@@ -189,11 +189,11 @@ class StepForgeApp {
|
|||||||
this.updateCaptureState(state);
|
this.updateCaptureState(state);
|
||||||
const hotkey = this.state.settings?.capture?.hotkeyCapture;
|
const hotkey = this.state.settings?.capture?.hotkeyCapture;
|
||||||
if (state.clickCapture) {
|
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) {
|
} 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 {
|
} 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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user