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:
+116
-1
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user