Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccbb9b03dc | ||
|
|
6ffef69705 | ||
|
|
50e445e7c3 | ||
|
|
6a3005f24c |
@@ -1,17 +1,26 @@
|
||||
# StepForge
|
||||
|
||||
StepForge is a **fully offline**, open-source desktop app for Windows, with
|
||||
StepForge is a **local-first**, open-source desktop app for Windows, with
|
||||
Linux (WIP) builds. It captures step-by-step workflows as screenshots, lets
|
||||
you annotate and describe each step in a focused three-pane editor, and
|
||||
exports the result to Markdown, DOCX, PPTX, PDF, HTML (WIP), GIF (WIP),
|
||||
confluence (WIP), Wiki.js (WIP), and image bundles (WIP). The current
|
||||
reconmendations for exporting is Markdown and PDF.
|
||||
|
||||
It is an independent offline desktop guide-capture tool inspired by publicly
|
||||
documented workflow patterns of commercial documentation tools like Folge. It contains no
|
||||
third-party branding, assets, or code from those tools, and it never talks to
|
||||
the network: no telemetry, no update checks, no license checks, no cloud, no
|
||||
remote AI.
|
||||
It is an independent desktop guide-capture tool inspired by publicly
|
||||
documented workflow patterns of commercial documentation tools like Folge. It
|
||||
contains no third-party branding, assets, or code from those tools.
|
||||
|
||||
**Network and privacy contract.** StepForge has no telemetry, no update
|
||||
checks, no license checks, and no cloud. Guides never leave your machine on
|
||||
their own. The only outbound network feature is the **optional** AI
|
||||
integration: when *you* enable it and configure an [Ollama](https://ollama.com)
|
||||
endpoint, StepForge sends step screenshots and text to that endpoint to
|
||||
generate titles and descriptions. By default that endpoint must be **local
|
||||
(loopback)**; sending data to a remote host requires the explicit "Allow
|
||||
remote AI host" opt-in. See [docs/PRIVACY.md](docs/PRIVACY.md) for exactly
|
||||
what is collected and sent. Note that OCR (Tesseract) and its English language
|
||||
data are bundled production dependencies — Electron is not the only one.
|
||||
|
||||
## Overview
|
||||
|
||||
|
||||
+19
-2
@@ -1025,6 +1025,11 @@ class CaptureService {
|
||||
// by other processes and a polling loop can miss short clicks under
|
||||
// load; WH_MOUSE_LL gives us one event for each button-down, with the
|
||||
// hook-time cursor position and timestamp.
|
||||
//
|
||||
// Raw typed-text capture is a keylogging surface, so the hook only
|
||||
// emits printable CHAR events when the user explicitly opted in; by
|
||||
// default the characters never even cross the process boundary.
|
||||
const captureTypedText = this.settings.get('capture.captureTypedText') ? 'true' : 'false';
|
||||
const ps = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -TypeDefinition @'
|
||||
@@ -1054,6 +1059,7 @@ public static class SFHook {
|
||||
private const uint PROCESS_POWER_THROTTLING_EXECUTION_SPEED = 0x1;
|
||||
private const uint HIGH_PRIORITY_CLASS = 0x00000080;
|
||||
|
||||
private static readonly bool CaptureTypedText = ${captureTypedText};
|
||||
private static IntPtr hook = IntPtr.Zero;
|
||||
private static IntPtr keyHook = IntPtr.Zero;
|
||||
private static LowLevelMouseProc proc = MouseHookCallback;
|
||||
@@ -1306,8 +1312,10 @@ public static class SFHook {
|
||||
queue.Enqueue("KEY Escape " + unixMs); signal.Set();
|
||||
} else if (vk == 0x0D) {
|
||||
queue.Enqueue("KEY Enter " + unixMs); signal.Set();
|
||||
} else {
|
||||
// Map to Unicode character using current keyboard layout + shift state.
|
||||
} else if (CaptureTypedText) {
|
||||
// Map to Unicode character using current keyboard layout + shift
|
||||
// state. Only reached when the user opted into typed-text capture;
|
||||
// otherwise raw characters are never read or emitted.
|
||||
byte[] ks = new byte[256];
|
||||
GetKeyboardState(ks);
|
||||
var sb = new System.Text.StringBuilder(4);
|
||||
@@ -1790,6 +1798,11 @@ public static class SFHook {
|
||||
this._keyLastAt = now;
|
||||
|
||||
if (type === 'CHAR') {
|
||||
// Raw typed-text capture is off by default: it can record passwords or
|
||||
// other secrets. Only buffer printable characters when the user has
|
||||
// explicitly opted in via capture.captureTypedText. Shortcut/navigation
|
||||
// keys below are unaffected.
|
||||
if (!this.settings.get('capture.captureTypedText')) return;
|
||||
const ch = typeof data === 'number' ? String.fromCharCode(data) : String(data);
|
||||
this._keyBuffer = (this._keyBuffer + ch).slice(-200);
|
||||
} else if (type === 'KEY') {
|
||||
@@ -2019,8 +2032,12 @@ public static class SFHook {
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'region-preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
},
|
||||
});
|
||||
// The overlay may only display region.html; deny navigation/popups.
|
||||
require('./security').installWindowSecurity(overlay, 'region');
|
||||
let settled = false;
|
||||
const finish = (rect) => {
|
||||
if (settled) return;
|
||||
|
||||
+201
-57
@@ -15,12 +15,13 @@ const { TemplateManager, FORMATS, FORMAT_LABELS } = require('../core/templates')
|
||||
const { buildRenderAst } = require('../core/renderast');
|
||||
const { runExport, EXPORTERS } = require('../exporters');
|
||||
const { runExportInWorker } = require('./export-runner');
|
||||
const { exportGuideArchive, importGuideArchive, saveLinkedGuide, readArchive } = require('../core/archive');
|
||||
const { exportGuideArchive, importGuideArchive, saveLinkedGuide } = require('../core/archive');
|
||||
const { createSnapshot, listSnapshots, restoreSnapshot } = require('../core/snapshots');
|
||||
const { readLock } = require('../core/locks');
|
||||
const CaptureService = require('./capture');
|
||||
const { TextIntelService } = require('./text-intel');
|
||||
const { keepProcessesResponsive } = require('./win-power');
|
||||
const security = require('./security');
|
||||
const PACKAGE_JSON = require(path.join(__dirname, '..', 'package.json'));
|
||||
|
||||
const APP_ID = 'com.stepforge.app';
|
||||
@@ -95,6 +96,7 @@ function createWindow() {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
spellcheck: Boolean(settings.get('spellcheck')),
|
||||
// During a recording the window is minimized (Linux) or hidden (Windows).
|
||||
// A throttled renderer stops processing capture:added events, so the step
|
||||
@@ -103,6 +105,10 @@ function createWindow() {
|
||||
backgroundThrottling: false,
|
||||
},
|
||||
});
|
||||
// The main window may only ever display our index.html: all navigation
|
||||
// away from it and every popup is denied, so no other document can run
|
||||
// with this window's preload bridge.
|
||||
security.installWindowSecurity(mainWindow, 'main');
|
||||
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow.show();
|
||||
@@ -375,7 +381,37 @@ function sendToRenderer(channel, payload) {
|
||||
// ---- IPC ------------------------------------------------------------------
|
||||
|
||||
function setupIpc() {
|
||||
const h = (channel, fn) => ipcMain.handle(channel, async (event, args = {}) => fn(args));
|
||||
// Every invoke channel is guarded: the event must come from the current
|
||||
// main window's top frame showing our index.html, the argument bag must be
|
||||
// a plain object within a per-channel payload budget, and channels with
|
||||
// risky inputs additionally validate fields before the handler runs.
|
||||
const trustedSender = security.makeIpcSenderGuard({
|
||||
getMainWebContents: () => (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null),
|
||||
});
|
||||
const c = security.check;
|
||||
const IMAGE_BUDGET = 256 * 1024 * 1024; // channels that carry base64 PNGs
|
||||
const h = (channel, fn, opts = {}) => {
|
||||
const { maxChars = 2 * 1024 * 1024, validate = null } = opts;
|
||||
ipcMain.handle(channel, async (event, args = {}) => {
|
||||
if (!trustedSender(event)) {
|
||||
throw new Error(`${channel}: rejected — untrusted IPC sender`);
|
||||
}
|
||||
const a = args === undefined || args === null ? {} : args;
|
||||
if (!security.isPlainArgs(a) || !security.payloadWithinBudget(a, maxChars)) {
|
||||
throw new Error(`${channel}: rejected — invalid or oversized arguments`);
|
||||
}
|
||||
if (validate && !validate(a)) {
|
||||
throw new Error(`${channel}: rejected — arguments failed validation`);
|
||||
}
|
||||
return fn(a);
|
||||
});
|
||||
};
|
||||
|
||||
// Files the main process itself produced (exports, previews); only these
|
||||
// may be re-opened via the shell on renderer request.
|
||||
const producedFiles = new security.ProducedFiles();
|
||||
// Output directories the user actually picked in a dialog this session.
|
||||
const chosenOutputDirs = new Set();
|
||||
|
||||
// library
|
||||
h('library:list', () => ({
|
||||
@@ -393,60 +429,71 @@ function setupIpc() {
|
||||
});
|
||||
reindex(guide.guideId);
|
||||
return guide;
|
||||
});
|
||||
}, { validate: (a) => c.optionalString(a.title, 500) });
|
||||
h('library:duplicate', ({ guideId }) => {
|
||||
const copy = store.duplicateGuide(guideId);
|
||||
reindex(copy.guideId);
|
||||
return copy;
|
||||
});
|
||||
}, { validate: (a) => c.id(a.guideId) });
|
||||
h('library:delete', ({ guideId }) => {
|
||||
store.deleteGuide(guideId);
|
||||
searchIndex.removeGuide(guideId);
|
||||
return true;
|
||||
});
|
||||
h('library:setFavorite', ({ guideId, favorite }) => store.setFavorite(guideId, favorite));
|
||||
}, { validate: (a) => c.id(a.guideId) });
|
||||
h('library:setFavorite', ({ guideId, favorite }) => store.setFavorite(guideId, favorite),
|
||||
{ validate: (a) => c.id(a.guideId) });
|
||||
h('library:trash:list', () => store.listTrash());
|
||||
h('library:trash:restore', ({ name }) => {
|
||||
const id = store.restoreFromTrash(name);
|
||||
reindex(id);
|
||||
return id;
|
||||
});
|
||||
}, { validate: (a) => c.fileName(a.name) });
|
||||
h('library:trash:purge', ({ names } = {}) => {
|
||||
if (names && names.length) store.purgeTrashItems(names);
|
||||
else store.purgeTrash();
|
||||
return true;
|
||||
}, {
|
||||
validate: (a) => a.names === undefined || a.names === null
|
||||
|| (Array.isArray(a.names) && a.names.length <= 1000 && a.names.every((n) => c.fileName(n))),
|
||||
});
|
||||
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));
|
||||
h('folders:create', ({ name, parentId }) => store.createFolder(name, parentId || null),
|
||||
{ validate: (a) => c.string(a.name, 200) && c.optionalId(a.parentId) });
|
||||
h('folders:rename', ({ folderId, name }) => store.renameFolder(folderId, name),
|
||||
{ validate: (a) => c.id(a.folderId) && c.string(a.name, 200) });
|
||||
h('folders:delete', ({ folderId }) => store.deleteFolder(folderId),
|
||||
{ validate: (a) => c.id(a.folderId) });
|
||||
h('folders:moveGuide', ({ guideId, folderId }) => store.moveGuideToFolder(guideId, folderId || null),
|
||||
{ validate: (a) => c.id(a.guideId) && c.optionalId(a.folderId) });
|
||||
|
||||
// guide + steps
|
||||
h('guide:get', ({ guideId }) => ({
|
||||
guide: store.getGuide(guideId),
|
||||
steps: orderedSteps(guideId),
|
||||
}));
|
||||
}), { validate: (a) => c.id(a.guideId) });
|
||||
h('guide:save', ({ guide }) => {
|
||||
const saved = store.saveGuide(guide);
|
||||
reindex(guide.guideId);
|
||||
return saved;
|
||||
});
|
||||
}, { validate: (a) => security.isPlainArgs(a.guide) && a.guide && c.id(a.guide.guideId) });
|
||||
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;
|
||||
}, {
|
||||
maxChars: IMAGE_BUDGET,
|
||||
validate: (a) => c.id(a.guideId) && c.optionalBase64(a.imageBase64) && c.optionalNumber(a.position, 0, 100000),
|
||||
});
|
||||
h('step:save', ({ guideId, step }) => {
|
||||
const saved = store.saveStep(guideId, step);
|
||||
reindex(guideId);
|
||||
return saved;
|
||||
});
|
||||
}, { validate: (a) => c.id(a.guideId) && security.isPlainArgs(a.step) && a.step && c.id(a.step.stepId) });
|
||||
h('step:delete', ({ guideId, stepId }) => {
|
||||
store.deleteStep(guideId, stepId);
|
||||
reindex(guideId);
|
||||
return true;
|
||||
});
|
||||
}, { validate: (a) => c.id(a.guideId) && c.id(a.stepId) });
|
||||
h('step:restore', ({ guideId, step, originalBase64, workingBase64, position }) => {
|
||||
const images = {
|
||||
original: originalBase64 ? Buffer.from(originalBase64, 'base64') : null,
|
||||
@@ -455,20 +502,34 @@ function setupIpc() {
|
||||
const restored = store.restoreStep(guideId, step, images, position);
|
||||
reindex(guideId);
|
||||
return restored;
|
||||
}, {
|
||||
maxChars: IMAGE_BUDGET,
|
||||
validate: (a) => c.id(a.guideId) && security.isPlainArgs(a.step) && a.step
|
||||
&& c.optionalBase64(a.originalBase64) && c.optionalBase64(a.workingBase64)
|
||||
&& c.optionalNumber(a.position, 0, 100000),
|
||||
});
|
||||
h('steps:reorder', ({ guideId, order }) => store.reorderSteps(guideId, order), {
|
||||
validate: (a) => c.id(a.guideId)
|
||||
&& Array.isArray(a.order) && a.order.length <= 100000 && a.order.every((id) => c.id(id)),
|
||||
});
|
||||
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;
|
||||
}, {
|
||||
validate: (a) => c.id(a.guideId) && c.id(a.stepId)
|
||||
&& (a.which === undefined || a.which === null || c.oneOf(a.which, ['original', 'working'])),
|
||||
});
|
||||
h('step:setWorkingImage', ({ guideId, stepId, pngBase64, size, step }) =>
|
||||
store.setWorkingImage(guideId, stepId, Buffer.from(pngBase64, 'base64'), size, step || null));
|
||||
store.setWorkingImage(guideId, stepId, Buffer.from(pngBase64, 'base64'), size, step || null), {
|
||||
maxChars: IMAGE_BUDGET,
|
||||
validate: (a) => c.id(a.guideId) && c.id(a.stepId) && c.base64(a.pngBase64),
|
||||
});
|
||||
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 });
|
||||
});
|
||||
}, { validate: (a) => c.id(a.guideId) && c.id(a.stepId) });
|
||||
h('step:fromClipboard', ({ guideId, position }) => {
|
||||
const img = clipboard.readImage();
|
||||
if (img.isEmpty()) return { ok: false, reason: 'clipboard has no image' };
|
||||
@@ -479,7 +540,7 @@ function setupIpc() {
|
||||
}, img.toPNG(), { width, height }, { position });
|
||||
reindex(guideId);
|
||||
return { ok: true, step };
|
||||
});
|
||||
}, { validate: (a) => c.id(a.guideId) && c.optionalNumber(a.position, 0, 100000) });
|
||||
h('step:importImage', async ({ guideId }) => {
|
||||
const res = await dialog.showOpenDialog(mainWindow, {
|
||||
title: 'Import images as steps',
|
||||
@@ -497,11 +558,13 @@ function setupIpc() {
|
||||
}
|
||||
reindex(guideId);
|
||||
return { ok: true, steps };
|
||||
});
|
||||
}, { validate: (a) => c.id(a.guideId) });
|
||||
|
||||
// search
|
||||
h('search:query', ({ q, guideId }) => searchIndex.search(q, { guideId: guideId || null }));
|
||||
h('search:titles', ({ q }) => searchIndex.searchTitles(q));
|
||||
h('search:query', ({ q, guideId }) => searchIndex.search(q, { guideId: guideId || null }),
|
||||
{ validate: (a) => c.optionalString(a.q, 1000) && c.optionalId(a.guideId) });
|
||||
h('search:titles', ({ q }) => searchIndex.searchTitles(q),
|
||||
{ validate: (a) => c.optionalString(a.q, 1000) });
|
||||
|
||||
// settings + placeholders
|
||||
h('settings:all', () => settings.data);
|
||||
@@ -510,13 +573,13 @@ function setupIpc() {
|
||||
if (keyPath === 'appearance') applyTheme();
|
||||
if (keyPath.startsWith('capture.hotkey')) registerHotkeys();
|
||||
return settings.data;
|
||||
});
|
||||
}, { validate: (a) => c.settingsKeyPath(a.keyPath) });
|
||||
h('ai:test', async ({ enabled = null, ollama = null } = {}) => {
|
||||
return textIntel.testAiConnection({
|
||||
enabled,
|
||||
ollama,
|
||||
});
|
||||
});
|
||||
}, { validate: (a) => (a.ollama === undefined || a.ollama === null || security.isPlainArgs(a.ollama)) });
|
||||
h('ai:fillStep', async ({ guideId, stepId, target = 'all', blockId = null } = {}) => {
|
||||
const result = await textIntel.generateStepPatch({
|
||||
guideId,
|
||||
@@ -526,10 +589,22 @@ function setupIpc() {
|
||||
});
|
||||
if (result.ok) reindex(guideId);
|
||||
return result;
|
||||
}, {
|
||||
validate: (a) => c.id(a.guideId) && c.id(a.stepId) && c.optionalId(a.blockId)
|
||||
&& (a.target === undefined || c.oneOf(a.target, ['all', 'title', 'description', 'block'])),
|
||||
});
|
||||
h('ai:rewriteText', async ({ text, guideTitle = '', stepTitle = '' } = {}) => {
|
||||
return textIntel.rewriteText({ text, guideTitle, stepTitle });
|
||||
}, {
|
||||
validate: (a) => c.string(a.text, 200000)
|
||||
&& c.optionalString(a.guideTitle, 1000) && c.optionalString(a.stepTitle, 1000),
|
||||
});
|
||||
// Cancel outstanding AI requests, e.g. when a guide/editor closes, so a
|
||||
// slow response can't resolve against data the user has moved on from.
|
||||
h('ai:cancel', ({ guideId = null } = {}) => {
|
||||
textIntel.cancelInflight(guideId || null);
|
||||
return true;
|
||||
}, { validate: (a) => c.optionalId(a.guideId) });
|
||||
h('placeholders:globals:get', () => settings.getGlobalPlaceholders());
|
||||
h('placeholders:globals:set', (values) => settings.setGlobalPlaceholders(values));
|
||||
|
||||
@@ -552,6 +627,10 @@ function setupIpc() {
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, {
|
||||
validate: (a) => c.id(a.guideId)
|
||||
&& (a.mode === undefined || c.oneOf(a.mode, ['fullscreen', 'window', 'region']))
|
||||
&& c.optionalNumber(a.delayMs, 0, 600000),
|
||||
});
|
||||
h('capture:region', async ({ guideId }) => {
|
||||
const result = await capture.regionCapture(guideId);
|
||||
@@ -571,7 +650,7 @@ function setupIpc() {
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}, { validate: (a) => c.id(a.guideId) });
|
||||
let capturePowerBlocker = -1;
|
||||
const startCapturePower = () => {
|
||||
if (!powerSaveBlocker.isStarted(capturePowerBlocker)) {
|
||||
@@ -615,6 +694,10 @@ function setupIpc() {
|
||||
const state = capture.state();
|
||||
sendToRenderer('capture:state', state);
|
||||
return state;
|
||||
}, {
|
||||
validate: (a) => c.oneOf(a.action, ['start', 'pause', 'resume', 'finish', 'interval'])
|
||||
&& (a.action !== 'start' || c.id(a.guideId))
|
||||
&& c.optionalNumber(a.intervalSec, 0, 86400),
|
||||
});
|
||||
h('capture:state', () => capture.state());
|
||||
|
||||
@@ -629,7 +712,7 @@ function setupIpc() {
|
||||
if (res.canceled) return { ok: false };
|
||||
exportGuideArchive(store, guideId, res.filePath);
|
||||
return { ok: true, path: res.filePath };
|
||||
});
|
||||
}, { validate: (a) => c.id(a.guideId) });
|
||||
h('archive:open', async ({ mode }) => {
|
||||
const res = await dialog.showOpenDialog(mainWindow, {
|
||||
title: 'Open guide archive',
|
||||
@@ -644,30 +727,38 @@ function setupIpc() {
|
||||
} 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) }));
|
||||
}, { validate: (a) => a.mode === undefined || c.oneOf(a.mode, ['copy', 'linked']) });
|
||||
// archive:peek was removed: nothing in the renderer used it, and it let a
|
||||
// compromised renderer read arbitrary local archives by path.
|
||||
h('archive:saveLinked', ({ guideId, force }) => saveLinkedGuide(store, guideId, { force: Boolean(force) }),
|
||||
{ validate: (a) => c.id(a.guideId) });
|
||||
|
||||
// snapshots
|
||||
h('snapshots:list', ({ guideId }) => listSnapshots(store, guideId));
|
||||
h('snapshots:list', ({ guideId }) => listSnapshots(store, guideId),
|
||||
{ validate: (a) => c.id(a.guideId) });
|
||||
h('snapshots:create', ({ guideId, label }) =>
|
||||
createSnapshot(store, guideId, { label: label || 'manual', keepLast: settings.get('backups.keepLast') }));
|
||||
createSnapshot(store, guideId, { label: label || 'manual', keepLast: settings.get('backups.keepLast') }),
|
||||
{ validate: (a) => c.id(a.guideId) && c.optionalString(a.label, 200) });
|
||||
h('snapshots:restore', ({ guideId, name }) => {
|
||||
const guide = restoreSnapshot(store, guideId, name);
|
||||
reindex(guideId);
|
||||
return guide;
|
||||
});
|
||||
}, { validate: (a) => c.id(a.guideId) && c.fileName(a.name) });
|
||||
|
||||
// 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));
|
||||
const validFormat = (v) => c.oneOf(v, FORMATS);
|
||||
h('templates:list', ({ format }) => templates.list(format),
|
||||
{ validate: (a) => validFormat(a.format) });
|
||||
h('templates:load', ({ format, name }) => templates.load(format, name),
|
||||
{ validate: (a) => validFormat(a.format) && c.fileName(a.name) });
|
||||
h('templates:save', ({ format, name, options }) => templates.save(format, name, options),
|
||||
{ validate: (a) => validFormat(a.format) && c.fileName(a.name) && security.isPlainArgs(a.options) });
|
||||
h('templates:delete', ({ format, name }) => templates.remove(format, name),
|
||||
{ validate: (a) => validFormat(a.format) && c.fileName(a.name) });
|
||||
h('templates:rename', ({ format, name, newName }) => templates.rename(format, name, newName),
|
||||
{ validate: (a) => validFormat(a.format) && c.fileName(a.name) && c.fileName(a.newName) });
|
||||
h('templates:duplicate', ({ format, name }) => templates.duplicate(format, name),
|
||||
{ validate: (a) => validFormat(a.format) && c.fileName(a.name) });
|
||||
h('templates:export', async ({ format, name }) => {
|
||||
const res = await dialog.showSaveDialog(mainWindow, {
|
||||
defaultPath: `${name}.sfglt`,
|
||||
@@ -676,7 +767,7 @@ function setupIpc() {
|
||||
if (res.canceled) return { ok: false };
|
||||
templates.exportTemplate(format, name, res.filePath);
|
||||
return { ok: true };
|
||||
});
|
||||
}, { validate: (a) => validFormat(a.format) && c.fileName(a.name) });
|
||||
h('templates:import', async () => {
|
||||
const res = await dialog.showOpenDialog(mainWindow, {
|
||||
filters: [{ name: 'StepForge template', extensions: ['sfglt'] }],
|
||||
@@ -709,15 +800,24 @@ function setupIpc() {
|
||||
}[format];
|
||||
if (!mod) return {};
|
||||
return { ...require(mod).DEFAULT_TEMPLATE };
|
||||
});
|
||||
}, { validate: (a) => c.string(a.format, 40) });
|
||||
h('export:run', async ({ guideId, format, options, outDir }) => {
|
||||
let dir = outDir || settings.get(`exports.lastOutputDirs.${format}`);
|
||||
// The renderer may only nominate directories that came from this main
|
||||
// process: a dialog pick from this session or a remembered last-output
|
||||
// directory. Anything else is ignored and re-asked via the dialog.
|
||||
const rememberedDirs = Object.values(settings.get('exports.lastOutputDirs') || {});
|
||||
let dir = null;
|
||||
if (outDir && (chosenOutputDirs.has(outDir) || rememberedDirs.includes(outDir))) {
|
||||
dir = outDir;
|
||||
}
|
||||
if (!dir) dir = 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];
|
||||
chosenOutputDirs.add(dir);
|
||||
}
|
||||
settings.set(`exports.lastOutputDirs.${format}`, dir);
|
||||
const result = await runExportInWorker({
|
||||
@@ -728,17 +828,23 @@ function setupIpc() {
|
||||
outDir: dir,
|
||||
globals: settings.getGlobalPlaceholders(),
|
||||
});
|
||||
producedFiles.add(result.file);
|
||||
if (settings.get('exports.openFolderAfterExport')) shell.showItemInFolder(result.file);
|
||||
return { ok: true, ...result };
|
||||
}, {
|
||||
validate: (a) => c.id(a.guideId) && validFormat(a.format)
|
||||
&& (a.options === undefined || security.isPlainArgs(a.options))
|
||||
&& c.optionalString(a.outDir, 1000),
|
||||
});
|
||||
h('export:chooseDir', async ({ format }) => {
|
||||
const res = await dialog.showOpenDialog(mainWindow, {
|
||||
title: 'Choose output folder', properties: ['openDirectory', 'createDirectory'],
|
||||
});
|
||||
if (res.canceled) return null;
|
||||
chosenOutputDirs.add(res.filePaths[0]);
|
||||
settings.set(`exports.lastOutputDirs.${format}`, res.filePaths[0]);
|
||||
return res.filePaths[0];
|
||||
});
|
||||
}, { validate: (a) => validFormat(a.format) });
|
||||
h('export:preview', ({ guideId, format, options }) => {
|
||||
const previewDir = path.join(store.tempDir, `preview-${guideId}-${format}`);
|
||||
fs.rmSync(previewDir, { recursive: true, force: true });
|
||||
@@ -747,7 +853,11 @@ function setupIpc() {
|
||||
maxSteps: settings.get('exports.previewStepCount') || 3,
|
||||
});
|
||||
const result = runExport(format, ast, previewDir, options || {});
|
||||
producedFiles.add(result.file);
|
||||
return { ok: true, file: result.file, fileUrl: `file://${result.file}` };
|
||||
}, {
|
||||
validate: (a) => c.id(a.guideId) && validFormat(a.format)
|
||||
&& (a.options === undefined || security.isPlainArgs(a.options)),
|
||||
});
|
||||
h('preview:cleanup', () => {
|
||||
for (const entry of fs.readdirSync(store.tempDir)) {
|
||||
@@ -758,9 +868,32 @@ function setupIpc() {
|
||||
return true;
|
||||
});
|
||||
|
||||
// shell helpers
|
||||
h('shell:openPath', ({ target }) => shell.openPath(target));
|
||||
h('shell:showItemInFolder', ({ target }) => shell.showItemInFolder(target));
|
||||
// shell helpers — intent-specific, no arbitrary paths from the renderer.
|
||||
// Only files this main process produced (exports/previews) may be opened.
|
||||
h('shell:openProduced', ({ target }) => {
|
||||
if (!producedFiles.has(target)) {
|
||||
return { ok: false, reason: 'not a StepForge-produced file' };
|
||||
}
|
||||
shell.openPath(target);
|
||||
return { ok: true };
|
||||
}, { validate: (a) => c.string(a.target, 2000) });
|
||||
// Reveal the linked archive of a guide; the path comes from the store,
|
||||
// never from the renderer.
|
||||
h('shell:revealLinkedArchive', ({ guideId }) => {
|
||||
const guide = store.getGuide(guideId);
|
||||
const target = guide && guide.linkedSource && guide.linkedSource.path;
|
||||
if (!target || !fs.existsSync(target)) return { ok: false, reason: 'no linked archive' };
|
||||
shell.showItemInFolder(target);
|
||||
return { ok: true };
|
||||
}, { validate: (a) => c.id(a.guideId) });
|
||||
// Open a user-clicked link in the system browser. Scheme-validated;
|
||||
// everything that is not plain http(s)/mailto is refused.
|
||||
h('shell:openExternal', ({ url }) => {
|
||||
const safe = security.validateExternalUrl(url);
|
||||
if (!safe) return { ok: false, reason: 'blocked URL' };
|
||||
shell.openExternal(safe);
|
||||
return { ok: true };
|
||||
}, { validate: (a) => c.string(a.url, 2048) });
|
||||
h('app:info', () => ({
|
||||
version: app.getVersion(),
|
||||
buildVersion: PACKAGE_JSON.buildVersion || app.getVersion(),
|
||||
@@ -842,15 +975,19 @@ if (!gotLock) {
|
||||
textIntel,
|
||||
});
|
||||
|
||||
// Allow the hidden capture-worker renderer to open a desktop media stream.
|
||||
// Electron 29+ requires an explicit permission grant for display-capture in
|
||||
// renderer windows; without it getUserMedia/getDisplayMedia fails, the
|
||||
// stream backend never starts, and every capture falls back to
|
||||
// desktopCapturer.getSources() — which triggers the portal dialog on Linux
|
||||
// on every single capture. StepForge is fully local/offline so allowing
|
||||
// all permissions for our own content is safe.
|
||||
session.defaultSession.setPermissionCheckHandler(() => true);
|
||||
session.defaultSession.setPermissionRequestHandler((_wc, _perm, cb) => cb(true));
|
||||
// Deny-by-default permission policy. The only grant in the entire app is
|
||||
// display capture (and the media permission getDisplayMedia consults) for
|
||||
// the dedicated hidden capture-worker page. Electron 29+ requires that
|
||||
// explicit grant; everything else — including our own main window — is
|
||||
// rejected. "Content is local" is not a security control.
|
||||
session.defaultSession.setPermissionCheckHandler((wc, permission, requestingOrigin, details) => {
|
||||
const url = (details && details.requestingUrl) || (wc && wc.getURL()) || requestingOrigin;
|
||||
return security.permissionAllowed(permission, url);
|
||||
});
|
||||
session.defaultSession.setPermissionRequestHandler((wc, permission, cb, details) => {
|
||||
const url = (details && details.requestingUrl) || (wc && wc.getURL());
|
||||
cb(security.permissionAllowed(permission, url));
|
||||
});
|
||||
|
||||
// On GNOME Wayland the only working screen-capture path is the portal-backed
|
||||
// getDisplayMedia (desktopCapturer source ids fail with "device not found").
|
||||
@@ -860,6 +997,13 @@ if (!gotLock) {
|
||||
// the chosen source then streams for the whole session. (useSystemPicker is
|
||||
// macOS-only today, harmless elsewhere.)
|
||||
session.defaultSession.setDisplayMediaRequestHandler((request, callback) => {
|
||||
// Only the capture worker page may open a desktop stream.
|
||||
const frameUrl = request && request.frame && request.frame.url;
|
||||
if (!security.isAppPageUrl(frameUrl, 'captureWorker')) {
|
||||
console.error('[stepforge] display-media request denied for', frameUrl || '(unknown frame)');
|
||||
callback({});
|
||||
return;
|
||||
}
|
||||
desktopCapturer.getSources({ types: ['screen'] })
|
||||
.then((sources) => {
|
||||
console.log(`[stepforge] display-media request resolved: ${sources.length} screen source(s)`);
|
||||
|
||||
+6
-2
@@ -56,6 +56,7 @@ const api = {
|
||||
test: invoke('ai:test'),
|
||||
fillStep: invoke('ai:fillStep'),
|
||||
rewriteText: invoke('ai:rewriteText'),
|
||||
cancel: invoke('ai:cancel'),
|
||||
},
|
||||
capture: {
|
||||
shoot: invoke('capture:shoot'),
|
||||
@@ -95,8 +96,11 @@ const api = {
|
||||
cleanupPreviews: invoke('preview:cleanup'),
|
||||
},
|
||||
shell: {
|
||||
openPath: invoke('shell:openPath'),
|
||||
showItemInFolder: invoke('shell:showItemInFolder'),
|
||||
// Intent-specific shell access only: files the main process produced,
|
||||
// the guide's linked archive, and scheme-validated external links.
|
||||
openProduced: invoke('shell:openProduced'),
|
||||
revealLinkedArchive: invoke('shell:revealLinkedArchive'),
|
||||
openExternal: invoke('shell:openExternal'),
|
||||
},
|
||||
app: {
|
||||
info: invoke('app:info'),
|
||||
|
||||
+19
-1
@@ -172,7 +172,7 @@ class StepForgeApp {
|
||||
el('div.welcome', {},
|
||||
el('div.welcome-title', {},
|
||||
el('h1', {}, 'StepForge'),
|
||||
el('p.muted', {}, 'Capture, annotate, and export step-by-step guides, fully offline.'),
|
||||
el('p.muted', {}, 'Capture, annotate, and export step-by-step guides. Local-first, no telemetry.'),
|
||||
),
|
||||
el('div.welcome-actions', {},
|
||||
el('button.welcome-btn.primary', {
|
||||
@@ -934,6 +934,24 @@ class StepForgeApp {
|
||||
|
||||
window.StepForgeApp = StepForgeApp;
|
||||
|
||||
// Links never navigate this window. http(s)/mailto links from guide content
|
||||
// open externally via the scheme-validated main-process handler; internal
|
||||
// step:/# links are handled by their own click handlers; everything else is
|
||||
// inert. The main process additionally denies all navigation, so this is the
|
||||
// user-experience half of a two-layer guarantee.
|
||||
document.addEventListener('click', (e) => {
|
||||
const anchor = e.target && e.target.closest ? e.target.closest('a[href]') : null;
|
||||
if (!anchor) return;
|
||||
const href = anchor.getAttribute('href') || '';
|
||||
if (/^(https?|mailto):/i.test(href)) {
|
||||
e.preventDefault();
|
||||
api.shell.openExternal({ url: href });
|
||||
return;
|
||||
}
|
||||
// Ensure a stray href can never navigate the privileged window.
|
||||
if (!href.startsWith('#')) e.preventDefault();
|
||||
}, true);
|
||||
|
||||
function boot() {
|
||||
const app = new StepForgeApp();
|
||||
app.init();
|
||||
|
||||
@@ -151,6 +151,11 @@ class GuideEditor {
|
||||
|
||||
setActive(active) {
|
||||
this.active = Boolean(active);
|
||||
// Leaving the editor cancels any in-flight AI request for this guide so a
|
||||
// slow response can't resolve against a guide the user has closed.
|
||||
if (!this.active && this.guideId) {
|
||||
api.ai.cancel({ guideId: this.guideId }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
setSettings(settings) {
|
||||
@@ -1899,7 +1904,7 @@ class GuideEditor {
|
||||
onPreview: async ({ format, options }) => {
|
||||
const preview = await api.export.preview({ guideId: this.guideId, format, options });
|
||||
if (preview && preview.file) {
|
||||
await api.shell.openPath({ target: preview.file }); // open in default viewer
|
||||
await api.shell.openProduced({ target: preview.file }); // open in default viewer
|
||||
this.onToast('Preview opened (first steps only).');
|
||||
}
|
||||
return true;
|
||||
@@ -1935,7 +1940,7 @@ class GuideEditor {
|
||||
else this.onToast('Could not save linked archive.', { error: true });
|
||||
},
|
||||
onOpenArchive: async () => {
|
||||
await api.shell.showItemInFolder({ target: this.guide.linkedSource.path });
|
||||
await api.shell.revealLinkedArchive({ guideId: this.guideId });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Privilege-boundary policy for every renderer surface.
|
||||
*
|
||||
* This module is deliberately loadable under plain Node (no electron
|
||||
* require at module scope) so the decision logic is unit-testable:
|
||||
* - which URLs count as our own app pages,
|
||||
* - whether a navigation/popup may proceed (it may not),
|
||||
* - which permission a renderer may be granted (display capture for the
|
||||
* dedicated capture worker only),
|
||||
* - whether an IPC event comes from the trusted main-window frame,
|
||||
* - which external URLs may be handed to shell.openExternal,
|
||||
* - which filesystem paths the renderer may ask the shell to open
|
||||
* (only files the main process itself produced).
|
||||
*/
|
||||
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
|
||||
const RENDERER_DIR = path.join(__dirname, 'renderer');
|
||||
|
||||
const APP_PAGES = {
|
||||
main: pathToFileURL(path.join(RENDERER_DIR, 'index.html')).href,
|
||||
region: pathToFileURL(path.join(RENDERER_DIR, 'region.html')).href,
|
||||
captureWorker: pathToFileURL(path.join(RENDERER_DIR, 'capture-worker.html')).href,
|
||||
};
|
||||
|
||||
/** Normalize a URL for identity comparison: drop query/hash, decode path. */
|
||||
function normalizeAppUrl(rawUrl) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(String(rawUrl));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== 'file:') return null;
|
||||
let pathname;
|
||||
try {
|
||||
pathname = decodeURIComponent(url.pathname);
|
||||
} catch {
|
||||
pathname = url.pathname;
|
||||
}
|
||||
// Windows drive letters may differ in case between loadFile and senderFrame.
|
||||
if (process.platform === 'win32') pathname = pathname.toLowerCase();
|
||||
return pathname;
|
||||
}
|
||||
|
||||
function isAppPageUrl(rawUrl, page) {
|
||||
const expected = APP_PAGES[page];
|
||||
if (!expected) return false;
|
||||
const a = normalizeAppUrl(rawUrl);
|
||||
const b = normalizeAppUrl(expected);
|
||||
return a !== null && a === b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation policy: a privileged window may only ever stay on the exact
|
||||
* page it was created with. Everything else — remote URLs, other local
|
||||
* files, javascript:, data: — is denied.
|
||||
*/
|
||||
function navigationAllowed(targetUrl, page) {
|
||||
return isAppPageUrl(targetUrl, page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission policy for Electron sessions. Display capture (and the media
|
||||
* permission that getDisplayMedia consults) is granted only to the dedicated
|
||||
* hidden capture-worker page. Everything else is denied for everyone,
|
||||
* including our own main window.
|
||||
*/
|
||||
function permissionAllowed(permission, requestingUrl) {
|
||||
if (permission === 'media' || permission === 'display-capture') {
|
||||
return isAppPageUrl(requestingUrl, 'captureWorker');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* External-link policy: only well-formed http(s) and mailto URLs may reach
|
||||
* shell.openExternal. Returns the normalized URL string or null.
|
||||
*/
|
||||
function validateExternalUrl(rawUrl) {
|
||||
if (typeof rawUrl !== 'string' || rawUrl.length > 2048) return null;
|
||||
let url;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol === 'https:' || url.protocol === 'http:') {
|
||||
if (!url.hostname) return null;
|
||||
return url.href;
|
||||
}
|
||||
if (url.protocol === 'mailto:') return url.href;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* IPC sender guard: an invoke event is trusted only when it originates from
|
||||
* the current main window's top frame, and that frame is our index.html.
|
||||
* Destroyed frames, subframes, other windows, and navigated-away frames are
|
||||
* all rejected.
|
||||
*/
|
||||
function makeIpcSenderGuard({ getMainWebContents }) {
|
||||
return function trustedSender(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
const expected = getMainWebContents();
|
||||
if (!expected || event.sender !== expected) return false;
|
||||
const frame = event.senderFrame;
|
||||
if (!frame) return false;
|
||||
try {
|
||||
if (frame.parent) return false; // top frame only
|
||||
return isAppPageUrl(frame.url, 'main');
|
||||
} catch {
|
||||
// Accessing a disposed WebFrameMain throws — reject.
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap recursive payload budget: sums string lengths (and key counts) with
|
||||
* early exit. Protects handlers from absurd payloads without JSON.stringify
|
||||
* on hundred-megabyte image saves.
|
||||
*/
|
||||
function payloadWithinBudget(value, maxChars, depth = 0) {
|
||||
let budget = maxChars;
|
||||
|
||||
const walk = (v, d) => {
|
||||
if (budget < 0 || d > 16) return false;
|
||||
if (v == null) return true;
|
||||
const t = typeof v;
|
||||
if (t === 'string') {
|
||||
budget -= v.length;
|
||||
return budget >= 0;
|
||||
}
|
||||
if (t === 'number' || t === 'boolean') {
|
||||
budget -= 8;
|
||||
return budget >= 0;
|
||||
}
|
||||
if (t === 'function' || t === 'symbol' || t === 'bigint') return false;
|
||||
if (Array.isArray(v)) {
|
||||
if (v.length > 100000) return false;
|
||||
for (const item of v) if (!walk(item, d + 1)) return false;
|
||||
return true;
|
||||
}
|
||||
if (t === 'object') {
|
||||
const keys = Object.keys(v);
|
||||
if (keys.length > 4096) return false;
|
||||
for (const key of keys) {
|
||||
budget -= key.length;
|
||||
if (budget < 0) return false;
|
||||
if (!walk(v[key], d + 1)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return walk(value, depth);
|
||||
}
|
||||
|
||||
/** True for plain-object argument bags (what every IPC channel expects). */
|
||||
function isPlainArgs(args) {
|
||||
if (args === undefined || args === null) return true;
|
||||
if (typeof args !== 'object' || Array.isArray(args)) return false;
|
||||
const proto = Object.getPrototypeOf(args);
|
||||
return proto === Object.prototype || proto === null;
|
||||
}
|
||||
|
||||
// ---- field validators (used by main.js per-channel checks) ----------------
|
||||
|
||||
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
|
||||
const check = {
|
||||
/** Guide/step/folder/template identifiers and snapshot/trash entry names:
|
||||
* single path segment, no separators, no dot-dot, sane length. */
|
||||
id(v) {
|
||||
return typeof v === 'string' && ID_RE.test(v) && !v.includes('..');
|
||||
},
|
||||
optionalId(v) {
|
||||
return v === null || v === undefined || check.id(v);
|
||||
},
|
||||
string(v, max = 4096) {
|
||||
return typeof v === 'string' && v.length <= max;
|
||||
},
|
||||
optionalString(v, max = 4096) {
|
||||
return v === null || v === undefined || check.string(v, max);
|
||||
},
|
||||
bool(v) {
|
||||
return typeof v === 'boolean';
|
||||
},
|
||||
number(v, min = -1e15, max = 1e15) {
|
||||
return typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max;
|
||||
},
|
||||
optionalNumber(v, min, max) {
|
||||
return v === null || v === undefined || check.number(v, min, max);
|
||||
},
|
||||
oneOf(v, values) {
|
||||
return values.includes(v);
|
||||
},
|
||||
base64(v, maxChars = 192 * 1024 * 1024) {
|
||||
return typeof v === 'string' && v.length <= maxChars && /^[A-Za-z0-9+/=\r\n]*$/.test(v.slice(0, 4096));
|
||||
},
|
||||
optionalBase64(v, maxChars) {
|
||||
return v === null || v === undefined || check.base64(v, maxChars);
|
||||
},
|
||||
/** Single filesystem name (template/snapshot/trash entries): no path
|
||||
* separators, no traversal, no NUL, printable, bounded length. */
|
||||
fileName(v, max = 160) {
|
||||
return (
|
||||
typeof v === 'string' &&
|
||||
v.trim().length > 0 &&
|
||||
v.length <= max &&
|
||||
!/[/\\\0]/.test(v) &&
|
||||
!v.includes('..') &&
|
||||
v !== '.' &&
|
||||
// eslint-disable-next-line no-control-regex
|
||||
!/[\x00-\x1f]/.test(v)
|
||||
);
|
||||
},
|
||||
optionalFileName(v, max) {
|
||||
return v === null || v === undefined || check.fileName(v, max);
|
||||
},
|
||||
/** settings keyPath: dotted segments, no prototype-pollution segments. */
|
||||
settingsKeyPath(v) {
|
||||
if (typeof v !== 'string' || v.length === 0 || v.length > 200) return false;
|
||||
const segments = v.split('.');
|
||||
return segments.every(
|
||||
(segment) =>
|
||||
/^[A-Za-z0-9_-]+$/.test(segment) &&
|
||||
!['__proto__', 'constructor', 'prototype'].includes(segment)
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Registry of files the main process itself produced (export outputs,
|
||||
* previews) and may therefore re-open on renderer request. Bounded LRU.
|
||||
*/
|
||||
class ProducedFiles {
|
||||
constructor(limit = 256) {
|
||||
this.limit = limit;
|
||||
this.paths = new Set();
|
||||
}
|
||||
|
||||
key(p) {
|
||||
const resolved = path.resolve(String(p));
|
||||
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
||||
}
|
||||
|
||||
add(p) {
|
||||
if (!p || typeof p !== 'string') return;
|
||||
const key = this.key(p);
|
||||
this.paths.delete(key);
|
||||
this.paths.add(key);
|
||||
while (this.paths.size > this.limit) {
|
||||
this.paths.delete(this.paths.values().next().value);
|
||||
}
|
||||
}
|
||||
|
||||
has(p) {
|
||||
if (!p || typeof p !== 'string') return false;
|
||||
return this.paths.has(this.key(p));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the navigation/popup policy to a BrowserWindow. `page` names the
|
||||
* app page this window is allowed to display (key of APP_PAGES).
|
||||
*/
|
||||
function installWindowSecurity(win, page) {
|
||||
const contents = win.webContents;
|
||||
contents.setWindowOpenHandler(() => ({ action: 'deny' }));
|
||||
contents.on('will-navigate', (event, url) => {
|
||||
if (!navigationAllowed(url, page)) event.preventDefault();
|
||||
});
|
||||
contents.on('will-frame-navigate', (details) => {
|
||||
if (!navigationAllowed(details.url, page) && typeof details.preventDefault === 'function') {
|
||||
details.preventDefault();
|
||||
}
|
||||
});
|
||||
// Defense in depth: if a disallowed document somehow starts loading,
|
||||
// never let it attach webviews either.
|
||||
contents.on('will-attach-webview', (event) => event.preventDefault());
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
APP_PAGES,
|
||||
ProducedFiles,
|
||||
check,
|
||||
installWindowSecurity,
|
||||
isAppPageUrl,
|
||||
isPlainArgs,
|
||||
makeIpcSenderGuard,
|
||||
navigationAllowed,
|
||||
normalizeAppUrl,
|
||||
payloadWithinBudget,
|
||||
permissionAllowed,
|
||||
validateExternalUrl,
|
||||
};
|
||||
@@ -343,11 +343,14 @@ async function createElectronHost(onEvent) {
|
||||
preload: path.join(__dirname, 'capture-worker-preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
// The worker must keep sampling while hidden — throttling a hidden
|
||||
// window is exactly the wrong default for a frame recorder.
|
||||
backgroundThrottling: false,
|
||||
},
|
||||
});
|
||||
// The worker may only display capture-worker.html; deny navigation/popups.
|
||||
require('./security').installWindowSecurity(win, 'captureWorker');
|
||||
const listener = (event, msg) => {
|
||||
if (event.sender === win.webContents) onEvent(msg);
|
||||
};
|
||||
|
||||
+150
-23
@@ -8,6 +8,7 @@ const {
|
||||
DEFAULT_CAPTURE_TITLES,
|
||||
buildCaptureTitle,
|
||||
normalizeOllamaHost,
|
||||
validateOllamaHost,
|
||||
normalizeAiPatch,
|
||||
buildAiPrompt,
|
||||
applyAiPatchToStep,
|
||||
@@ -74,9 +75,101 @@ class TextIntelService {
|
||||
this.workerQueue = Promise.resolve();
|
||||
this.ocrDataDir = path.join(dataDir, 'ocr', 'eng');
|
||||
this.modelCapabilityCache = new Map();
|
||||
// In-flight AI request controllers, grouped by guide, so closing a guide
|
||||
// (or quitting) can cancel outstanding requests instead of leaving them
|
||||
// to resolve against stale data.
|
||||
this.inflight = new Set();
|
||||
// Bounded concurrency for AI network calls.
|
||||
this.maxConcurrent = 2;
|
||||
this.activeCount = 0;
|
||||
this.waitQueue = [];
|
||||
}
|
||||
|
||||
aiNetworkOptions() {
|
||||
const ai = this.settings.get('ai') || {};
|
||||
const timeoutMs = Number.isFinite(ai.timeoutMs) && ai.timeoutMs > 0 ? ai.timeoutMs : 60000;
|
||||
const maxImageBytes = Number.isFinite(ai.maxImageBytes) && ai.maxImageBytes > 0
|
||||
? ai.maxImageBytes
|
||||
: 12 * 1024 * 1024;
|
||||
return {
|
||||
allowRemote: Boolean(ai.allowRemoteHost),
|
||||
attachScreenshots: ai.attachScreenshots !== false,
|
||||
timeoutMs,
|
||||
maxImageBytes,
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve the endpoint against the local-first policy or throw a clear error.
|
||||
resolveHost(host) {
|
||||
const { allowRemote } = this.aiNetworkOptions();
|
||||
const result = validateOllamaHost(host, { allowRemote });
|
||||
if (!result.ok) {
|
||||
const err = new Error(result.reason);
|
||||
err.code = 'STEPFORGE_AI_HOST_BLOCKED';
|
||||
throw err;
|
||||
}
|
||||
return result.host;
|
||||
}
|
||||
|
||||
// fetch with a hard deadline and cooperative cancellation. Every AI network
|
||||
// call goes through here so a dead endpoint can never hang the UI.
|
||||
async fetchJson(url, { method = 'GET', body = null, guideId = null } = {}) {
|
||||
const { timeoutMs } = this.aiNetworkOptions();
|
||||
const controller = new AbortController();
|
||||
if (guideId) controller._guideId = guideId;
|
||||
this.inflight.add(controller);
|
||||
const timer = setTimeout(() => controller.abort(new Error('timeout')), timeoutMs);
|
||||
try {
|
||||
const res = await this.fetch(url, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
return res;
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) {
|
||||
// The abort reason distinguishes an explicit cancel from a timeout.
|
||||
const reasonMsg = controller.signal.reason && controller.signal.reason.message;
|
||||
const cancelled = reasonMsg === 'cancelled';
|
||||
const wrapped = new Error(cancelled ? 'AI request cancelled.' : 'AI request timed out.');
|
||||
wrapped.code = 'STEPFORGE_AI_ABORTED';
|
||||
throw wrapped;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
this.inflight.delete(controller);
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel in-flight AI requests, optionally scoped to one guide.
|
||||
cancelInflight(guideId = null) {
|
||||
for (const controller of [...this.inflight]) {
|
||||
if (!guideId || controller._guideId === guideId) {
|
||||
try { controller.abort(new Error('cancelled')); } catch { /* already settled */ }
|
||||
this.inflight.delete(controller);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bounded-concurrency gate for AI network work.
|
||||
async withConcurrency(fn) {
|
||||
if (this.activeCount >= this.maxConcurrent) {
|
||||
await new Promise((resolve) => this.waitQueue.push(resolve));
|
||||
}
|
||||
this.activeCount += 1;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.activeCount -= 1;
|
||||
const next = this.waitQueue.shift();
|
||||
if (next) next();
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
this.cancelInflight();
|
||||
if (this.worker) {
|
||||
try {
|
||||
await this.worker.terminate();
|
||||
@@ -374,8 +467,19 @@ public static class Win32 {
|
||||
if (!config.ollama.host) {
|
||||
return { ok: false, reason: 'Set an Ollama host first.' };
|
||||
}
|
||||
const tagsUrl = new URL('/api/tags', `${config.ollama.host.replace(/\/+$/, '')}/`);
|
||||
const res = await this.fetch(tagsUrl, { method: 'GET' });
|
||||
let host;
|
||||
try {
|
||||
host = this.resolveHost(config.ollama.host);
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err.message };
|
||||
}
|
||||
const tagsUrl = new URL('/api/tags', `${host.replace(/\/+$/, '')}/`);
|
||||
let res;
|
||||
try {
|
||||
res = await this.fetchJson(tagsUrl, { method: 'GET' });
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err.message };
|
||||
}
|
||||
if (!res.ok) {
|
||||
return { ok: false, reason: `Ollama check failed (${res.status})` };
|
||||
}
|
||||
@@ -383,7 +487,7 @@ public static class Win32 {
|
||||
const models = Array.isArray(data?.models) ? data.models.map((model) => model.name).filter(Boolean) : [];
|
||||
const installed = config.ollama.model ? models.includes(config.ollama.model) : false;
|
||||
const vision = installed ? await this.modelSupportsVision({
|
||||
host: config.ollama.host,
|
||||
host,
|
||||
model: config.ollama.model,
|
||||
}) : false;
|
||||
return {
|
||||
@@ -391,7 +495,7 @@ public static class Win32 {
|
||||
installed,
|
||||
vision,
|
||||
models,
|
||||
host: config.ollama.host,
|
||||
host,
|
||||
model: config.ollama.model,
|
||||
};
|
||||
}
|
||||
@@ -407,10 +511,9 @@ public static class Win32 {
|
||||
const url = new URL('/api/show', `${normalizedHost.replace(/\/+$/, '')}/`);
|
||||
let capabilities = [];
|
||||
try {
|
||||
const response = await this.fetch(url, {
|
||||
const response = await this.fetchJson(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: normalizedModel }),
|
||||
body: { model: normalizedModel },
|
||||
});
|
||||
if (response.ok) {
|
||||
const payload = await response.json();
|
||||
@@ -439,12 +542,12 @@ public static class Win32 {
|
||||
return fs.readFileSync(imagePath).toString('base64');
|
||||
}
|
||||
|
||||
async callOllamaText({ host, model, prompt, systemPrompt }) {
|
||||
async callOllamaText({ host, model, prompt, systemPrompt, guideId = null }) {
|
||||
const url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`);
|
||||
const response = await this.fetch(url, {
|
||||
const response = await this.withConcurrency(() => this.fetchJson(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
guideId,
|
||||
body: {
|
||||
model,
|
||||
stream: false,
|
||||
messages: [
|
||||
@@ -452,8 +555,8 @@ public static class Win32 {
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
options: { temperature: 0.4 },
|
||||
}),
|
||||
});
|
||||
},
|
||||
}));
|
||||
if (!response.ok) throw new Error(`Ollama request failed (${response.status})`);
|
||||
const payload = await response.json();
|
||||
const content = payload?.message?.content;
|
||||
@@ -461,16 +564,16 @@ public static class Win32 {
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
async callOllama({ host, model, prompt, systemPrompt, images = [] }) {
|
||||
async callOllama({ host, model, prompt, systemPrompt, images = [], guideId = null }) {
|
||||
const url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`);
|
||||
const userMessage = { role: 'user', content: prompt };
|
||||
if (Array.isArray(images) && images.length) {
|
||||
userMessage.images = images;
|
||||
}
|
||||
const response = await this.fetch(url, {
|
||||
const response = await this.withConcurrency(() => this.fetchJson(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
guideId,
|
||||
body: {
|
||||
model,
|
||||
stream: false,
|
||||
format: 'json',
|
||||
@@ -481,8 +584,8 @@ public static class Win32 {
|
||||
options: {
|
||||
temperature: 0.2,
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
}));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama request failed (${response.status})`);
|
||||
}
|
||||
@@ -508,6 +611,13 @@ public static class Win32 {
|
||||
if (!config.ollama.host || !config.ollama.model) {
|
||||
return { ok: false, reason: 'Configure Ollama host and model in Settings.' };
|
||||
}
|
||||
let host;
|
||||
try {
|
||||
host = this.resolveHost(config.ollama.host);
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err.message };
|
||||
}
|
||||
const netOptions = this.aiNetworkOptions();
|
||||
|
||||
const guide = this.store.getGuide(guideId);
|
||||
const step = this.store.getStep(guideId, stepId);
|
||||
@@ -522,10 +632,20 @@ public static class Win32 {
|
||||
return { ok: false, reason: 'Block not found.' };
|
||||
}
|
||||
|
||||
const screenshotBase64 = step.image ? this.readStepImageBase64(guideId, stepId) : '';
|
||||
// Only attach a screenshot when the user allows it, the model can use
|
||||
// it, and it is within the size budget (a full 4K PNG base64-expands to
|
||||
// tens of MB in the request body).
|
||||
let screenshotBase64 = '';
|
||||
if (netOptions.attachScreenshots && step.image) {
|
||||
const candidate = this.readStepImageBase64(guideId, stepId);
|
||||
const bytes = candidate ? Math.floor((candidate.length * 3) / 4) : 0;
|
||||
if (candidate && bytes <= netOptions.maxImageBytes) {
|
||||
screenshotBase64 = candidate;
|
||||
}
|
||||
}
|
||||
const screenshotAttached = Boolean(screenshotBase64)
|
||||
? await this.modelSupportsVision({
|
||||
host: config.ollama.host,
|
||||
host,
|
||||
model: config.ollama.model,
|
||||
})
|
||||
: false;
|
||||
@@ -588,11 +708,12 @@ public static class Win32 {
|
||||
});
|
||||
|
||||
const raw = await this.callOllama({
|
||||
host: config.ollama.host,
|
||||
host,
|
||||
model: config.ollama.model,
|
||||
prompt,
|
||||
systemPrompt,
|
||||
images: screenshotAttached ? [screenshotBase64] : [],
|
||||
guideId,
|
||||
});
|
||||
const patch = normalizeAiPatch(raw);
|
||||
const updated = applyAiPatchToStep(step, patch, { target, blockId });
|
||||
@@ -610,6 +731,12 @@ public static class Win32 {
|
||||
if (!config.ollama.host || !config.ollama.model) {
|
||||
return { ok: false, reason: 'Configure Ollama host and model in Settings.' };
|
||||
}
|
||||
let host;
|
||||
try {
|
||||
host = this.resolveHost(config.ollama.host);
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err.message };
|
||||
}
|
||||
const trimmed = normalizeWhitespace(text);
|
||||
if (!trimmed) return { ok: false, reason: 'No text to rewrite.' };
|
||||
|
||||
@@ -628,7 +755,7 @@ public static class Win32 {
|
||||
].filter((l) => l !== null).join('\n');
|
||||
|
||||
const result = await this.callOllamaText({
|
||||
host: config.ollama.host,
|
||||
host,
|
||||
model: config.ollama.model,
|
||||
prompt,
|
||||
systemPrompt: 'You are a documentation editor. Return only the improved text, nothing else.',
|
||||
|
||||
@@ -45,6 +45,13 @@ const DEFAULT_SETTINGS = {
|
||||
// user is likely to click so the buffer holds frames of the now-visible
|
||||
// screen rather than the just-dismissed app window.
|
||||
postHideSettleMs: 150,
|
||||
// Raw typed-text capture. When true, printable characters typed between
|
||||
// captures are buffered and stored in step capture metadata (and can be
|
||||
// sent to a configured AI host). This can record passwords or other
|
||||
// secrets, so it is OFF by default and must be explicitly opted into.
|
||||
// Shortcut detection (Ctrl+T, Enter, …) is unaffected — only raw
|
||||
// character logging is gated here.
|
||||
captureTypedText: false,
|
||||
},
|
||||
editor: {
|
||||
focusedViewDefaultForNewSteps: false,
|
||||
@@ -52,6 +59,22 @@ const DEFAULT_SETTINGS = {
|
||||
},
|
||||
ai: {
|
||||
enabled: false,
|
||||
// Auto-document captured steps in the background when a session capture
|
||||
// lands. Requires enabled + a reachable model.
|
||||
autoDoc: false,
|
||||
// Local-first: only a loopback Ollama endpoint is contacted unless this
|
||||
// is explicitly turned on. Turning it on sends screenshots and text to
|
||||
// the configured remote host.
|
||||
allowRemoteHost: false,
|
||||
// Attach the step screenshot to AI requests (only for vision-capable
|
||||
// models). Turning this off keeps requests text-only.
|
||||
attachScreenshots: true,
|
||||
// Per-request network deadline (ms). A dead endpoint fails fast instead
|
||||
// of leaving UI actions pending forever.
|
||||
timeoutMs: 60000,
|
||||
// Skip attaching a screenshot larger than this many bytes (pre-base64)
|
||||
// to avoid multi-hundred-MB request bodies.
|
||||
maxImageBytes: 12 * 1024 * 1024,
|
||||
ollama: {
|
||||
host: 'http://127.0.0.1:11434',
|
||||
model: 'llama3.2:1b',
|
||||
|
||||
@@ -538,6 +538,60 @@ function normalizeOllamaHost(host) {
|
||||
return `http://${raw.replace(/\/+$/, '')}`;
|
||||
}
|
||||
|
||||
// A hostname/IP that refers to this machine only. StepForge is local-first:
|
||||
// by default the Ollama endpoint must be loopback so screenshots and text
|
||||
// never leave the device, unless the user explicitly opts into a remote host.
|
||||
function isLoopbackHost(host) {
|
||||
const normalized = normalizeOllamaHost(host);
|
||||
if (!normalized) return false;
|
||||
let url;
|
||||
try {
|
||||
url = new URL(normalized);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const name = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
if (name === 'localhost' || name === '::1' || name === '0.0.0.0' || name === '::') return true;
|
||||
// IPv4 loopback block 127.0.0.0/8.
|
||||
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(name);
|
||||
if (m && Number(m[1]) === 127 && m.slice(1).every((o) => Number(o) >= 0 && Number(o) <= 255)) {
|
||||
return true;
|
||||
}
|
||||
// IPv4-mapped IPv6 loopback, e.g. ::ffff:127.0.0.1.
|
||||
if (/^::ffff:127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(name)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a configured Ollama endpoint against the local-first policy.
|
||||
* Returns { ok, host, reason }. Remote hosts are rejected unless the caller
|
||||
* passes allowRemote: true (the explicit ai.allowRemoteHost opt-in).
|
||||
*/
|
||||
function validateOllamaHost(host, { allowRemote = false } = {}) {
|
||||
const normalized = normalizeOllamaHost(host);
|
||||
if (!normalized) return { ok: false, host: '', reason: 'No Ollama host configured.' };
|
||||
let url;
|
||||
try {
|
||||
url = new URL(normalized);
|
||||
} catch {
|
||||
return { ok: false, host: normalized, reason: 'Ollama host is not a valid URL.' };
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return { ok: false, host: normalized, reason: 'Ollama host must use http or https.' };
|
||||
}
|
||||
if (!allowRemote && !isLoopbackHost(normalized)) {
|
||||
return {
|
||||
ok: false,
|
||||
host: normalized,
|
||||
reason:
|
||||
'Remote Ollama hosts are disabled. StepForge only contacts a local (loopback) ' +
|
||||
'Ollama by default. Enable "Allow remote AI host" in AI settings to send ' +
|
||||
'screenshots and text to this host.',
|
||||
};
|
||||
}
|
||||
return { ok: true, host: normalized, reason: '' };
|
||||
}
|
||||
|
||||
function normalizeAiLevel(level) {
|
||||
const key = normalizeWhitespace(level).toLowerCase();
|
||||
return AI_LEVEL_ALIASES.get(key) || (TEXTBLOCK_LEVELS.includes(key) ? key : 'info');
|
||||
@@ -845,6 +899,8 @@ module.exports = {
|
||||
buildCaptureTitle,
|
||||
plainTextToHtml,
|
||||
normalizeOllamaHost,
|
||||
isLoopbackHost,
|
||||
validateOllamaHost,
|
||||
normalizeAiPatch,
|
||||
buildAiPrompt,
|
||||
applyAiPatchToStep,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# StepForge privacy and network contract
|
||||
|
||||
StepForge is **local-first**. Guides, screenshots, and settings live on your
|
||||
machine and are never uploaded on their own. This document describes exactly
|
||||
what data StepForge collects locally and the one situation in which data
|
||||
leaves your device.
|
||||
|
||||
## What never happens
|
||||
|
||||
- No telemetry or analytics.
|
||||
- No update checks, license checks, or "phone home".
|
||||
- No cloud storage or sync.
|
||||
- No dependency downloads at runtime (dependencies are installed only by you,
|
||||
via `npm ci`).
|
||||
|
||||
## Data StepForge collects locally
|
||||
|
||||
When you capture a step, StepForge may record, **stored only on disk in your
|
||||
data directory**, capture context to help title and describe the step:
|
||||
|
||||
- The screenshot image.
|
||||
- OCR text read from the region around your click (via the bundled Tesseract
|
||||
engine — this runs locally, it is not a network call).
|
||||
- The foreground window title and application name.
|
||||
- The accessibility label/role/value of the clicked UI element (Windows).
|
||||
- Keyboard shortcuts you pressed (for example `Ctrl+T`).
|
||||
|
||||
### Raw typed text is OFF by default
|
||||
|
||||
StepForge can additionally record the **raw printable characters** you type
|
||||
between captures. Because this can capture passwords or other secrets, it is
|
||||
**disabled by default**. It is only recorded when you explicitly enable
|
||||
`capture.captureTypedText`, and even then the characters are used only to
|
||||
title the current step and are not retained beyond it. With the setting off,
|
||||
raw characters are never read or stored (on Windows they never even leave the
|
||||
keyboard-hook process).
|
||||
|
||||
## The one outbound feature: optional AI
|
||||
|
||||
StepForge has an **optional** AI integration that generates step titles and
|
||||
descriptions with a local large-language-model runtime
|
||||
([Ollama](https://ollama.com)). It is **off by default**. When you turn it on
|
||||
and configure an endpoint:
|
||||
|
||||
- StepForge sends the step **screenshot** (only to vision-capable models, only
|
||||
when "Attach screenshots" is on, and only if within the size limit) and the
|
||||
step **text/capture context** to the configured Ollama endpoint.
|
||||
- By default the endpoint must be a **local (loopback) address** — for example
|
||||
`http://127.0.0.1:11434`. StepForge refuses to send data to a non-loopback
|
||||
host unless you explicitly enable **"Allow remote AI host"**. Enabling that
|
||||
option means your screenshots and text are sent to the remote host you
|
||||
configured; StepForge cannot control what that host does with them.
|
||||
- Every AI request has a timeout, can be cancelled (closing the guide cancels
|
||||
in-flight requests), and runs under a bounded concurrency limit.
|
||||
|
||||
## Bundled dependencies
|
||||
|
||||
Beyond the Electron desktop shell, StepForge bundles the Tesseract OCR engine
|
||||
and its English language data as production dependencies. All OCR runs locally.
|
||||
|
||||
## Where your data lives
|
||||
|
||||
- Windows: `%APPDATA%\stepforge`
|
||||
- Linux: `~/.local/share/stepforge` (or `$XDG_DATA_HOME/stepforge`)
|
||||
- Override with the `STEPFORGE_DATA_DIR` environment variable.
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "stepforge",
|
||||
"version": "0.3.2",
|
||||
"buildVersion": "0.3.2.1",
|
||||
"description": "Fully offline desktop tool for capturing, annotating, and exporting step-by-step guides.",
|
||||
"description": "Local-first desktop tool for capturing, annotating, and exporting step-by-step guides, with an optional user-configured local AI integration.",
|
||||
"main": "app/main.js",
|
||||
"author": "StepForge [email protected]",
|
||||
"license": "MPL-2.0",
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { makeTmpDir, rmrf } = require('./helpers');
|
||||
const { isLoopbackHost, validateOllamaHost } = require('../../core/text-intel');
|
||||
const { TextIntelService } = require('../../app/text-intel');
|
||||
const CaptureService = require('../../app/capture');
|
||||
|
||||
function makeSettings(ai = {}) {
|
||||
const data = {
|
||||
ai: {
|
||||
enabled: true,
|
||||
allowRemoteHost: false,
|
||||
attachScreenshots: true,
|
||||
timeoutMs: 60000,
|
||||
maxImageBytes: 12 * 1024 * 1024,
|
||||
ollama: { host: 'http://127.0.0.1:11434', model: 'llama3.2:1b' },
|
||||
...ai,
|
||||
ollama: { host: 'http://127.0.0.1:11434', model: 'llama3.2:1b', ...(ai.ollama || {}) },
|
||||
},
|
||||
};
|
||||
return {
|
||||
get(key) {
|
||||
return key.split('.').reduce((acc, part) => (acc == null ? undefined : acc[part]), data);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- loopback policy (pure core) -------------------------------------------
|
||||
|
||||
test('isLoopbackHost recognizes local addresses and rejects remote ones', () => {
|
||||
for (const host of [
|
||||
'http://127.0.0.1:11434',
|
||||
'127.0.0.1',
|
||||
'localhost:11434',
|
||||
'http://[::1]:11434',
|
||||
'http://127.5.6.7',
|
||||
'0.0.0.0',
|
||||
]) {
|
||||
assert.equal(isLoopbackHost(host), true, `loopback: ${host}`);
|
||||
}
|
||||
for (const host of [
|
||||
'http://10.0.0.5:11434',
|
||||
'http://192.168.1.20',
|
||||
'https://ollama.example.com',
|
||||
'http://8.8.8.8',
|
||||
'http://ollama.internal',
|
||||
]) {
|
||||
assert.equal(isLoopbackHost(host), false, `remote: ${host}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('validateOllamaHost blocks remote hosts unless explicitly allowed', () => {
|
||||
assert.equal(validateOllamaHost('http://127.0.0.1:11434').ok, true);
|
||||
const blocked = validateOllamaHost('http://10.0.0.5:11434');
|
||||
assert.equal(blocked.ok, false);
|
||||
assert.match(blocked.reason, /remote/i);
|
||||
assert.equal(validateOllamaHost('http://10.0.0.5:11434', { allowRemote: true }).ok, true);
|
||||
assert.equal(validateOllamaHost('').ok, false);
|
||||
assert.equal(validateOllamaHost('ftp://127.0.0.1').ok, false);
|
||||
});
|
||||
|
||||
// ---- host policy enforced by the service -----------------------------------
|
||||
|
||||
test('AI connection test refuses a remote host without the opt-in', async (t) => {
|
||||
const root = makeTmpDir('ai-remote-block');
|
||||
t.after(() => rmrf(root));
|
||||
let called = false;
|
||||
const service = new TextIntelService({
|
||||
store: { settingsDir: root },
|
||||
settings: makeSettings({ ollama: { host: 'http://10.0.0.5:11434', model: 'llama3.2:1b' } }),
|
||||
dataDir: root,
|
||||
fetchImpl: async () => { called = true; return { ok: true, json: async () => ({}) }; },
|
||||
});
|
||||
const result = await service.testAiConnection();
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.reason, /remote/i);
|
||||
assert.equal(called, false, 'must not contact a blocked host at all');
|
||||
});
|
||||
|
||||
test('AI connection test allows a remote host with the opt-in', async (t) => {
|
||||
const root = makeTmpDir('ai-remote-allow');
|
||||
t.after(() => rmrf(root));
|
||||
const service = new TextIntelService({
|
||||
store: { settingsDir: root },
|
||||
settings: makeSettings({
|
||||
allowRemoteHost: true,
|
||||
ollama: { host: 'http://10.0.0.5:11434', model: 'llama3.2:1b' },
|
||||
}),
|
||||
dataDir: root,
|
||||
fetchImpl: async (url) => {
|
||||
const pathname = new URL(url).pathname;
|
||||
if (pathname === '/api/tags') return { ok: true, json: async () => ({ models: [{ name: 'llama3.2:1b' }] }) };
|
||||
if (pathname === '/api/show') return { ok: true, json: async () => ({ capabilities: ['vision'] }) };
|
||||
throw new Error(`unexpected ${pathname}`);
|
||||
},
|
||||
});
|
||||
const result = await service.testAiConnection();
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.host, 'http://10.0.0.5:11434');
|
||||
});
|
||||
|
||||
// ---- request timeout / cancellation ----------------------------------------
|
||||
|
||||
test('a hung endpoint times out instead of hanging forever', async (t) => {
|
||||
const root = makeTmpDir('ai-timeout');
|
||||
t.after(() => rmrf(root));
|
||||
const service = new TextIntelService({
|
||||
store: { settingsDir: root },
|
||||
settings: makeSettings({ timeoutMs: 40 }),
|
||||
dataDir: root,
|
||||
fetchImpl: (url, init) => new Promise((resolve, reject) => {
|
||||
// Never resolves on its own; only the abort signal ends it.
|
||||
init.signal.addEventListener('abort', () => {
|
||||
const err = new Error('aborted');
|
||||
err.name = 'AbortError';
|
||||
reject(err);
|
||||
});
|
||||
}),
|
||||
});
|
||||
const result = await service.testAiConnection();
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.reason, /timed out|timeout/i);
|
||||
});
|
||||
|
||||
test('cancelInflight aborts an outstanding request', async (t) => {
|
||||
const root = makeTmpDir('ai-cancel');
|
||||
t.after(() => rmrf(root));
|
||||
const service = new TextIntelService({
|
||||
store: { settingsDir: root },
|
||||
settings: makeSettings({ timeoutMs: 60000 }),
|
||||
dataDir: root,
|
||||
fetchImpl: (url, init) => new Promise((resolve, reject) => {
|
||||
init.signal.addEventListener('abort', () => {
|
||||
const err = new Error('aborted');
|
||||
err.name = 'AbortError';
|
||||
reject(err);
|
||||
});
|
||||
}),
|
||||
});
|
||||
const pending = service.testAiConnection();
|
||||
// Give the request a tick to register in the inflight set.
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(service.inflight.size, 1);
|
||||
service.cancelInflight();
|
||||
const result = await pending;
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.reason, /cancel/i);
|
||||
});
|
||||
|
||||
// ---- typed-text capture is off by default ----------------------------------
|
||||
|
||||
function makeCaptureService(settingsOverrides = {}) {
|
||||
const settingsData = { 'capture.mode': 'fullscreen', ...settingsOverrides };
|
||||
return new CaptureService({
|
||||
store: {},
|
||||
settings: { get: (k) => (k in settingsData ? settingsData[k] : null) },
|
||||
getWindow: () => null,
|
||||
notify: () => {},
|
||||
screenApi: { getCursorScreenPoint: () => ({ x: 0, y: 0 }), getAllDisplays: () => [] },
|
||||
});
|
||||
}
|
||||
|
||||
test('raw typed text is ignored unless explicitly enabled', () => {
|
||||
const off = makeCaptureService();
|
||||
off.onKeyboardEvent('CHAR', 'h'.charCodeAt(0), Date.now());
|
||||
off.onKeyboardEvent('CHAR', 'i'.charCodeAt(0), Date.now());
|
||||
assert.equal(off.snapshotKeyContext().recentTyped, '', 'typed text must not be buffered by default');
|
||||
|
||||
const on = makeCaptureService({ 'capture.captureTypedText': true });
|
||||
on.onKeyboardEvent('CHAR', 'h'.charCodeAt(0), Date.now());
|
||||
on.onKeyboardEvent('CHAR', 'i'.charCodeAt(0), Date.now());
|
||||
assert.equal(on.snapshotKeyContext().recentTyped, 'hi');
|
||||
});
|
||||
|
||||
test('shortcut detection still works with typed text disabled', () => {
|
||||
const off = makeCaptureService();
|
||||
off.onKeyboardEvent('KEY', 'Ctrl+T', Date.now());
|
||||
assert.equal(off.snapshotKeyContext().recentShortcut, 'Ctrl+T');
|
||||
});
|
||||
|
||||
// ---- source-level guards ----------------------------------------------------
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const ROOT = path.resolve(__dirname, '..', '..');
|
||||
|
||||
test('the Windows keyboard hook only emits characters when opted in', () => {
|
||||
const src = fs.readFileSync(path.join(ROOT, 'app/capture.js'), 'utf8');
|
||||
// The CHAR emission must be guarded by the opt-in flag threaded into C#.
|
||||
assert.match(src, /CaptureTypedText = \$\{captureTypedText\}/);
|
||||
assert.match(src, /else if \(CaptureTypedText\) \{/);
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const security = require('../../app/security');
|
||||
|
||||
const MAIN_URL = security.APP_PAGES.main;
|
||||
const WORKER_URL = security.APP_PAGES.captureWorker;
|
||||
const REGION_URL = security.APP_PAGES.region;
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..', '..');
|
||||
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
||||
|
||||
// ---- navigation policy -----------------------------------------------------
|
||||
|
||||
test('navigation: a window may only stay on its own app page', () => {
|
||||
assert.equal(security.navigationAllowed(MAIN_URL, 'main'), true);
|
||||
assert.equal(security.navigationAllowed(`${MAIN_URL}?q=1#frag`, 'main'), true);
|
||||
assert.equal(security.navigationAllowed(REGION_URL, 'region'), true);
|
||||
|
||||
// Hostile / cross-page navigations are all denied.
|
||||
for (const target of [
|
||||
'https://evil.example/phish.html',
|
||||
'http://127.0.0.1:8080/',
|
||||
'javascript:alert(1)',
|
||||
'data:text/html,<script>1</script>',
|
||||
'about:blank',
|
||||
REGION_URL, // a *different* app page is still a denial for 'main'
|
||||
'file:///etc/passwd',
|
||||
'not a url',
|
||||
'',
|
||||
]) {
|
||||
assert.equal(security.navigationAllowed(target, 'main'), false, `should deny ${target}`);
|
||||
}
|
||||
assert.equal(security.navigationAllowed(MAIN_URL, 'nonexistent-page'), false);
|
||||
});
|
||||
|
||||
// ---- permission policy -----------------------------------------------------
|
||||
|
||||
test('permissions: display capture only for the capture worker, nothing else for anyone', () => {
|
||||
assert.equal(security.permissionAllowed('display-capture', WORKER_URL), true);
|
||||
assert.equal(security.permissionAllowed('media', WORKER_URL), true);
|
||||
|
||||
// The main window gets nothing, including display capture.
|
||||
assert.equal(security.permissionAllowed('display-capture', MAIN_URL), false);
|
||||
assert.equal(security.permissionAllowed('media', MAIN_URL), false);
|
||||
|
||||
// Everything else is denied even for the worker.
|
||||
for (const permission of ['geolocation', 'notifications', 'clipboard-read', 'openExternal', 'fullscreen', 'pointerLock', 'hid', 'usb', 'serial']) {
|
||||
assert.equal(security.permissionAllowed(permission, WORKER_URL), false, permission);
|
||||
}
|
||||
|
||||
// Remote origins never get anything.
|
||||
assert.equal(security.permissionAllowed('display-capture', 'https://evil.example/'), false);
|
||||
assert.equal(security.permissionAllowed('media', undefined), false);
|
||||
});
|
||||
|
||||
// ---- external URL policy ---------------------------------------------------
|
||||
|
||||
test('external links: only well-formed http(s)/mailto pass', () => {
|
||||
assert.equal(security.validateExternalUrl('https://example.com/docs'), 'https://example.com/docs');
|
||||
assert.equal(security.validateExternalUrl('http://example.com'), 'http://example.com/');
|
||||
assert.match(security.validateExternalUrl('mailto:[email protected]'), /^mailto:/);
|
||||
|
||||
for (const url of [
|
||||
'javascript:alert(1)',
|
||||
'file:///etc/passwd',
|
||||
'data:text/html,x',
|
||||
'ftp://example.com/x',
|
||||
'smb://server/share',
|
||||
'chrome://settings',
|
||||
'vbscript:x',
|
||||
'https://',
|
||||
'not a url',
|
||||
123,
|
||||
null,
|
||||
`https://example.com/${'a'.repeat(3000)}`,
|
||||
]) {
|
||||
assert.equal(security.validateExternalUrl(url), null, `should reject ${String(url).slice(0, 60)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- IPC sender guard --------------------------------------------------------
|
||||
|
||||
function makeGuard(mainWc) {
|
||||
return security.makeIpcSenderGuard({ getMainWebContents: () => mainWc });
|
||||
}
|
||||
|
||||
test('ipc guard: accepts only the main window top frame on index.html', () => {
|
||||
const wc = { id: 1 };
|
||||
const guard = makeGuard(wc);
|
||||
|
||||
assert.equal(guard({ sender: wc, senderFrame: { parent: null, url: MAIN_URL } }), true);
|
||||
});
|
||||
|
||||
test('ipc guard: rejects other windows, subframes, navigated and disposed frames', () => {
|
||||
const wc = { id: 1 };
|
||||
const otherWc = { id: 2 };
|
||||
const guard = makeGuard(wc);
|
||||
|
||||
// Different webContents (popup, worker, region overlay).
|
||||
assert.equal(guard({ sender: otherWc, senderFrame: { parent: null, url: MAIN_URL } }), false);
|
||||
// Subframe of our own window.
|
||||
assert.equal(guard({ sender: wc, senderFrame: { parent: {}, url: MAIN_URL } }), false);
|
||||
// Frame that navigated somewhere else but kept the preload bridge.
|
||||
assert.equal(guard({ sender: wc, senderFrame: { parent: null, url: 'https://evil.example/' } }), false);
|
||||
assert.equal(guard({ sender: wc, senderFrame: { parent: null, url: WORKER_URL } }), false);
|
||||
// Disposed frame accessor throws.
|
||||
const disposed = {};
|
||||
Object.defineProperty(disposed, 'parent', { get() { throw new Error('disposed'); } });
|
||||
assert.equal(guard({ sender: wc, senderFrame: disposed }), false);
|
||||
// Missing frame or window entirely.
|
||||
assert.equal(guard({ sender: wc, senderFrame: null }), false);
|
||||
assert.equal(makeGuard(null)({ sender: wc, senderFrame: { parent: null, url: MAIN_URL } }), false);
|
||||
assert.equal(guard(null), false);
|
||||
});
|
||||
|
||||
// ---- argument hygiene --------------------------------------------------------
|
||||
|
||||
test('args must be plain object bags', () => {
|
||||
assert.equal(security.isPlainArgs({ a: 1 }), true);
|
||||
assert.equal(security.isPlainArgs(Object.create(null)), true);
|
||||
assert.equal(security.isPlainArgs(undefined), true);
|
||||
assert.equal(security.isPlainArgs(null), true);
|
||||
assert.equal(security.isPlainArgs([1, 2]), false);
|
||||
assert.equal(security.isPlainArgs('x'), false);
|
||||
class Weird {}
|
||||
assert.equal(security.isPlainArgs(new Weird()), false);
|
||||
});
|
||||
|
||||
test('payload budget rejects oversized and non-data values', () => {
|
||||
assert.equal(security.payloadWithinBudget({ a: 'small' }, 1000), true);
|
||||
assert.equal(security.payloadWithinBudget({ a: 'x'.repeat(2000) }, 1000), false);
|
||||
assert.equal(security.payloadWithinBudget({ fn: () => 1 }, 1000), false);
|
||||
// Deep nesting is cut off.
|
||||
let deep = 'leaf';
|
||||
for (let i = 0; i < 40; i += 1) deep = { deep };
|
||||
assert.equal(security.payloadWithinBudget(deep, 100000), false);
|
||||
// Numbers/booleans/null are fine.
|
||||
assert.equal(security.payloadWithinBudget({ n: 5, b: true, z: null }, 1000), true);
|
||||
});
|
||||
|
||||
test('field validators refuse traversal, separators, and pollution', () => {
|
||||
const c = security.check;
|
||||
assert.equal(c.id('guide-123_A.b'), true);
|
||||
assert.equal(c.id('../../etc/passwd'), false);
|
||||
assert.equal(c.id('a/b'), false);
|
||||
assert.equal(c.id('a\\b'), false);
|
||||
assert.equal(c.id(''), false);
|
||||
assert.equal(c.id(42), false);
|
||||
|
||||
assert.equal(c.fileName('My snapshot 2026-07-03'), true);
|
||||
assert.equal(c.fileName('..'), false);
|
||||
assert.equal(c.fileName('a/../b'), false);
|
||||
assert.equal(c.fileName('a/b'), false);
|
||||
assert.equal(c.fileName('a\\b'), false);
|
||||
assert.equal(c.fileName('a\0b'), false);
|
||||
assert.equal(c.fileName(' '), false);
|
||||
|
||||
assert.equal(c.settingsKeyPath('capture.hotkeyCapture'), true);
|
||||
assert.equal(c.settingsKeyPath('__proto__.polluted'), false);
|
||||
assert.equal(c.settingsKeyPath('a.constructor.b'), false);
|
||||
assert.equal(c.settingsKeyPath('a..b'), false);
|
||||
assert.equal(c.settingsKeyPath(''), false);
|
||||
|
||||
assert.equal(c.base64('aGVsbG8=', 100), true);
|
||||
assert.equal(c.base64('<script>', 100), false);
|
||||
});
|
||||
|
||||
test('produced-files registry only re-opens what main created', () => {
|
||||
const produced = new security.ProducedFiles(3);
|
||||
produced.add('/tmp/exports/guide.pdf');
|
||||
assert.equal(produced.has('/tmp/exports/guide.pdf'), true);
|
||||
assert.equal(produced.has('/tmp/exports/../exports/guide.pdf'), true, 'path normalization');
|
||||
assert.equal(produced.has('/etc/passwd'), false);
|
||||
assert.equal(produced.has(null), false);
|
||||
|
||||
produced.add('/tmp/a');
|
||||
produced.add('/tmp/b');
|
||||
produced.add('/tmp/c'); // evicts guide.pdf (LRU bound)
|
||||
assert.equal(produced.has('/tmp/exports/guide.pdf'), false);
|
||||
assert.equal(produced.has('/tmp/c'), true);
|
||||
});
|
||||
|
||||
// ---- source-level regression guards -----------------------------------------
|
||||
|
||||
test('main process never grants blanket permissions again', () => {
|
||||
const src = read('app/main.js');
|
||||
assert.doesNotMatch(src, /setPermissionCheckHandler\(\(\)\s*=>\s*true\)/);
|
||||
assert.doesNotMatch(src, /cb\(true\)\)/);
|
||||
assert.match(src, /security\.permissionAllowed/);
|
||||
assert.match(src, /installWindowSecurity\(mainWindow, 'main'\)/);
|
||||
});
|
||||
|
||||
test('no generic shell path channels remain on the bridge', () => {
|
||||
const preload = read('app/preload.js');
|
||||
assert.doesNotMatch(preload, /shell:openPath/);
|
||||
assert.doesNotMatch(preload, /shell:showItemInFolder/);
|
||||
assert.match(preload, /shell:openProduced/);
|
||||
assert.match(preload, /shell:openExternal/);
|
||||
});
|
||||
|
||||
test('every renderer window is created sandboxed', () => {
|
||||
for (const file of ['app/main.js', 'app/capture.js', 'app/stream-backend.js']) {
|
||||
const src = read(file);
|
||||
const created = src.match(/new BrowserWindow\(/g) || [];
|
||||
const sandboxed = src.match(/sandbox: true/g) || [];
|
||||
assert.ok(created.length > 0, `${file} should create a window`);
|
||||
assert.equal(
|
||||
sandboxed.length,
|
||||
created.length,
|
||||
`${file}: every BrowserWindow must set sandbox: true (${sandboxed.length}/${created.length})`
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user