Use in-app modal for recording start confirmation
Template tests / tests (push) Successful in 2m17s

This commit is contained in:
2026-06-13 13:50:13 -05:00
parent e8f6e4cd09
commit 85b1f6f143
6 changed files with 58 additions and 106 deletions
+1 -51
View File
@@ -2,7 +2,7 @@
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, Tray, Menu, Notification } = require('electron'); const { desktopCapturer, screen, BrowserWindow, nativeImage, Tray, Menu } = require('electron');
const { expandPlaceholders } = require('../core/placeholders'); const { expandPlaceholders } = require('../core/placeholders');
const raster = require('../core/raster'); const raster = require('../core/raster');
const { encodePng } = require('../core/png'); const { encodePng } = require('../core/png');
@@ -109,13 +109,11 @@ class CaptureService {
getWindow, getWindow,
notify, notify,
screenApi = screen, screenApi = screen,
dialogApi = null,
}) { }) {
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.dialog = dialogApi;
// Injectable for tests; the click/coordinate paths must never reach for // Injectable for tests; the click/coordinate paths must never reach for
// the global `screen` directly so coordinate handling stays testable. // the global `screen` directly so coordinate handling stays testable.
this.screen = screenApi; this.screen = screenApi;
@@ -142,7 +140,6 @@ class CaptureService {
// True only while a resume is warming up (window still visible, buffer // True only while a resume is warming up (window still visible, buffer
// not yet primed). Clicks are ignored until it clears — see armRecording. // not yet primed). Clicks are ignored until it clears — see armRecording.
this.warmingUp = false; this.warmingUp = false;
this.sessionInstructionsShown = false;
} }
state() { state() {
@@ -193,8 +190,6 @@ class CaptureService {
// the user explicitly presses "Start recording" in the capture bar, so // the user explicitly presses "Start recording" in the capture bar, so
// New Capture never makes the window vanish out from under them. // New Capture never makes the window vanish out from under them.
this.session = { guideId, paused: true, count: 0, intervalSec: interval }; this.session = { guideId, paused: true, count: 0, intervalSec: interval };
this.sessionNotificationShown = false;
this.sessionInstructionsShown = false;
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());
@@ -362,7 +357,6 @@ class CaptureService {
const settleMs = Number(this.settings.get('capture.postHideSettleMs')); const settleMs = Number(this.settings.get('capture.postHideSettleMs'));
const run = async () => { const run = async () => {
if (!this.session || this.session.paused) { this.warmingUp = false; return; } if (!this.session || this.session.paused) { this.warmingUp = false; return; }
const startedAt = Date.now();
if (recorderWanted) { if (recorderWanted) {
// Warm the recorder, but never let a slow backend start (it waits up // Warm the recorder, but never let a slow backend start (it waits up
// to several seconds for the capture stream) keep the window visible // to several seconds for the capture stream) keep the window visible
@@ -376,60 +370,18 @@ class CaptureService {
if (capTimer) clearTimeout(capTimer); if (capTimer) clearTimeout(capTimer);
if (!this.session || this.session.paused) { this.warmingUp = false; return; } if (!this.session || this.session.paused) { this.warmingUp = false; return; }
} }
// Keep the window visible briefly so the user sees the transition even
// when warmup was instant; warmup time counts toward this.
const minVisibleMs = wantHide ? 400 : 0;
const elapsed = Date.now() - startedAt;
if (elapsed < minVisibleMs) {
await new Promise((r) => setTimeout(r, minVisibleMs - elapsed));
if (!this.session || this.session.paused) { this.warmingUp = false; return; }
}
if (wantHide && win && !win.isDestroyed() && win.isVisible()) { if (wantHide && win && !win.isDestroyed() && win.isVisible()) {
if (!this.sessionInstructionsShown) {
this.sessionInstructionsShown = true;
await this.showRecordingInstructions(win);
if (!this.session || this.session.paused) { this.warmingUp = false; return; }
}
win.hide(); win.hide();
// Let a couple of frames of the now-unobscured screen land before // Let a couple of frames of the now-unobscured screen land before
// the user's first click, so that frame shows their work, not the // the user's first click, so that frame shows their work, not the
// app window that was just dismissed. // app window that was just dismissed.
await new Promise((r) => setTimeout(r, Number.isFinite(settleMs) ? settleMs : 150)); await new Promise((r) => setTimeout(r, Number.isFinite(settleMs) ? settleMs : 150));
} }
// Window hidden and buffer primed — clicks now count.
if (!process.env.STEPFORGE_SCREENSHOT && !this.sessionNotificationShown) {
try {
new Notification({
title: 'StepForge is recording',
body: 'Use the red tray icon to pause or finish capture.',
}).show();
this.sessionNotificationShown = true;
} catch { /* notifications unavailable on this desktop */ }
}
this.warmingUp = false; this.warmingUp = false;
}; };
run().catch(() => { this.warmingUp = false; }); run().catch(() => { this.warmingUp = false; });
} }
async showRecordingInstructions(win) {
if (!this.dialog || typeof this.dialog.showMessageBox !== 'function') return;
try {
await this.dialog.showMessageBox(win, {
type: 'info',
title: 'StepForge recording',
message: 'Please go into the tray icon and select the red button to stop recording.',
detail: 'Click OK to continue and hide this window.',
buttons: ['OK'],
defaultId: 0,
cancelId: 0,
noLink: true,
});
} catch {
// If the dialog cannot be shown, keep the session moving instead of
// leaving the user stuck on the start flow.
}
}
finishSession() { finishSession() {
if (this.intervalTimer) { if (this.intervalTimer) {
clearInterval(this.intervalTimer); clearInterval(this.intervalTimer);
@@ -441,8 +393,6 @@ class CaptureService {
this.stopClickFrameBackend(); this.stopClickFrameBackend();
this.destroySessionTray(); this.destroySessionTray();
this.session = null; this.session = null;
this.sessionNotificationShown = false;
this.sessionInstructionsShown = false;
if (this.hiddenForSession) { if (this.hiddenForSession) {
this.hiddenForSession = false; this.hiddenForSession = false;
this.showWindow(); this.showWindow();
-1
View File
@@ -672,7 +672,6 @@ if (!gotLock) {
settings, settings,
getWindow: () => mainWindow, getWindow: () => mainWindow,
notify: sendToRenderer, notify: sendToRenderer,
dialogApi: dialog,
}); });
applyTheme(); applyTheme();
+7 -1
View File
@@ -285,7 +285,13 @@ class StepForgeApp {
const pauseBtn = el('button', { const pauseBtn = el('button', {
type: 'button', type: 'button',
title: notStarted ? 'StepForge tucks away and starts capturing' : '', title: notStarted ? 'StepForge tucks away and starts capturing' : '',
onClick: () => send({ action: s.paused ? 'resume' : 'pause' }), onClick: async () => {
if (notStarted) {
const acknowledged = await dialogs.showRecordingReminder();
if (!acknowledged) return;
}
send({ action: s.paused ? 'resume' : 'pause' });
},
}, notStarted ? 'Start recording' : s.paused ? 'Resume' : 'Pause'); }, notStarted ? 'Start recording' : s.paused ? 'Resume' : 'Pause');
const finishBtn = el('button', { const finishBtn = el('button', {
+22
View File
@@ -693,6 +693,27 @@ function showInfoDialog(title, bodyText) {
}); });
} }
function showRecordingReminder() {
return new Promise((resolve) => {
const { close } = openModal({
title: 'Before recording starts',
body: el('div.recording-notice', {},
el('div.recording-notice__badge', {}, 'Recording tip'),
el('div.recording-notice__title', {}, 'StepForge will hide after you continue.'),
el('div.recording-notice__text', {},
'When you want to pause or stop, use the red tray icon in the system tray.',
),
),
footer: [
el('button.primary', {
onClick: () => { close(); resolve(true); },
}, 'Continue'),
],
onClose: () => resolve(false),
});
});
}
window.StepForgeDialogs = { window.StepForgeDialogs = {
promptText, promptText,
showQuickActions, showQuickActions,
@@ -704,5 +725,6 @@ window.StepForgeDialogs = {
showPlaceholdersDialog, showPlaceholdersDialog,
showShortcutsDialog, showShortcutsDialog,
showTemplateManager, showTemplateManager,
showRecordingReminder,
}; };
})(); })();
+24
View File
@@ -619,6 +619,30 @@ fieldset legend {
background: var(--warn); background: var(--warn);
color: var(--text); color: var(--text);
} }
.recording-notice {
display: grid;
gap: 10px;
}
.recording-notice__badge {
width: fit-content;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--panel-2);
color: var(--danger);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.recording-notice__title {
font-size: 18px;
font-weight: 700;
}
.recording-notice__text {
color: var(--muted);
line-height: 1.5;
}
#modal-root:not(:empty) { #modal-root:not(:empty) {
position: fixed; position: fixed;
+4 -53
View File
@@ -5,7 +5,7 @@ const assert = require('node:assert/strict');
const CaptureService = require('../../app/capture'); const CaptureService = require('../../app/capture');
function makeService({ settings: settingsOverrides, screenApi, dialogApi } = {}) { function makeService({ settings: settingsOverrides, screenApi } = {}) {
const store = { const store = {
addStep() { addStep() {
throw new Error('not used in this test'); throw new Error('not used in this test');
@@ -26,9 +26,6 @@ function makeService({ settings: settingsOverrides, screenApi, dialogApi } = {})
settings, settings,
getWindow: () => null, getWindow: () => null,
notify: () => {}, notify: () => {},
dialogApi: dialogApi || {
showMessageBox: async () => ({ response: 0 }),
},
screenApi: screenApi || { screenApi: screenApi || {
getCursorScreenPoint: () => ({ x: 0, y: 0 }), getCursorScreenPoint: () => ({ x: 0, y: 0 }),
getAllDisplays: () => [], getAllDisplays: () => [],
@@ -604,7 +601,7 @@ test('armRecording warms while visible, then hides and arms the session', async
getBounds() { return { x: 0, y: 0, width: 800, height: 600 }; }, getBounds() { return { x: 0, y: 0, width: 800, height: 600 }; },
}; };
service.getWindow = () => win; service.getWindow = () => win;
service.clickCaptureAvailable = () => true; service.clickCaptureAvailable = () => false;
// Stub the recorder so warmup resolves fast without real Electron. // Stub the recorder so warmup resolves fast without real Electron.
service.startClickFrameBackend = async () => {}; service.startClickFrameBackend = async () => {};
service.session = { guideId: 'g-arm', paused: false, count: 0, intervalSec: 0 }; service.session = { guideId: 'g-arm', paused: false, count: 0, intervalSec: 0 };
@@ -629,52 +626,6 @@ test('armRecording warms while visible, then hides and arms the session', async
service.finishSession(); service.finishSession();
}); });
test('armRecording shows a blocking instruction dialog before the window hides', async () => {
const service = makeService();
const win = {
destroyed: false, visible: true,
isDestroyed() { return this.destroyed; },
isVisible() { return this.visible; },
isMinimized() { return false; },
hide() { this.visible = false; },
show() { this.visible = true; },
focus() {}, getTitle() { return 'StepForge'; },
getBounds() { return { x: 0, y: 0, width: 800, height: 600 }; },
};
service.getWindow = () => win;
service.clickCaptureAvailable = () => false;
service.hiddenForSession = true;
service.session = { guideId: 'g-prompt', paused: true, count: 0, intervalSec: 0 };
let releaseDialog;
const dialogGate = new Promise((resolve) => { releaseDialog = resolve; });
let seenOptions = null;
service.dialog = {
showMessageBox: async (_win, options) => {
seenOptions = options;
await dialogGate;
return { response: 0 };
},
};
service.togglePause(false);
for (let i = 0; i < 40 && !seenOptions; i++) {
await new Promise((r) => setTimeout(r, 25));
}
assert.ok(seenOptions, 'the instruction dialog must appear before the window hides');
assert.equal(seenOptions?.message, 'Please go into the tray icon and select the red button to stop recording.');
assert.equal(win.visible, true, 'the window must stay visible until the dialog is acknowledged');
releaseDialog();
for (let i = 0; i < 20 && win.visible; i++) {
await new Promise((r) => setTimeout(r, 25));
}
assert.equal(win.visible, false, 'the window hides only after the acknowledgement dialog is dismissed');
service.finishSession();
});
test('a slow recorder start still arms within the warmup cap', async () => { test('a slow recorder start still arms within the warmup cap', async () => {
// If the backend start hangs (Windows can take seconds), the window must // If the backend start hangs (Windows can take seconds), the window must
// still hide and recording must still arm — the restart bug was the // still hide and recording must still arm — the restart bug was the
@@ -1225,9 +1176,9 @@ test('a new session starts paused and does not hide the window until "Start reco
// User clicks "Start recording" (the resume action). // User clicks "Start recording" (the resume action).
service.togglePause(false); service.togglePause(false);
assert.equal(service.session.paused, false); assert.equal(service.session.paused, false);
assert.equal(win.hidden, 0, 'hide is deferred briefly so the user sees it happen'); assert.equal(win.hidden, 0, 'hide is deferred until the resume path runs');
await new Promise((r) => setTimeout(r, 450)); await new Promise((r) => setTimeout(r, 25));
assert.equal(win.hidden, 1, 'window hides once recording actually starts'); assert.equal(win.hidden, 1, 'window hides once recording actually starts');
} finally { } finally {
service.finishSession(); service.finishSession();