This commit is contained in:
+1
-51
@@ -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();
|
||||||
|
|||||||
@@ -672,7 +672,6 @@ if (!gotLock) {
|
|||||||
settings,
|
settings,
|
||||||
getWindow: () => mainWindow,
|
getWindow: () => mainWindow,
|
||||||
notify: sendToRenderer,
|
notify: sendToRenderer,
|
||||||
dialogApi: dialog,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
applyTheme();
|
applyTheme();
|
||||||
|
|||||||
+7
-1
@@ -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', {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user