From e8f6e4cd094f612861ca03666e56e7110df381e0 Mon Sep 17 00:00:00 2001 From: Iisyourdad Date: Fri, 12 Jun 2026 13:53:16 -0500 Subject: [PATCH] Require recording acknowledgment before hide --- app/capture.js | 37 ++++++++++++++++++++++++++- app/main.js | 1 + tests/unit/capture.test.js | 51 +++++++++++++++++++++++++++++++++++++- 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/app/capture.js b/app/capture.js index fb5e293..acf138d 100644 --- a/app/capture.js +++ b/app/capture.js @@ -103,11 +103,19 @@ function hasBinary(name) { } class CaptureService { - constructor({ store, settings, getWindow, notify, screenApi = screen }) { + constructor({ + store, + settings, + getWindow, + notify, + screenApi = screen, + dialogApi = null, + }) { this.store = store; this.settings = settings; this.getWindow = getWindow; this.notify = notify; + this.dialog = dialogApi; // Injectable for tests; the click/coordinate paths must never reach for // the global `screen` directly so coordinate handling stays testable. this.screen = screenApi; @@ -134,6 +142,7 @@ class CaptureService { // True only while a resume is warming up (window still visible, buffer // not yet primed). Clicks are ignored until it clears — see armRecording. this.warmingUp = false; + this.sessionInstructionsShown = false; } state() { @@ -185,6 +194,7 @@ class CaptureService { // New Capture never makes the window vanish out from under them. this.session = { guideId, paused: true, count: 0, intervalSec: interval }; this.sessionNotificationShown = false; + this.sessionInstructionsShown = false; if (this.settings.get('capture.captureOutsideClicks') !== false) this.startClickWatcher(); this.applyInterval(); this.notify('capture:state', this.state()); @@ -375,6 +385,11 @@ class CaptureService { if (!this.session || this.session.paused) { this.warmingUp = false; return; } } 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(); // 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 @@ -396,6 +411,25 @@ class CaptureService { 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() { if (this.intervalTimer) { clearInterval(this.intervalTimer); @@ -408,6 +442,7 @@ class CaptureService { this.destroySessionTray(); this.session = null; this.sessionNotificationShown = false; + this.sessionInstructionsShown = false; if (this.hiddenForSession) { this.hiddenForSession = false; this.showWindow(); diff --git a/app/main.js b/app/main.js index f929f09..f8ff1c5 100644 --- a/app/main.js +++ b/app/main.js @@ -672,6 +672,7 @@ if (!gotLock) { settings, getWindow: () => mainWindow, notify: sendToRenderer, + dialogApi: dialog, }); applyTheme(); diff --git a/tests/unit/capture.test.js b/tests/unit/capture.test.js index 8ec842b..52c5b4d 100644 --- a/tests/unit/capture.test.js +++ b/tests/unit/capture.test.js @@ -5,7 +5,7 @@ const assert = require('node:assert/strict'); const CaptureService = require('../../app/capture'); -function makeService({ settings: settingsOverrides, screenApi } = {}) { +function makeService({ settings: settingsOverrides, screenApi, dialogApi } = {}) { const store = { addStep() { throw new Error('not used in this test'); @@ -26,6 +26,9 @@ function makeService({ settings: settingsOverrides, screenApi } = {}) { settings, getWindow: () => null, notify: () => {}, + dialogApi: dialogApi || { + showMessageBox: async () => ({ response: 0 }), + }, screenApi: screenApi || { getCursorScreenPoint: () => ({ x: 0, y: 0 }), getAllDisplays: () => [], @@ -626,6 +629,52 @@ test('armRecording warms while visible, then hides and arms the session', async 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 () => { // If the backend start hangs (Windows can take seconds), the window must // still hide and recording must still arm — the restart bug was the