diff --git a/app/capture.js b/app/capture.js index acf138d..b380094 100644 --- a/app/capture.js +++ b/app/capture.js @@ -2,7 +2,7 @@ const path = require('node:path'); 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 raster = require('../core/raster'); const { encodePng } = require('../core/png'); @@ -109,13 +109,11 @@ class CaptureService { 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; @@ -142,7 +140,6 @@ 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() { @@ -193,8 +190,6 @@ class CaptureService { // the user explicitly presses "Start recording" in the capture bar, so // 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()); @@ -362,7 +357,6 @@ class CaptureService { const settleMs = Number(this.settings.get('capture.postHideSettleMs')); const run = async () => { if (!this.session || this.session.paused) { this.warmingUp = false; return; } - const startedAt = Date.now(); if (recorderWanted) { // Warm the recorder, but never let a slow backend start (it waits up // to several seconds for the capture stream) keep the window visible @@ -376,60 +370,18 @@ class CaptureService { if (capTimer) clearTimeout(capTimer); 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 (!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 // app window that was just dismissed. 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; }; 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); @@ -441,8 +393,6 @@ class CaptureService { this.stopClickFrameBackend(); 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 f8ff1c5..f929f09 100644 --- a/app/main.js +++ b/app/main.js @@ -672,7 +672,6 @@ if (!gotLock) { settings, getWindow: () => mainWindow, notify: sendToRenderer, - dialogApi: dialog, }); applyTheme(); diff --git a/app/renderer/app.js b/app/renderer/app.js index de97e3b..103c3c7 100644 --- a/app/renderer/app.js +++ b/app/renderer/app.js @@ -285,7 +285,13 @@ class StepForgeApp { const pauseBtn = el('button', { type: 'button', 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'); const finishBtn = el('button', { diff --git a/app/renderer/dialogs.js b/app/renderer/dialogs.js index f037f5d..4bb4fe7 100644 --- a/app/renderer/dialogs.js +++ b/app/renderer/dialogs.js @@ -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 = { promptText, showQuickActions, @@ -704,5 +725,6 @@ window.StepForgeDialogs = { showPlaceholdersDialog, showShortcutsDialog, showTemplateManager, + showRecordingReminder, }; })(); diff --git a/app/renderer/style.css b/app/renderer/style.css index 04a5457..7d8febd 100644 --- a/app/renderer/style.css +++ b/app/renderer/style.css @@ -619,6 +619,30 @@ fieldset legend { background: var(--warn); 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) { position: fixed; diff --git a/tests/unit/capture.test.js b/tests/unit/capture.test.js index 52c5b4d..51323ca 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, dialogApi } = {}) { +function makeService({ settings: settingsOverrides, screenApi } = {}) { const store = { addStep() { throw new Error('not used in this test'); @@ -26,9 +26,6 @@ function makeService({ settings: settingsOverrides, screenApi, dialogApi } = {}) settings, getWindow: () => null, notify: () => {}, - dialogApi: dialogApi || { - showMessageBox: async () => ({ response: 0 }), - }, screenApi: screenApi || { getCursorScreenPoint: () => ({ x: 0, y: 0 }), 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 }; }, }; service.getWindow = () => win; - service.clickCaptureAvailable = () => true; + service.clickCaptureAvailable = () => false; // Stub the recorder so warmup resolves fast without real Electron. service.startClickFrameBackend = async () => {}; 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(); }); -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 @@ -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). service.togglePause(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'); } finally { service.finishSession();