Files
Iisyourdad f2c5831315
Template tests / tests (push) Waiting to run
Template tests / tests (pull_request) Waiting to run
Fix restarted recordings dropping clicks / stopping after one click
Root cause: warm-before-hide kept the window visible during backend warmup,
and on a restart that warmup could take several seconds (the stream backend
start waits up to 8s). During that visible window, clicks over the app were
skipped by the userIsInApp guard and clicks elsewhere were shot post-click,
so a restarted session looked like it stopped after one click.

- Recording is now 'armed' only after the window is hidden and the buffer is
  primed. A new warmingUp flag makes onOsClick ignore clicks during warmup
  (the window is covering the user's work anyway) instead of mishandling
  them. Cleared on pause/finish.
- armRecording caps the warmup wait (WARMUP_MAX_MS=1500): the window hides
  and the session arms even if the backend start hangs, so it can never sit
  visible for seconds dropping clicks. The backend keeps coming up in the
  background; the first click or two may take the fresh-shot fallback.
- A generation token invalidates an in-flight backend start whose session
  has since finished, so a slow start can't install into a new session or
  leave the starting-guard stuck and block the restart from starting one.

Tests: 4 new behavioral capture tests (warmup ignores clicks; pause/finish
clear it; armRecording warms-then-hides-then-arms; a hung start still arms
within the cap; a stale start is discarded and frees the guard) plus a new
end-to-end self-test scenario (warmup click ignored, first armed click
captured). 152 unit tests + all repo checks pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:17:59 -05:00

705 lines
29 KiB
JavaScript

'use strict';
const path = require('node:path');
const fs = require('node:fs');
const os = require('node:os');
const {
app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, globalShortcut,
clipboard, nativeImage, screen,
} = require('electron');
const { GuideStore } = require('../core/store');
const { Settings } = require('../core/settings');
const { SearchIndex } = require('../core/search');
const { TemplateManager, FORMATS } = require('../core/templates');
const { buildRenderAst } = require('../core/renderast');
const { runExport, EXPORTERS } = require('../exporters');
const { exportGuideArchive, importGuideArchive, saveLinkedGuide, readArchive } = require('../core/archive');
const { createSnapshot, listSnapshots, restoreSnapshot } = require('../core/snapshots');
const { readLock } = require('../core/locks');
const CaptureService = require('./capture');
/**
* StepForge main process. Zero network code: no telemetry, no updates, no
* remote anything. The renderer is sandboxed; everything below is the full
* privileged surface.
*/
function resolveDataDir() {
if (process.env.STEPFORGE_DATA_DIR) return process.env.STEPFORGE_DATA_DIR;
if (process.platform === 'win32') {
return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'stepforge');
}
const xdg = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
return path.join(xdg, 'stepforge');
}
let store;
let settings;
let searchIndex;
let templates;
let capture;
let mainWindow;
function reindex(guideId) {
try {
searchIndex.indexGuide(store.getGuide(guideId), store.listSteps(guideId));
} catch {
// index failures must never block saves
}
}
function orderedSteps(guideId) {
const guide = store.getGuide(guideId);
const steps = store.listSteps(guideId);
return guide.stepsOrder.map((id) => steps.get(id)).filter(Boolean);
}
function applyTheme() {
nativeTheme.themeSource = settings.get('appearance') || 'system';
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1280,
height: 820,
minWidth: 880,
minHeight: 560,
show: false,
autoHideMenuBar: true,
backgroundColor: nativeTheme.shouldUseDarkColors ? '#111827' : '#ffffff',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
spellcheck: Boolean(settings.get('spellcheck')),
},
});
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
mainWindow.once('ready-to-show', () => {
mainWindow.show();
// Dev-only verification hook: optionally navigate, then write a
// screenshot locally and exit. Used by the smoke tooling.
if (process.env.STEPFORGE_SCREENSHOT) {
const target = process.env.STEPFORGE_SCREENSHOT;
const navigate = process.env.STEPFORGE_SCREENSHOT_JS || '';
setTimeout(async () => {
try {
if (navigate) {
await mainWindow.webContents.executeJavaScript(navigate, true);
await new Promise((r) => setTimeout(r, 900));
}
const image = await mainWindow.webContents.capturePage();
fs.writeFileSync(target, image.toPNG());
} catch (err) {
console.error('screenshot failed:', err.message);
} finally {
app.quit();
}
}, 1500);
}
// Dev-only self-test: exercise the full click-capture pipeline — resume
// session, wait for the frame recorder, inject OS-level clicks the way
// the watcher would, and verify one stored step per click.
if (process.env.STEPFORGE_CLICK_SELFTEST) {
setTimeout(async () => {
try {
// The marker/drain scenarios inject clicks faster than the default
// debounce to stress the frame pipeline; turn the debounce off for
// them so every injected click is captured. A dedicated scenario
// at the end re-enables it and verifies the debounce itself.
settings.set('capture.clickDebounceMs', 0);
const guide = store.createGuide({ title: 'click selftest' });
capture.startSession(guide.guideId, { intervalSec: 0 });
// Isolate the test from the user's real mouse: the session starts
// the live OS click watcher, and a stray real click (dismissing
// the toast, focusing the terminal) would add an extra step and
// shift every marker comparison below.
capture.stopClickWatcher();
capture.togglePause(false);
mainWindow.hide();
// Arm the frame recorder directly: this host may lack the click
// watcher binary (xinput), which normally gates the recorder, but
// the recorder itself must still be testable end to end.
await capture.startClickFrameBackend();
// Let the stream backend (or the fallback loop) come up and buffer.
await new Promise((res) => setTimeout(res, 3000));
console.log('CLICK-SELFTEST source:', capture.state().clickFrameSource);
// Targets are chosen in DIP; the OS hook reports *physical* pixels,
// so convert before injecting (identity on unscaled displays).
const { bounds } = screen.getPrimaryDisplay();
const dipTargets = [
{ x: Math.round(bounds.x + bounds.width * 0.2), y: Math.round(bounds.y + bounds.height * 0.2) },
{ x: Math.round(bounds.x + bounds.width * 0.5), y: Math.round(bounds.y + bounds.height * 0.5) },
{ x: Math.round(bounds.x + bounds.width * 0.8), y: Math.round(bounds.y + bounds.height * 0.8) },
];
const toPhysical = (p) => (typeof screen.dipToScreenPoint === 'function'
? screen.dipToScreenPoint(p)
: p);
for (const point of dipTargets) {
capture.onOsClick(Date.now(), toPhysical(point), 'button-1');
await new Promise((res) => setTimeout(res, 120)); // fast clicking
}
// Wait for the queue to drain (encodes can take seconds on WSLg).
await capture.clickQueue;
await new Promise((res) => setTimeout(res, 500));
const stepIds = store.getGuide(guide.guideId).stepsOrder;
const steps = store.listSteps(guide.guideId);
const markers = stepIds.map((id) => (steps.get(id).annotations || []).length);
console.log('CLICK-SELFTEST steps:', stepIds.length, 'of', dipTargets.length,
'markers:', JSON.stringify(markers));
if (stepIds.length !== dipTargets.length) {
console.log('CLICK-SELFTEST step count mismatch — marker offsets below are unreliable');
}
// Marker accuracy: each oval's center (fractional) must match the
// injected click position relative to the display bounds.
stepIds.forEach((id, i) => {
const a = (steps.get(id).annotations || [])[0];
const expectedClick = dipTargets[i];
if (!a || !expectedClick) return;
const center = { x: a.x + a.w / 2, y: a.y + a.h / 2 };
const expected = {
x: (expectedClick.x - bounds.x) / bounds.width,
y: (expectedClick.y - bounds.y) / bounds.height,
};
const offBy = Math.hypot(center.x - expected.x, center.y - expected.y);
console.log(`CLICK-SELFTEST marker ${i}: off by ${(offBy * 100).toFixed(2)}% of screen`);
});
capture.finishSession();
// Second scenario, reproducing the "I clicked many times but only
// got two screenshots" report: a fast burst of clicks immediately
// followed by finishing the session, so most clicks are still
// queued (frames still encoding) when the stop lands.
const burstGuide = store.createGuide({ title: 'burst selftest' });
capture.startSession(burstGuide.guideId, { intervalSec: 0 });
capture.stopClickWatcher();
capture.togglePause(false);
mainWindow.hide();
await capture.startClickFrameBackend();
await new Promise((res) => setTimeout(res, 1500));
const burstCount = 8;
for (let i = 0; i < burstCount; i++) {
const p = {
x: Math.round(bounds.x + bounds.width * (0.15 + 0.08 * i)),
y: Math.round(bounds.y + bounds.height * 0.5),
};
capture.onOsClick(Date.now(), toPhysical(p), 'button-1');
await new Promise((res) => setTimeout(res, 30)); // very fast clicking
}
// Finish right away — clicks are still mid-encode in the queue.
capture.finishSession();
await capture.clickQueue;
await new Promise((res) => setTimeout(res, 1000));
const burstSteps = store.getGuide(burstGuide.guideId).stepsOrder.length;
console.log('CLICK-SELFTEST burst:', burstSteps, 'of', burstCount,
burstSteps === burstCount ? 'OK — no clicks dropped on finish' : 'FAIL — clicks lost');
// Helper: wait until armRecording has finished warming (window
// hidden, buffer primed) so an injected click counts as a real
// recording click rather than being ignored as a warmup click.
const waitArmed = async () => {
for (let i = 0; i < 80 && capture.warmingUp; i++) {
await new Promise((res) => setTimeout(res, 50));
}
};
// Third scenario: the real "Start recording" path. armRecording
// warms the recorder while the window is visible and only arms the
// session once it hides; the first click *after* arming must get a
// pre-click frame (not the post-click shot that made "the first
// screenshot late"), and a click *during* warmup must be ignored,
// not mishandled. (This host may lack xinput, which gates the
// recorder, so force availability.)
const armGuide = store.createGuide({ title: 'arm selftest' });
mainWindow.show();
await new Promise((res) => setTimeout(res, 300));
capture.startSession(armGuide.guideId, { intervalSec: 0 });
capture.stopClickWatcher();
capture.clickCaptureAvailable = () => true;
capture.hiddenForSession = true; // window was visible at session start
capture.togglePause(false); // armRecording: warm → hide → arm
// A click during warmup must be ignored (window still visible).
await new Promise((res) => setTimeout(res, 200));
const warmupClicks = store.getGuide(armGuide.guideId).stepsOrder.length;
capture.onOsClick(Date.now(), toPhysical({ x: bounds.x + 100, y: bounds.y + 100 }), 'button-1');
await waitArmed();
const armPoint = {
x: Math.round(bounds.x + bounds.width * 0.4),
y: Math.round(bounds.y + bounds.height * 0.4),
};
capture.onOsClick(Date.now(), toPhysical(armPoint), 'button-1');
await capture.clickQueue;
await new Promise((res) => setTimeout(res, 800));
const armSteps = store.getGuide(armGuide.guideId).stepsOrder.length;
console.log('CLICK-SELFTEST arm: warmup-click steps', warmupClicks,
'-> after-arm steps', armSteps,
armSteps === 1 ? 'OK — warmup click ignored, first armed click captured' : 'FAIL');
capture.finishSession();
// Fourth scenario: the debounce itself, exercised end to end through
// onOsClick. A fast burst (40ms apart) must collapse to one step,
// and deliberate clicks (300ms apart) must each register.
settings.set('capture.clickDebounceMs', 200);
const dbGuide = store.createGuide({ title: 'debounce selftest' });
mainWindow.show();
await new Promise((res) => setTimeout(res, 200));
capture.startSession(dbGuide.guideId, { intervalSec: 0 });
capture.stopClickWatcher();
capture.clickCaptureAvailable = () => true;
capture.hiddenForSession = true;
capture.togglePause(false);
await capture.startClickFrameBackend();
await waitArmed();
await new Promise((res) => setTimeout(res, 300));
const dbPoint = {
x: Math.round(bounds.x + bounds.width * 0.55),
y: Math.round(bounds.y + bounds.height * 0.55),
};
// 4 clicks 40ms apart — accidental fast clicking → expect 1 step.
for (let i = 0; i < 4; i++) {
capture.onOsClick(Date.now(), toPhysical(dbPoint), 'button-1');
await new Promise((res) => setTimeout(res, 40));
}
// 3 deliberate clicks 300ms apart → expect 3 more steps.
for (let i = 0; i < 3; i++) {
await new Promise((res) => setTimeout(res, 300));
capture.onOsClick(Date.now(), toPhysical(dbPoint), 'button-1');
}
await capture.clickQueue;
await new Promise((res) => setTimeout(res, 800));
const dbSteps = store.getGuide(dbGuide.guideId).stepsOrder.length;
console.log('CLICK-SELFTEST debounce:', dbSteps, 'of 4 expected',
dbSteps === 4 ? 'OK — burst collapsed to 1, three deliberate clicks kept' : 'FAIL');
capture.finishSession();
} catch (err) {
console.log('CLICK-SELFTEST ERROR', err.message);
} finally {
app.quit();
}
}, 1500);
}
// Dev-only self-test: exercise the exact hotkey-session capture path
// (hide window -> grab -> showInactive) several times, then exit.
if (process.env.STEPFORGE_CAPTURE_SELFTEST) {
setTimeout(async () => {
try {
const guide = store.createGuide({ title: 'hotkey selftest' });
capture.startSession(guide.guideId, { intervalSec: 0 });
// Sessions start paused until "Start recording" is pressed; do
// that here instead of waiting out the toast-grace delay, and
// hide the window immediately rather than after the 400ms pause.
capture.togglePause(false);
mainWindow.hide();
await new Promise((res) => setTimeout(res, 400));
const results = [];
for (let i = 0; i < 3; i++) {
const r = await capture.hotkeyCapture();
results.push(r.ok ? 'OK' : `FAIL:${r.reason}`);
await new Promise((res) => setTimeout(res, 500));
}
console.log('HOTKEY-SELFTEST', JSON.stringify(results),
'steps:', store.getGuide(guide.guideId).stepsOrder.length);
// Interval auto-capture: 1s timer should add ~3 steps in 3.6s.
const guide2 = store.createGuide({ title: 'interval selftest' });
capture.startSession(guide2.guideId, { intervalSec: 1 });
capture.togglePause(false);
await new Promise((res) => setTimeout(res, 3600));
capture.finishSession();
console.log('INTERVAL-SELFTEST steps:', store.getGuide(guide2.guideId).stepsOrder.length);
} catch (err) {
console.log('SELFTEST ERROR', err.message);
} finally {
app.quit();
}
}, 1500);
}
});
mainWindow.on('closed', () => { mainWindow = null; });
}
function registerHotkeys() {
globalShortcut.unregisterAll();
const accel = settings.get('capture.hotkeyCapture');
const pauseAccel = settings.get('capture.hotkeyPauseResume');
try {
if (accel) {
globalShortcut.register(accel, () => {
capture.hotkeyCapture().catch(() => {});
});
}
if (pauseAccel) {
globalShortcut.register(pauseAccel, () => {
capture.togglePause();
sendToRenderer('capture:state', capture.state());
});
}
} catch {
// invalid accelerator strings must not crash the app
}
}
function sendToRenderer(channel, payload) {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send(channel, payload);
}
// ---- IPC ------------------------------------------------------------------
function setupIpc() {
const h = (channel, fn) => ipcMain.handle(channel, async (event, args = {}) => fn(args));
// library
h('library:list', () => ({
guides: store.listGuides().map((g) => ({
...g,
stepCount: g.stepsOrder.length,
locked: g.linkedSource ? Boolean(readLock(g.linkedSource.path)) : false,
})),
folders: store.loadFolders(),
}));
h('library:create', ({ title }) => {
const guide = store.createGuide({
title: title || 'Untitled guide',
flags: { focusedViewDefault: Boolean(settings.get('editor.focusedViewDefaultForNewSteps')) },
});
reindex(guide.guideId);
return guide;
});
h('library:duplicate', ({ guideId }) => {
const copy = store.duplicateGuide(guideId);
reindex(copy.guideId);
return copy;
});
h('library:delete', ({ guideId }) => {
store.deleteGuide(guideId);
searchIndex.removeGuide(guideId);
return true;
});
h('library:setFavorite', ({ guideId, favorite }) => store.setFavorite(guideId, favorite));
h('library:trash:list', () => store.listTrash());
h('library:trash:restore', ({ name }) => {
const id = store.restoreFromTrash(name);
reindex(id);
return id;
});
h('library:trash:purge', ({ names } = {}) => {
if (names && names.length) store.purgeTrashItems(names);
else store.purgeTrash();
return true;
});
h('folders:create', ({ name, parentId }) => store.createFolder(name, parentId || null));
h('folders:rename', ({ folderId, name }) => store.renameFolder(folderId, name));
h('folders:delete', ({ folderId }) => store.deleteFolder(folderId));
h('folders:moveGuide', ({ guideId, folderId }) => store.moveGuideToFolder(guideId, folderId || null));
// guide + steps
h('guide:get', ({ guideId }) => ({
guide: store.getGuide(guideId),
steps: orderedSteps(guideId),
}));
h('guide:save', ({ guide }) => {
const saved = store.saveGuide(guide);
reindex(guide.guideId);
return saved;
});
h('step:add', ({ guideId, fields, imageBase64, size, position }) => {
const buf = imageBase64 ? Buffer.from(imageBase64, 'base64') : null;
const step = store.addStep(guideId, fields || {}, buf, size || null, { position });
reindex(guideId);
return step;
});
h('step:save', ({ guideId, step }) => {
const saved = store.saveStep(guideId, step);
reindex(guideId);
return saved;
});
h('step:delete', ({ guideId, stepId }) => {
store.deleteStep(guideId, stepId);
reindex(guideId);
return true;
});
h('steps:reorder', ({ guideId, order }) => store.reorderSteps(guideId, order));
h('step:imagePath', ({ guideId, stepId, which }) => {
const p = store.stepImagePath(guideId, stepId, which || 'working');
return p && fs.existsSync(p) ? `file://${p}?v=${fs.statSync(p).mtimeMs}` : null;
});
h('step:setWorkingImage', ({ guideId, stepId, pngBase64, size }) =>
store.setWorkingImage(guideId, stepId, Buffer.from(pngBase64, 'base64'), size));
h('step:resetWorkingImage', ({ guideId, stepId }) => {
const p = store.stepImagePath(guideId, stepId, 'original');
const img = nativeImage.createFromPath(p);
const { width, height } = img.getSize();
return store.resetWorkingImage(guideId, stepId, { width, height });
});
h('step:fromClipboard', ({ guideId, position }) => {
const img = clipboard.readImage();
if (img.isEmpty()) return { ok: false, reason: 'clipboard has no image' };
const { width, height } = img.getSize();
const step = store.addStep(guideId, {
title: 'Pasted image',
focusedView: { enabled: false, zoom: 1, panX: 0.5, panY: 0.5 },
}, img.toPNG(), { width, height }, { position });
reindex(guideId);
return { ok: true, step };
});
h('step:importImage', async ({ guideId }) => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Import images as steps',
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] }],
properties: ['openFile', 'multiSelections'],
});
if (res.canceled) return { ok: false };
const steps = [];
for (const file of res.filePaths) {
const img = nativeImage.createFromPath(file);
if (img.isEmpty()) continue;
const { width, height } = img.getSize();
steps.push(store.addStep(guideId, { title: path.basename(file, path.extname(file)) },
img.toPNG(), { width, height }));
}
reindex(guideId);
return { ok: true, steps };
});
// search
h('search:query', ({ q, guideId }) => searchIndex.search(q, { guideId: guideId || null }));
h('search:titles', ({ q }) => searchIndex.searchTitles(q));
// settings + placeholders
h('settings:all', () => settings.data);
h('settings:set', ({ keyPath, value }) => {
settings.set(keyPath, value);
if (keyPath === 'appearance') applyTheme();
if (keyPath.startsWith('capture.hotkey')) registerHotkeys();
return settings.data;
});
h('placeholders:globals:get', () => settings.getGlobalPlaceholders());
h('placeholders:globals:set', ({ values }) => settings.setGlobalPlaceholders(values));
// capture
h('capture:shoot', async ({ guideId, mode, delayMs }) => {
const result = await capture.shoot({ guideId, mode, delayMs });
if (result.ok) reindex(guideId);
return result;
});
h('capture:region', async ({ guideId }) => {
const result = await capture.regionCapture(guideId);
if (result.ok) reindex(guideId);
return result;
});
h('capture:session', async ({ action, guideId, intervalSec }) => {
if (action === 'start') capture.startSession(guideId, { intervalSec: intervalSec ?? null });
else if (action === 'pause') capture.togglePause(true);
else if (action === 'resume') capture.togglePause(false);
else if (action === 'finish') capture.finishSession();
else if (action === 'interval') capture.setInterval(intervalSec);
else if (action === 'shoot') await capture.sessionCapture('manual');
const state = capture.state();
sendToRenderer('capture:state', state);
return state;
});
h('capture:state', () => capture.state());
// archives & linked guides
h('archive:export', async ({ guideId }) => {
const guide = store.getGuide(guideId);
const res = await dialog.showSaveDialog(mainWindow, {
title: 'Share guide as file',
defaultPath: `${guide.title.replace(/[/\\:]+/g, '-')}.sfgz`,
filters: [{ name: 'StepForge guide archive', extensions: ['sfgz'] }],
});
if (res.canceled) return { ok: false };
exportGuideArchive(store, guideId, res.filePath);
return { ok: true, path: res.filePath };
});
h('archive:open', async ({ mode }) => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Open guide archive',
filters: [{ name: 'StepForge guide archive', extensions: ['sfgz'] }],
properties: ['openFile'],
});
if (res.canceled) return { ok: false };
try {
const guide = importGuideArchive(store, res.filePaths[0], { mode: mode || 'copy' });
reindex(guide.guideId);
return { ok: true, guide };
} catch (err) {
return { ok: false, reason: err.message };
}
});
h('archive:peek', ({ file }) => {
const { manifest } = readArchive(file);
return manifest;
});
h('archive:saveLinked', ({ guideId, force }) => saveLinkedGuide(store, guideId, { force: Boolean(force) }));
// snapshots
h('snapshots:list', ({ guideId }) => listSnapshots(store, guideId));
h('snapshots:create', ({ guideId, label }) =>
createSnapshot(store, guideId, { label: label || 'manual', keepLast: settings.get('backups.keepLast') }));
h('snapshots:restore', ({ guideId, name }) => {
const guide = restoreSnapshot(store, guideId, name);
reindex(guideId);
return guide;
});
// templates
h('templates:list', ({ format }) => templates.list(format));
h('templates:load', ({ format, name }) => templates.load(format, name));
h('templates:save', ({ format, name, options }) => templates.save(format, name, options));
h('templates:delete', ({ format, name }) => templates.remove(format, name));
h('templates:rename', ({ format, name, newName }) => templates.rename(format, name, newName));
h('templates:duplicate', ({ format, name }) => templates.duplicate(format, name));
h('templates:export', async ({ format, name }) => {
const res = await dialog.showSaveDialog(mainWindow, {
defaultPath: `${name}.sfglt`,
filters: [{ name: 'StepForge template', extensions: ['sfglt'] }],
});
if (res.canceled) return { ok: false };
templates.exportTemplate(format, name, res.filePath);
return { ok: true };
});
h('templates:import', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
filters: [{ name: 'StepForge template', extensions: ['sfglt'] }],
properties: ['openFile'],
});
if (res.canceled) return { ok: false };
return { ok: true, ...templates.importTemplate(res.filePaths[0]) };
});
// export + preview
h('export:formats', () => FORMATS.filter((f) => EXPORTERS[f]));
h('export:defaults', ({ format }) => {
// Exporter modules expose DEFAULT_TEMPLATE; the dialog renders editable
// options from it (booleans -> checkbox, numbers -> number, strings -> text).
const mod = {
json: '../exporters/json',
markdown: '../exporters/markdown',
'html-simple': '../exporters/html',
'html-rich': '../exporters/html',
pdf: '../exporters/pdf',
gif: '../exporters/gif',
'image-bundle': '../exporters/image-bundle',
docx: '../exporters/docx',
pptx: '../exporters/pptx',
}[format];
if (!mod) return {};
return { ...require(mod).DEFAULT_TEMPLATE };
});
h('export:run', async ({ guideId, format, options, outDir }) => {
let dir = outDir || settings.get(`exports.lastOutputDirs.${format}`);
if (!dir) {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Choose output folder', properties: ['openDirectory', 'createDirectory'],
});
if (res.canceled) return { ok: false };
dir = res.filePaths[0];
}
settings.set(`exports.lastOutputDirs.${format}`, dir);
const ast = buildRenderAst(store, guideId, { globals: settings.getGlobalPlaceholders() });
const result = runExport(format, ast, dir, options || {});
if (settings.get('exports.openFolderAfterExport')) shell.showItemInFolder(result.file);
return { ok: true, ...result };
});
h('export:chooseDir', async ({ format }) => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Choose output folder', properties: ['openDirectory', 'createDirectory'],
});
if (res.canceled) return null;
settings.set(`exports.lastOutputDirs.${format}`, res.filePaths[0]);
return res.filePaths[0];
});
h('export:preview', ({ guideId, format, options }) => {
const previewDir = path.join(store.tempDir, `preview-${guideId}-${format}`);
fs.rmSync(previewDir, { recursive: true, force: true });
const ast = buildRenderAst(store, guideId, {
globals: settings.getGlobalPlaceholders(),
maxSteps: settings.get('exports.previewStepCount') || 3,
});
const result = runExport(format, ast, previewDir, options || {});
return { ok: true, file: result.file, fileUrl: `file://${result.file}` };
});
h('preview:cleanup', () => {
for (const entry of fs.readdirSync(store.tempDir)) {
if (entry.startsWith('preview-')) {
fs.rmSync(path.join(store.tempDir, entry), { recursive: true, force: true });
}
}
return true;
});
// shell helpers
h('shell:openPath', ({ target }) => shell.openPath(target));
h('shell:showItemInFolder', ({ target }) => shell.showItemInFolder(target));
h('app:info', () => ({
version: app.getVersion(),
dataDir: store.root,
platform: process.platform,
}));
}
// ---- lifecycle --------------------------------------------------------------
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
// Exiting silently here looks like a broken install ("npm start does
// nothing") — say why, and let the running instance surface itself.
console.error('[stepforge] already running — surfacing the existing window (check the tray).');
app.quit();
} else {
app.on('second-instance', () => {
if (!mainWindow) return;
// The window may be tucked away by a recording session; opening the
// app again is an explicit request to see it, so pause and show, the
// same as the tray's "Open StepForge".
if (capture) capture.togglePause(true);
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
});
app.whenReady().then(() => {
const dataDir = resolveDataDir();
store = new GuideStore(dataDir);
settings = new Settings(store.settingsDir);
searchIndex = new SearchIndex(store.indexDir);
templates = new TemplateManager(store.templatesDir);
capture = new CaptureService({
store,
settings,
getWindow: () => mainWindow,
notify: sendToRenderer,
});
applyTheme();
setupIpc();
createWindow();
registerHotkeys();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
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)) {
fs.rmSync(path.join(store.tempDir, entry), { recursive: true, force: true });
}
} catch { /* best effort */ }
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
}