Author SHA1 Message Date
TylerandClaude Fable 5 8aa9756b8a Harden archives, snapshots, locks, and search against corruption and races
Template tests / tests (pull_request) Failing after 33s
Phase 1/2 of the improvement plan (PR 6 of the sequence). Recovery and
resource-limit hardening for the storage-adjacent modules.

ZIP resource limits (core/zip.js):
- unzipSync now enforces entry-count, total compressed, total inflated, and
  per-entry inflated budgets, and caps inflation with inflateRawSync
  maxOutputLength so a deflate bomb can't exhaust memory. Exact inflated-size
  match (not "at least") and CRC verification are kept. Import uses the
  default limits.

Transactional archive import (core/archive.js):
- The import validates the guide AND every step before writing anything, then
  stages the whole guide in a temp directory and publishes it with a single
  atomic rename. A corrupt step no longer leaves a partial guide in the
  library; a failure cleans up the staging directory.

Atomic snapshot restore (core/snapshots.js):
- Restore extracts and validates into a temp directory first; only then does
  it swap content in, moving live content aside so a mid-swap failure rolls
  back. A corrupt/truncated snapshot can no longer destroy the live guide
  (the old restore deleted live content before extracting).
- Fixed snapshot filename collisions: names kept milliseconds so two backups
  in the same second no longer overwrite each other.

Automatic backups (core/snapshots.js):
- Implemented the previously-dead backups.automatic/everyNSaves/keepLast
  settings: autoSnapshotIfDue snapshots every N saves and prunes to keepLast,
  wired into the save choke point in main.js. Never throws — a backup failure
  cannot break the save that triggered it.

Exclusive locks (core/locks.js):
- acquireLock uses O_CREAT|O_EXCL (flag 'wx') so only one writer wins the
  race; the old read-then-write left a window where two writers both believed
  they held the lock. Added a per-acquisition token so release only removes
  the exact lock it took (never one a force-steal replaced). Same-process
  re-acquire still succeeds; cross-process fresh locks conflict.

Search reconciliation (core/search.js):
- New reconcile(store) rebuilds/repairs the index against the library at
  startup using per-guide fingerprints (updatedAt+revision): reindexes new/
  changed guides, drops entries for deleted ones, and exposes a recovery
  status ('ok'|'reset'|'reconciled'). A missing/corrupt/version-mismatched
  index recovers instead of silently returning nothing. Wired into startup.

Recovery surface:
- New recovery:status IPC + preload method returns quarantined files (this
  session) and the search index status so the UI can surface data issues.

Tests: ZIP bomb/limits, transactional import abort with no partial guide,
atomic snapshot restore preserving the live guide on corruption, exclusive
lock conflict/steal/release-by-token, same-process re-acquire, search
reconcile (rebuild/drop/reindex/corrupt-reset), and automatic backup
cadence/pruning. 255 unit tests pass; startup smoke and workflow E2E pass.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 23:10:21 -05:00
Tyler f31f1407a5 Merge pull request #9 from Twest2/StepForge pr/04-autosave-storage
Template tests / tests (push) Failing after 5m6s
Add optimistic revisions, keep dirty state on failed saves, quarantine corrupt data (plan PR 4)
2026-07-03 22:59:51 -05:00
TylerandClaude Fable 5 c1ccb5739b Add optimistic revisions, keep dirty state on failed saves, quarantine corrupt data
Template tests / tests (pull_request) Failing after 33s
Phase 1 of the improvement plan (PR 4 of the sequence): stop concurrent
whole-object saves from losing edits, stop failed saves from reporting clean,
and stop corrupt user data from silently vanishing.

Revisions (core/schema.js, core/store.js):
- Every guide and step carries a monotonic `revision`, bumped on each store
  write. Legacy v1 data without the field reads as revision 0 and upgrades on
  its next save — no migration pass, no data rewrite.
- saveGuide/saveStep accept { expectedRevision } for compare-and-swap saves;
  a mismatch throws RevisionConflictError instead of clobbering. Direct user
  edits pass no expectation (the user is the authority); background writers
  must pass one.

Stale AI responses (app/text-intel.js):
- generateStepPatch snapshots the step revision before the (slow) model call,
  re-reads the step after it, and saves with the original expectedRevision. A
  user edit made during generation now surfaces as "the step changed while AI
  was generating; nothing was overwritten" — previously the AI response
  silently overwrote the newer edit.

Autosave truthfulness (app/renderer/editor.js):
- flushStep/flushGuide cleared the dirty flag BEFORE awaiting the IPC save,
  so a rejected save (invoked via a debounce that never handled rejections)
  lost the visible dirty state. The flag is now cleared only after a durable
  save; failures keep it dirty, surface a persistent saveError in editor
  meta, toast the user, and retry on the next edit or explicit save.
- Navigating away from the editor flushes pending debounced saves so the
  last edit can never be dropped by a view switch.

Corruption quarantine (core/store.js):
- listGuides/listSteps used to silently skip unreadable entries — a corrupt
  guide just vanished from the library. Corrupt guide/step directories are
  now moved to library/quarantine (original bytes preserved) and recorded in
  a recovery report (store.getRecoveryReport()) for the UI. Empty in-progress
  directories are still skipped quietly — absence of guide.json is not
  corruption.

Tests: revision increments, stale-CAS rejection with user edit surviving,
CAS success path, guide CAS, v1 no-revision upgrade, guide/step quarantine
with preserved bytes + recovery report, empty-dir non-quarantine, AI
stale-write rejection end-to-end (user edit mid-generation survives) and the
clean-apply path. 240 unit tests pass; startup smoke and sample-artifact
E2E pass.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 22:57:50 -05:00
Tyler dd71cffac5 Merge pull request #8 from Twest2/StepForge pr/05-capture-fixes
Template tests / tests (push) Failing after 33s
Fix region capture, power ownership, click-source reporting, strict timing (plan PR 5)
2026-07-03 11:52:31 -07:00
TylerandClaude Fable 5 f62e3e19cb Fix region capture, power ownership, click-source reporting, strict timing
Template tests / tests (pull_request) Failing after 33s
Phase 2 of the improvement plan (PR 5 of the sequence). Several confirmed
capture defects from the audit.

Region capture:
- regionCapture returned { ok, step } wrapping storeFrameAsStep's own
  { ok, step }, so the real step was at result.step.step — region selection
  and region auto-doc (which read result.step.stepId) broke. Return the
  storeFrameAsStep result directly.
- pickRegion leaked the region:picked IPC listener (and the overlay/image
  refs) whenever the overlay was cancelled/closed rather than picked: cleanup
  only ran on a pick. Cleanup is now idempotent and runs on pick, close, load
  failure, and settle. The received rectangle is validated and clamped to the
  image (new overlayRectToImageRect handles negative-size drags and
  out-of-bounds selections) so image.crop can never read out of bounds.

Power blocker ownership:
- New single owner: CaptureService.syncPower() holds the blocker iff a session
  is actively recording, called on start/pause/resume/finish. A new session
  starts paused and no longer holds the blocker while idle; tray and
  second-instance pauses that previously bypassed main.js's stop closure now
  release it correctly. main.js provides the power policy (blocker + EcoQoS
  opt-out) via dependency injection.

Explicit click-trigger source:
- startEvdevWatcher never set clickWatcher, so state().clickCapture read false
  while evdev was actively capturing clicks. Replaced the boolean with an
  explicit clickSource (windows-hook | x11 | evdev-x11 | evdev-wayland |
  unavailable); clickCapture is now derived from it. evdev device-stream
  errors/closes now fall back via handleClickWatcherLoss instead of being
  swallowed.

Strict click timing:
- When no pre-click frame qualifies, strict mode previously fell through to a
  fresh (post-click) shot and stored it — contradicting the strict promise.
  It now skips with a capture:diagnostic instead. Non-strict (balanced) mode
  keeps the fresh-shot fallback. Existing tests that exercised the fallback
  now run in balanced mode; the strict test asserts the skip.

Other:
- pathToFileURL replaces file://${p} concatenation for step image URLs and
  export previews (correct for spaces, #, %, drive letters).
- Best-effort click-queue drain on app shutdown (before-quit) so a burst just
  before quit is not lost; bounded so quit never hangs.

Tests: region rect clamping/normalization, power-held-only-while-recording
(incl. finish releases), click-source reporting incl. evdev, drain deadline.
230 unit tests pass. Click self-test: markers 3/3 (strict) and burst 8/8
deterministic across runs; the burst scenario runs in balanced mode because
it tests the drain, not strict timing. (arm/debounce remain the pre-existing
Linux capture failures untouched by this PR.)

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 11:50:44 -07:00
Tyler c916234ae8 Merge pull request #7 from Twest2/StepForge pr/03-privacy-ai-contract
Template tests / tests (push) Failing after 32s
Enforce a truthful local-first AI/privacy contract (plan PR 3)
2026-07-03 11:39:08 -07:00
TylerandClaude Fable 5 ccbb9b03dc Enforce a truthful local-first AI/privacy contract
Template tests / tests (pull_request) Failing after 34s
Phase 1 of the improvement plan (PR 3 of the sequence). The docs claimed
"fully offline"/"never talks to the network," but text-intel makes HTTP
requests to a configurable Ollama host that could be remote, with no timeout,
no cancellation, and no size limit; the Windows hook logged raw keystrokes
into capture metadata that could then be sent to that host.

Privacy — raw keystroke capture:
- New capture.captureTypedText setting, default false. With it off, printable
  characters are never buffered in JS and the Windows keyboard hook never even
  emits them across the process boundary (the flag is threaded into the C#).
  Shortcut/navigation detection (Ctrl+T, Enter, …) is unaffected.

AI network hardening (app/text-intel.js):
- Every Ollama call goes through fetchJson with an AbortController deadline
  (ai.timeoutMs, default 60s): a dead endpoint fails fast instead of leaving
  UI actions pending forever.
- Cancellation: in-flight requests are tracked and cancelInflight(guideId)
  aborts them; new ai:cancel IPC + api.ai.cancel are called when the editor
  closes, and shutdown cancels everything.
- Bounded concurrency (2) for AI network work.
- Screenshots are only attached when allowed (ai.attachScreenshots), the model
  is vision-capable, and the image is within ai.maxImageBytes — no more
  unbounded base64-expanded 4K bodies.

Local-first host policy (core/text-intel.js):
- New isLoopbackHost + validateOllamaHost. By default only a loopback Ollama
  endpoint is contacted; a remote host is refused with a clear message unless
  ai.allowRemoteHost is explicitly enabled. Blocked hosts are never contacted.

Honest documentation:
- README, package.json, and the welcome screen drop "fully offline"/"never
  talks to the network"/"Electron is the only dependency" for an accurate
  local-first contract that discloses the optional AI path and the bundled
  Tesseract OCR dependency.
- New docs/PRIVACY.md details exactly what is collected locally and the one
  outbound (opt-in, loopback-by-default) AI feature.

Tests: loopback/remote host matrix, remote-blocked-without-opt-in (and never
contacted), remote-allowed-with-opt-in, request timeout, explicit cancel vs
timeout, typed-text off-by-default vs opted-in, shortcut detection still works,
and a source guard that the C# CHAR emission stays behind the opt-in. 224 unit
tests pass; startup smoke and workflow E2E pass.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 11:36:01 -07:00
Tyler 6ffef69705 Merge pull request #6 from Twest2/StepForge pr/02-security-boundary
Template tests / tests (push) Failing after 2m37s
Close the renderer privilege boundary: navigation, IPC, permissions, shell (plan PR 2)
2026-07-03 11:27:28 -07:00
TylerandClaude Fable 5 50e445e7c3 Close the renderer privilege boundary: navigation, IPC, permissions, shell
Template tests / tests (pull_request) Failing after 33s
Phase 1 of the improvement plan (PR 2 of the sequence). A remote page could
previously inherit the privileged preload bridge: the main window had no
navigation guard or popup handler, IPC handlers accepted any sender, every
Electron permission was granted to everyone, and shell:openPath accepted an
arbitrary renderer-supplied target.

- New app/security.js (plain-Node testable) centralizes the policy:
  app-page identity, navigation/popup denial, deny-by-default permissions,
  external-URL validation, IPC sender guard, payload budgets, field
  validators, and a produced-files registry.
- Every window (main, region overlay, capture worker) is sandboxed, denies
  all navigation away from its own page, denies every popup, and refuses
  webview attachment.
- Every IPC channel now rejects events that are not from the main window's
  top frame on index.html, rejects non-plain/oversized argument bags, and
  channels with risky inputs validate ids, enums, names, and sizes
  (path-traversal and prototype-pollution guards included).
- Permissions are deny-by-default; the only grant in the app is display
  capture (+media) for the dedicated capture-worker page. The display-media
  handler also verifies the requesting frame.
- shell:openPath/showItemInFolder are gone. Replacements are intent-specific:
  openProduced (only files the main process produced this session),
  revealLinkedArchive (path read from the store, not the renderer), and
  openExternal (parsed, scheme-checked http(s)/mailto only).
- archive:peek removed: unused, and it let the renderer read arbitrary
  archives by path.
- export:run only accepts output directories that came from a dialog pick or
  remembered settings; anything else re-prompts.
- Renderer: description links never navigate; http(s)/mailto open externally
  via the validated handler.
- 12 new security regression tests: hostile navigation targets, permission
  matrix, sender spoofing (wrong window/subframe/navigated/disposed frames),
  oversized payloads, traversal/pollution attempts, produced-file registry,
  and source-level guards (no blanket grants, no generic shell channels,
  sandbox on every window).

Verified: 215 unit tests pass; startup smoke, unit-workflows, sample
artifacts, and build-release E2E pass; click self-test still reaches
source: stream with markers 3/3 and burst 8/8 under the deny-by-default
policy (arm/debounce remain the known pre-existing failures, untouched
here); UI screenshot confirms the sandboxed renderer boots.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 11:25:36 -07:00
Tyler 6a3005f24c Merge pull request #5 from Twest2/StepForge pr/01-toolchain-ci
Template tests / tests (push) Failing after 33s
Pin Node toolchain, remove runtime npm repair, make CI and E2E truthful (plan PR 1)
2026-07-03 11:12:29 -07:00
26 changed files with 2616 additions and 215 deletions
+15 -6
View File
@@ -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
+169 -27
View File
@@ -198,11 +198,18 @@ class CaptureService {
notify,
screenApi = screen,
textIntel = null,
powerPolicy = null,
}) {
this.store = store;
this.settings = settings;
this.getWindow = getWindow;
this.notify = notify;
// Single owner of OS power/throttling state for the capture lifecycle.
// setRecording(true) is called exactly while a session is actively
// recording (session present and not paused); setRecording(false) whenever
// it pauses or ends. No-op by default so tests and non-Electron hosts work.
this.powerPolicy = powerPolicy || { setRecording() {} };
this._recordingPower = false;
// Injectable for tests; the click/coordinate paths must never reach for
// the global `screen` directly so coordinate handling stays testable.
this.screen = screenApi;
@@ -213,6 +220,10 @@ class CaptureService {
this.session = null; // { guideId, paused, count, intervalSec }
this.intervalTimer = null;
this.clickWatcher = null;
// Explicit trigger source rather than a bare boolean, so the UI can tell
// the truth about how clicks are being captured (or that they are not):
// windows-hook | x11 | evdev-x11 | evdev-wayland | unavailable
this.clickSource = 'unavailable';
this.frameLoopTimer = null;
this.frameLoopRunning = false;
this.frameWaiters = [];
@@ -240,6 +251,22 @@ class CaptureService {
this.warmingUp = false;
}
/**
* Reconcile OS power state with the actual recording state. Called after
* every session transition so there is exactly one owner: the blocker is
* held iff a session exists and is not paused. Idempotent.
*/
syncPower() {
const recording = Boolean(this.session && !this.session.paused);
if (recording === this._recordingPower) return;
this._recordingPower = recording;
try {
this.powerPolicy.setRecording(recording);
} catch {
// power management is best-effort; never break capture over it
}
}
state() {
return this.session
? {
@@ -248,12 +275,21 @@ class CaptureService {
guideId: this.session.guideId,
count: this.session.count,
intervalSec: this.session.intervalSec || 0,
clickCapture: Boolean(this.clickWatcher),
// clickCapture reflects any live global click source (xinput/evdev/
// Windows hook), not just the spawned-process watcher — evdev has no
// child process, so the old Boolean(this.clickWatcher) reported false
// while clicks were in fact being captured.
clickCapture: this.clickSource !== 'unavailable',
clickSource: this.clickSource,
clickCaptureAvailable: this.clickCaptureAvailable(),
clickFrameSource: this.streamBackend ? 'stream' : (this.frameLoopRunning ? 'loop' : 'idle'),
strictClickFrames: this.strictClickFrames(),
}
: { active: false, clickCaptureAvailable: this.clickCaptureAvailable() };
: {
active: false,
clickSource: this.clickSource,
clickCaptureAvailable: this.clickCaptureAvailable(),
};
}
/**
@@ -331,6 +367,9 @@ class CaptureService {
this.session = { guideId, paused: true, count: 0, intervalSec: interval };
if (this.settings.get('capture.captureOutsideClicks') !== false) this.startClickWatcher();
this.applyInterval();
// A new session starts paused, so it must NOT hold the power blocker yet
// (it did before, leaking the blocker while idle). syncPower reconciles.
this.syncPower();
this.notify('capture:state', this.state());
// (Skipped for the dev screenshot hook, which needs a visible page.)
@@ -476,6 +515,9 @@ class CaptureService {
this.stopFrameLoop();
this.stopClickFrameBackend();
}
// Recording only while unpaused: this is the one place tray/second-instance
// pauses previously bypassed, leaking the power blocker. syncPower owns it.
this.syncPower();
if (this.rebuildTrayMenu) this.rebuildTrayMenu();
this.notify('capture:state', this.state());
}
@@ -555,6 +597,9 @@ class CaptureService {
this.stopClickFrameBackend();
this.destroySessionTray();
this.session = null;
// A finished session never records: release the power blocker here so it
// can't outlive the recording (finish from any path — bar, tray, quit).
this.syncPower();
if (this.hiddenForSession) {
this.hiddenForSession = false;
this.showWindow();
@@ -562,6 +607,23 @@ class CaptureService {
this.notify('capture:state', this.state());
}
/**
* Best-effort drain of clicks still encoding in the queue, for application
* shutdown. Resolves when the queue settles or the deadline passes so quit
* is never blocked indefinitely. Clicks captured mid-session are pinned to
* their guide id and stored even after the session ends (see onOsClick).
*/
async drainPendingClicks(timeoutMs = 2000) {
try {
await Promise.race([
this.clickQueue.catch(() => {}),
new Promise((resolve) => setTimeout(resolve, Math.max(0, timeoutMs))),
]);
} catch {
// best effort
}
}
/**
* True when the user is interacting with StepForge itself. Deliberately
* based on cursor position over the visible window, not isFocused():
@@ -624,11 +686,22 @@ class CaptureService {
if (result.ok) this.noteStepAdded(result.step, trigger, guideId);
return result;
}
// No usable frame: fall through to a one-off fresh shot — but only
// while still recording. After a stop, a fresh shot would show
// whatever replaced the user's workflow on screen.
clog('click@', clickAt, 'no frame qualified — falling back to a fresh (post-click) shot');
if (!sessionLive) return { ok: false, reason: 'session ended before the fallback shot' };
// No usable pre-click frame. In strict mode a fresh shot now would be a
// POST-click frame — exactly what strict mode promises never to store.
// Skip with a visible diagnostic instead of silently labeling a
// post-click fallback as strict. Non-strict mode keeps the legacy
// fresh-shot fallback below.
if (this.strictClickFrames()) {
clog('click@', clickAt, 'strict mode — no pre-click frame; skipping rather than storing a post-click shot');
this.notify('capture:diagnostic', {
kind: 'strict-click-skipped',
guideId,
reason: 'No pre-click frame was ready for this click. In strict timing mode StepForge skips the shot rather than capturing the screen after the click.',
});
return { ok: false, reason: 'strict mode: no pre-click frame available for this click' };
}
clog('click@', clickAt, 'no frame qualified — falling back to a fresh (post-click) shot');
}
// For non-click triggers (interval, hotkey, manual) pull the latest frame
@@ -1009,6 +1082,7 @@ class CaptureService {
? ['stdbuf', '-oL', 'xinput', 'test-xi2', '--root']
: ['xinput', 'test-xi2', '--root'];
this.clickWatcher = spawn(argv[0], argv.slice(1), { stdio: ['ignore', 'pipe', 'ignore'] });
this.clickSource = 'x11';
this.clickWatcher.stdout.on('data', (chunk) => {
this.ingestClickWatcherChunk(chunk.toString(), 'linux');
});
@@ -1025,6 +1099,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 +1133,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 +1386,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);
@@ -1366,6 +1448,7 @@ public static class SFHook {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
this.clickSource = 'windows-hook';
this.clickWatcher.stdout.on('data', (chunk) => {
this.ingestClickWatcherChunk(chunk.toString(), 'win32');
});
@@ -1417,6 +1500,7 @@ public static class SFHook {
this.clickWatcher = null;
}
this.stopEvdevWatcher();
this.clickSource = 'unavailable';
this.clickWatcherBuf = '';
this.linuxEvent = null;
this.discardPendingRawClick();
@@ -1442,26 +1526,48 @@ public static class SFHook {
buf = rest;
for (const button of presses) this.onOsClick(Date.now(), null, button);
});
// A device can disappear (unplugged); just drop that stream.
stream.on('error', () => {});
// A device can disappear (unplugged): drop that stream, and if it was
// the last live click source, fall back like any other watcher loss
// instead of silently going dark.
stream.on('error', () => this.handleEvdevStreamLoss(stream, 'device read error'));
stream.on('close', () => this.handleEvdevStreamLoss(stream, 'device closed'));
this.evdevStreams.push(stream);
} catch {
// Node became unreadable between enumeration and open — skip it.
}
}
if (!this.evdevStreams.length) {
console.error('[stepforge] no readable mouse input devices — add your user to the "input" group for per-click capture: sudo usermod -aG input "$USER" (then log out and back in)');
this.clickSource = 'unavailable';
console.error('[stepforge] no readable mouse input devices for per-click capture (see docs/linux for the least-privilege device-access setup)');
} else {
// evdev carries no cursor position on Wayland (no marker); on X11 without
// xinput it is the click source but likewise has no root coordinates.
this.clickSource = this.onWayland() ? 'evdev-wayland' : 'evdev-x11';
console.log(`[stepforge] per-click capture via evdev on ${this.evdevStreams.length} device(s)${this.onWayland() ? ' (Wayland: no click marker)' : ''}`);
}
}
/** One evdev device stream ended; drop it and fall back if none remain. */
handleEvdevStreamLoss(stream, reason) {
if (!this.evdevStreams) return; // already stopped deliberately
const idx = this.evdevStreams.indexOf(stream);
if (idx === -1) return;
this.evdevStreams.splice(idx, 1);
try { stream.destroy(); } catch { /* already gone */ }
if (this.evdevStreams.length === 0) {
this.evdevStreams = null;
this.clickSource = 'unavailable';
this.handleClickWatcherLoss(`evdev ${reason}`);
}
}
stopEvdevWatcher() {
if (!this.evdevStreams) return;
for (const stream of this.evdevStreams) {
const streams = this.evdevStreams;
this.evdevStreams = null; // clear first so close handlers no-op
for (const stream of streams) {
try { stream.destroy(); } catch { /* already closed */ }
}
this.evdevStreams = null;
}
/**
@@ -1790,6 +1896,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') {
@@ -1994,13 +2105,43 @@ public static class SFHook {
const cropped = image.crop(rect);
const size = cropped.getSize();
if (!size.width || !size.height) return { ok: false, reason: 'empty selection' };
const step = await this.storeFrameAsStep(guideId, 'region', {
// storeFrameAsStep already returns { ok, step }; return it directly so
// callers see result.step.stepId — not result.step.step.stepId, which is
// what wrapping it in another { ok, step } produced (region selection and
// region auto-documentation both read result.step.stepId).
return this.storeFrameAsStep(guideId, 'region', {
image: cropped,
size,
display,
cursor: null,
}, null, null);
return { ok: true, step };
}
/**
* Convert an overlay selection (display px) into an image-space crop rect,
* clamped to the image bounds. Returns null for an empty/invalid rect.
*/
overlayRectToImageRect(rect, display, imgSize) {
if (!rect || !imgSize) return null;
const { width: iw, height: ih } = imgSize;
if (!iw || !ih) return null;
const sx = iw / display.bounds.width;
const sy = ih / display.bounds.height;
const toNum = (v) => (Number.isFinite(v) ? v : 0);
let x = Math.round(toNum(rect.x) * sx);
let y = Math.round(toNum(rect.y) * sy);
let w = Math.round(toNum(rect.w) * sx);
let h = Math.round(toNum(rect.h) * sy);
// Normalize negative-size drags (drawn up/left).
if (w < 0) { x += w; w = -w; }
if (h < 0) { y += h; h = -h; }
// Clamp to the image so image.crop can never read out of bounds.
x = Math.min(Math.max(0, x), iw);
y = Math.min(Math.max(0, y), ih);
w = Math.min(w, iw - x);
h = Math.min(h, ih - y);
if (w <= 0 || h <= 0) return null;
return { x, y, width: w, height: h };
}
/** Fullscreen overlay window that resolves with a crop rect (image px). */
@@ -2019,33 +2160,34 @@ 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');
const { ipcMain } = require('electron');
let settled = false;
// Idempotent cleanup: remove the IPC listener and close the overlay
// exactly once, whether the user picked, cancelled, closed, or the page
// failed to load. Previously the listener was only removed on a pick, so
// cancelling/closing leaked it (and the captured overlay/image refs).
const finish = (rect) => {
if (settled) return;
settled = true;
ipcMain.removeListener('region:picked', onPick);
if (!overlay.isDestroyed()) overlay.close();
resolve(rect);
};
const { ipcMain } = require('electron');
const onPick = (event, rect) => {
if (event.sender !== overlay.webContents) return;
ipcMain.removeListener('region:picked', onPick);
if (!rect) return finish(null);
const imgSize = image.getSize();
const sx = imgSize.width / display.bounds.width;
const sy = imgSize.height / display.bounds.height;
finish({
x: Math.round(rect.x * sx),
y: Math.round(rect.y * sy),
width: Math.round(rect.w * sx),
height: Math.round(rect.h * sy),
});
finish(this.overlayRectToImageRect(rect, display, image.getSize()));
};
ipcMain.on('region:picked', onPick);
overlay.on('closed', () => finish(null));
overlay.loadFile(path.join(__dirname, 'renderer', 'region.html'));
overlay.webContents.on('did-fail-load', () => finish(null));
overlay.loadFile(path.join(__dirname, 'renderer', 'region.html')).catch(() => finish(null));
});
}
}
+279 -88
View File
@@ -3,6 +3,7 @@
const path = require('node:path');
const fs = require('node:fs');
const os = require('node:os');
const { pathToFileURL } = require('node:url');
const {
app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, globalShortcut,
clipboard, nativeImage, screen, powerSaveBlocker, session, desktopCapturer,
@@ -15,12 +16,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 { createSnapshot, listSnapshots, restoreSnapshot } = require('../core/snapshots');
const { exportGuideArchive, importGuideArchive, saveLinkedGuide } = require('../core/archive');
const { createSnapshot, listSnapshots, restoreSnapshot, autoSnapshotIfDue } = 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';
@@ -70,6 +72,9 @@ function reindex(guideId) {
} catch {
// index failures must never block saves
}
// Automatic backup policy runs on the same save choke point. It is
// self-contained and never throws, so it can't affect the save either.
autoSnapshotIfDue(store, guideId, settings);
}
function orderedSteps(guideId) {
@@ -95,6 +100,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 +109,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();
@@ -198,7 +208,12 @@ function createWindow() {
// 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.
// queued (frames still encoding) when the stop lands. This scenario
// tests the queue DRAIN, not strict timing — 30ms-apart clicks
// outpace the frame sampler, so run it in balanced mode where every
// queued click stores. (Strict-mode skip-vs-store is covered by the
// marker scenario above and by unit tests.)
settings.set('capture.strictClickFrames', false);
const burstGuide = store.createGuide({ title: 'burst selftest' });
capture.startSession(burstGuide.guideId, { intervalSec: 0 });
capture.stopClickWatcher();
@@ -222,6 +237,7 @@ function createWindow() {
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');
settings.set('capture.strictClickFrames', true); // restore for later scenarios
// Helper: wait until armRecording has finished warming (window
// hidden, buffer primed) so an injected click counts as a real
@@ -375,7 +391,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 +439,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 +512,39 @@ 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;
if (!p || !fs.existsSync(p)) return null;
// pathToFileURL correctly encodes spaces, #, %, drive letters, etc.; the
// mtime is a cache-buster so the renderer reloads after an edit.
const url = pathToFileURL(p);
url.searchParams.set('v', String(fs.statSync(p).mtimeMs));
return url.href;
}, {
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 +555,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 +573,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 +588,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 +604,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 +642,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,50 +665,31 @@ function setupIpc() {
}
}
return result;
});
let capturePowerBlocker = -1;
const startCapturePower = () => {
if (!powerSaveBlocker.isStarted(capturePowerBlocker)) {
capturePowerBlocker = powerSaveBlocker.start('prevent-app-suspension');
}
};
const stopCapturePower = () => {
if (powerSaveBlocker.isStarted(capturePowerBlocker)) {
powerSaveBlocker.stop(capturePowerBlocker);
}
};
// Opt every live Electron process (browser, GPU, the screen-capture utility,
// any renderers) out of EcoQoS for the duration of a recording. The hidden
// capture-worker renderer is created later, during warmup, so it opts itself
// out separately (see stream-backend.js); this covers the rest.
const keepCaptureProcessesResponsive = () => {
try {
keepProcessesResponsive(app.getAppMetrics().map((m) => m.pid));
} catch { /* metrics unavailable — best effort */ }
};
}, { validate: (a) => c.id(a.guideId) });
// Power/throttling state is owned entirely by the capture service's
// recording transitions (see createCapturePowerPolicy) so there is exactly
// one owner: it is held iff a session is actively recording, and paused,
// finished, tray, and second-instance transitions all release it correctly.
h('capture:session', async ({ action, guideId, intervalSec }) => {
if (action === 'start') {
capture.startSession(guideId, { intervalSec: intervalSec ?? null });
startCapturePower();
keepCaptureProcessesResponsive();
} else if (action === 'pause') {
capture.togglePause(true);
stopCapturePower();
} else if (action === 'resume') {
capture.togglePause(false);
startCapturePower();
keepCaptureProcessesResponsive();
} else if (action === 'finish') {
capture.finishSession();
stopCapturePower();
} else if (action === 'interval') {
capture.setInterval(intervalSec);
}
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 +704,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 +719,45 @@ 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) });
// recovery status: corrupt files quarantined this session and search index
// health, so the UI can surface them instead of data silently vanishing.
h('recovery:status', () => ({
quarantined: store.getRecoveryReport(),
searchStatus: searchIndex.status,
}));
// 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 +766,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 +799,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 +827,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 +852,11 @@ function setupIpc() {
maxSteps: settings.get('exports.previewStepCount') || 3,
});
const result = runExport(format, ast, previewDir, options || {});
return { ok: true, file: result.file, fileUrl: `file://${result.file}` };
producedFiles.add(result.file);
return { ok: true, file: result.file, fileUrl: pathToFileURL(result.file).href };
}, {
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 +867,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(),
@@ -794,6 +926,17 @@ if (!gotLock) {
store = new GuideStore(dataDir);
settings = new Settings(store.settingsDir);
searchIndex = new SearchIndex(store.indexDir);
// Rebuild/reconcile the index against the library at startup so a missing,
// corrupt, or version-mismatched index recovers instead of silently
// returning nothing.
try {
const summary = searchIndex.reconcile(store);
if (summary.reindexed || summary.removed || summary.status !== 'ok') {
console.log(`[stepforge] search index reconciled: ${JSON.stringify(summary)}`);
}
} catch (err) {
console.error(`[stepforge] search reconcile failed: ${err && err.message}`);
}
templates = new TemplateManager(store.templatesDir);
textIntel = new TextIntelService({
store,
@@ -834,23 +977,51 @@ if (!gotLock) {
}
};
// Single owner of OS power/throttling state for recording. The capture
// service calls setRecording(true/false) on every recording transition;
// this holds a power-save blocker and opts live Electron processes out of
// EcoQoS while recording, and releases the blocker when recording stops.
const capturePowerPolicy = (() => {
let blocker = -1;
const keepResponsive = () => {
try { keepProcessesResponsive(app.getAppMetrics().map((m) => m.pid)); } catch { /* best effort */ }
};
return {
setRecording(recording) {
if (recording) {
if (!powerSaveBlocker.isStarted(blocker)) {
blocker = powerSaveBlocker.start('prevent-app-suspension');
}
keepResponsive();
} else if (powerSaveBlocker.isStarted(blocker)) {
powerSaveBlocker.stop(blocker);
}
},
};
})();
capture = new CaptureService({
store,
settings,
getWindow: () => mainWindow,
notify: captureNotify,
textIntel,
powerPolicy: capturePowerPolicy,
});
// 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 +1031,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)`);
@@ -881,6 +1059,19 @@ if (!gotLock) {
});
});
// Drain clicks still encoding in the capture queue before the app exits, so
// a fast burst immediately before quit is not lost. Defer the quit exactly
// once with a bounded deadline, then let it proceed.
let quitDrained = false;
app.on('before-quit', (event) => {
if (quitDrained || !capture) return;
quitDrained = true;
event.preventDefault();
// Stop new clicks from being queued, then wait for the queue to settle.
capture.stopClickWatcher();
capture.drainPendingClicks(2000).finally(() => app.quit());
});
app.on('will-quit', () => {
globalShortcut.unregisterAll();
if (capture) {
+9 -2
View File
@@ -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'),
@@ -76,6 +77,9 @@ const api = {
create: invoke('snapshots:create'),
restore: invoke('snapshots:restore'),
},
recovery: {
status: invoke('recovery:status'),
},
templates: {
list: invoke('templates:list'),
load: invoke('templates:load'),
@@ -95,8 +99,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
View File
@@ -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();
+39 -4
View File
@@ -124,6 +124,7 @@ class GuideEditor {
this.currentZoom = 'fit';
this.pendingSave = false;
this.pendingGuideSave = false;
this.saveError = null;
this.canvasHistory = [];
this.canvasFuture = [];
this.beforeCanvasSnapshot = null;
@@ -151,6 +152,16 @@ class GuideEditor {
setActive(active) {
this.active = Boolean(active);
if (!this.active && this.guideId) {
// Leaving the editor: flush pending debounced saves so navigation can
// never drop the last edit (failures keep the dirty state and retry),
// and cancel any in-flight AI request for this guide so a slow response
// can't resolve against a guide the user has closed.
if (this.pendingSave || this.pendingGuideSave) {
this.saveAll().catch(() => {});
}
api.ai.cancel({ guideId: this.guideId }).catch(() => {});
}
}
setSettings(settings) {
@@ -314,6 +325,7 @@ class GuideEditor {
selectedAnnotationId: this.selectedAnnotationId,
linked: Boolean(this.guide && this.guide.linkedSource),
dirty: this.pendingSave || this.pendingGuideSave || this.descriptionDirty || this.titleDirty,
saveError: this.saveError || null,
view: 'editor',
};
}
@@ -1419,8 +1431,22 @@ class GuideEditor {
async flushStep(step = this.currentStep) {
if (!step) return;
// Clear the dirty flag only AFTER a durable save. Clearing it first meant
// a rejected IPC save silently lost the unsaved state (and this runs from
// a debounce that does not handle rejections). Keep it dirty on failure,
// surface it, and retry on the next edit or explicit save.
let saved;
try {
saved = await api.step.save({ guideId: this.guideId, step });
} catch (err) {
this.pendingSave = true;
this.saveError = (err && err.message) || 'Save failed';
this.emitMeta();
this.onToast('Could not save this step — your changes are kept. Retrying…', { error: true });
return null;
}
this.pendingSave = false;
const saved = await api.step.save({ guideId: this.guideId, step });
this.saveError = null;
const committed = this.commitSavedStep(saved);
if (this.selectedStepId === committed.stepId) {
this.renderStepList();
@@ -1465,8 +1491,17 @@ class GuideEditor {
async flushGuide() {
if (!this.guide) return;
try {
await api.guide.save({ guide: this.guide });
} catch (err) {
this.pendingGuideSave = true;
this.saveError = (err && err.message) || 'Save failed';
this.emitMeta();
this.onToast('Could not save guide details — your changes are kept. Retrying…', { error: true });
return;
}
this.pendingGuideSave = false;
await api.guide.save({ guide: this.guide });
this.saveError = null;
this.emitMeta();
}
@@ -1899,7 +1934,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 +1970,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
View File
@@ -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,
};
+3
View File
@@ -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);
};
+174 -25
View File
@@ -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,12 +611,23 @@ 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);
if (!guide || !step) {
return { ok: false, reason: 'Guide or step not found.' };
}
// Snapshot the revision now: AI generation is slow, and the user may
// edit the step meanwhile. We save with this expectedRevision so a
// response built from stale data cannot overwrite a newer user edit.
const baseRevision = Number.isInteger(step.revision) ? step.revision : 0;
const currentBlock = blockId
? [...(step.textBlocks || []), ...(step.codeBlocks || []), ...(step.tableBlocks || [])].find((b) => b.id === blockId) || null
@@ -522,10 +636,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,15 +712,34 @@ 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 });
const saved = this.store.saveStep(guideId, updated);
// Re-read the step: while generation ran, a capture auto-doc or another
// background write may have advanced it. Apply the patch to the current
// step and save with the original expected revision so a user edit made
// during generation causes a conflict instead of a silent overwrite.
let currentStep = step;
try {
currentStep = this.store.getStep(guideId, stepId) || step;
} catch {
currentStep = step;
}
const updated = applyAiPatchToStep(currentStep, patch, { target, blockId });
let saved;
try {
saved = this.store.saveStep(guideId, updated, { expectedRevision: baseRevision });
} catch (err) {
if (err && err.code === 'STEPFORGE_REVISION_CONFLICT') {
return { ok: false, reason: 'The step changed while AI was generating; nothing was overwritten.' };
}
throw err;
}
return { ok: true, step: saved, patch };
} catch (err) {
return { ok: false, reason: err && err.message ? err.message : 'AI generation failed.' };
@@ -610,6 +753,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 +777,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.',
+32 -8
View File
@@ -124,18 +124,40 @@ function importGuideArchive(store, file, { mode = 'copy' } = {}) {
}
function finalizeImport(store, newGuide, idMap, stepJsons, stepFiles) {
// Transactional import: validate the guide and EVERY step first, then write
// the whole guide into a temporary staging directory, and only publish it
// with a single atomic rename. Previously guide.json was written before the
// steps validated, so a bad step left a partial guide in the library.
validateGuide(newGuide);
writeJsonSync(path.join(store.guideDir(newGuide.guideId), 'guide.json'), newGuide);
const normalizedSteps = [];
for (const [stepId, { raw }] of stepJsons) {
const step = normalizeStep({ ...raw, stepId });
step.parentStepId = raw.parentStepId ? idMap.get(raw.parentStepId) || null : null;
validateStep(step);
const dir = store.stepDir(newGuide.guideId, stepId);
writeJsonSync(path.join(dir, 'step.json'), step);
for (const { name, data } of stepFiles.get(stepId) || []) {
atomicWriteFileSync(path.join(dir, name), data);
validateStep(step); // throws before anything is written on a bad step
normalizedSteps.push([stepId, step]);
}
const finalDir = store.guideDir(newGuide.guideId);
if (fs.existsSync(finalDir)) throw new Error(`guide already exists: ${newGuide.guideId}`);
const stagingDir = `${finalDir}.importing-${Date.now()}`;
fs.rmSync(stagingDir, { recursive: true, force: true });
try {
fs.mkdirSync(stagingDir, { recursive: true });
writeJsonSync(path.join(stagingDir, 'guide.json'), newGuide);
for (const [stepId, step] of normalizedSteps) {
const dir = path.join(stagingDir, 'steps', stepId);
fs.mkdirSync(dir, { recursive: true });
writeJsonSync(path.join(dir, 'step.json'), step);
for (const { name, data } of stepFiles.get(stepId) || []) {
atomicWriteFileSync(path.join(dir, name), data);
}
}
// Publish atomically. If the final dir appeared meanwhile, fail cleanly.
if (fs.existsSync(finalDir)) throw new Error(`guide already exists: ${newGuide.guideId}`);
fs.renameSync(stagingDir, finalDir);
} catch (err) {
fs.rmSync(stagingDir, { recursive: true, force: true });
throw err;
}
return store.getGuide(newGuide.guideId);
}
@@ -160,7 +182,9 @@ function saveLinkedGuide(store, guideId, { force = false } = {}) {
store.saveGuide(guide, { touch: false });
return { saved: true, path: target };
} finally {
releaseLock(target);
// Release by our acquisition token so we never remove a lock a concurrent
// force-steal replaced with theirs.
releaseLock(target, { lock: result.lock });
}
}
+56 -10
View File
@@ -20,18 +20,39 @@ function lockPathFor(archivePath) {
return path.join(dir, `${stem}.lock-sfgz`);
}
function currentHolder() {
function currentProcess() {
return { host: os.hostname(), user: os.userInfo().username, pid: process.pid };
}
function currentHolder() {
return {
...currentProcess(),
// Random per-acquisition token so two processes that happen to share
// host+user+pid space (containers, pid reuse) still compare distinctly,
// and so a steal can be detected by the previous holder.
token: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
};
}
function readLock(archivePath) {
return readJsonIfExists(lockPathFor(archivePath), null);
}
function sameHolder(a, b) {
// Process identity (host+user+pid). Used to decide whether an existing lock is
// held by *this process* (safe to re-acquire) or someone else (a conflict).
function sameProcess(a, b) {
return a && b && a.host === b.host && a.user === b.user && a.pid === b.pid;
}
// Exact-acquisition identity via the per-acquisition token. Used by release so
// a caller only removes the lock it actually took (never one a force-steal
// replaced with its own).
function sameAcquisition(existing, owner) {
if (!existing || !owner) return false;
if (owner.token) return existing.token === owner.token;
return sameProcess(existing, owner);
}
function isStale(lock, now = Date.now()) {
const t = Date.parse(lock && lock.acquiredAt);
return !Number.isFinite(t) || now - t > STALE_AFTER_MS;
@@ -44,24 +65,49 @@ function isStale(lock, now = Date.now()) {
*/
function acquireLock(archivePath, { force = false } = {}) {
const file = lockPathFor(archivePath);
const existing = readLock(archivePath);
const me = currentHolder();
if (existing && !sameHolder(existing, me) && !isStale(existing) && !force) {
const lock = { ...me, acquiredAt: nowIso() };
const payload = JSON.stringify(lock, null, 2);
// Fast path: exclusive create. Only one writer wins the O_CREAT|O_EXCL race,
// so two processes can't both believe they hold the lock (the old
// read-then-write left exactly that window open).
try {
fs.writeFileSync(file, payload, { flag: 'wx' });
return { acquired: true, lock };
} catch (err) {
if (err.code !== 'EEXIST') throw err;
}
// A lock already exists. We may take it over only if this process already
// holds it, it is stale, or the caller is force-stealing (user confirmed).
const existing = readLock(archivePath);
if (existing && !sameProcess(existing, me) && !isStale(existing) && !force) {
return { acquired: false, conflict: existing };
}
const lock = { ...me, acquiredAt: nowIso() };
fs.writeFileSync(file, JSON.stringify(lock, null, 2));
// Overwrite to claim ownership (our token now identifies the lock).
fs.writeFileSync(file, payload);
return { acquired: true, lock };
}
/** Release only if we are the holder (or force). */
function releaseLock(archivePath, { force = false } = {}) {
/**
* Release only if we are the holder (or force). Pass the `lock` (or its
* `token`) returned by acquireLock so ownership is matched by token the
* per-acquisition token means a fresh currentHolder() would not match.
*/
function releaseLock(archivePath, { force = false, lock = null, token = null } = {}) {
const file = lockPathFor(archivePath);
const existing = readLock(archivePath);
if (!existing) return true;
if (!force && !sameHolder(existing, currentHolder())) return false;
// With no explicit lock/token, fall back to process identity (the legacy
// "release my own lock" path) rather than a fresh token that can't match.
const owner = lock || (token ? { token } : currentProcess());
if (!force && !sameAcquisition(existing, owner)) return false;
fs.rmSync(file, { force: true });
return true;
}
module.exports = { lockPathFor, readLock, acquireLock, releaseLock, isStale, STALE_AFTER_MS };
module.exports = {
lockPathFor, readLock, acquireLock, releaseLock, isStale, STALE_AFTER_MS,
sameProcess, sameAcquisition,
};
+5
View File
@@ -52,6 +52,9 @@ function createGuide(fields = {}) {
favorite: Boolean(fields.favorite),
linkedSource: fields.linkedSource || null,
exportProfiles: { ...(fields.exportProfiles || {}) },
// Monotonic revision for optimistic concurrency. Absent in v1 data (reads
// as 0), bumped on every store write.
revision: Number.isInteger(fields.revision) && fields.revision >= 0 ? fields.revision : 0,
};
}
@@ -89,6 +92,8 @@ function createStep(fields = {}) {
captureMetadata: (fields.captureMetadata && typeof fields.captureMetadata === 'object' && !Array.isArray(fields.captureMetadata))
? { ...fields.captureMetadata }
: null,
// Monotonic revision for optimistic concurrency (see createGuide).
revision: Number.isInteger(fields.revision) && fields.revision >= 0 ? fields.revision : 0,
};
}
+69 -5
View File
@@ -14,7 +14,7 @@ const { blockText } = require('./blocks');
* specific step in the editor.
*/
const INDEX_VERSION = 1;
const INDEX_VERSION = 2;
function tokenize(text) {
if (!text) return [];
@@ -27,20 +27,82 @@ function tokenize(text) {
class SearchIndex {
constructor(indexDir) {
this.file = path.join(indexDir, 'search-index.json');
// Per-guide source fingerprints so a startup reconcile can tell which
// guides changed while the app was closed, without re-reading every step.
this.fingerprints = {}; // guideId -> fingerprint string
// Recovery status surfaced to the UI: 'ok' | 'reset' (missing/corrupt/
// version mismatch) | 'reconciled' (rebuilt from the store at startup).
this.status = 'ok';
const fileExisted = require('node:fs').existsSync(this.file);
const stored = readJsonIfExists(this.file, null);
if (stored && stored.version === INDEX_VERSION) {
if (stored && stored.version === INDEX_VERSION && stored.docs && typeof stored.docs === 'object') {
this.docs = stored.docs;
this.fingerprints = stored.fingerprints || {};
} else {
// Missing, corrupt, or an older index version: start empty and mark it,
// so reconcile() rebuilds from the store instead of silently staying
// blank (which made search "work" but return nothing). A file that
// existed but could not be used is a 'reset' (recovery-worthy); a
// genuinely absent index on first run is just 'ok'.
this.docs = {}; // docKey -> { guideId, stepId, title, text, updatedAt }
this.status = fileExisted ? 'reset' : 'ok';
}
}
persist() {
writeJsonSync(this.file, { version: INDEX_VERSION, docs: this.docs });
writeJsonSync(this.file, {
version: INDEX_VERSION,
docs: this.docs,
fingerprints: this.fingerprints,
});
}
static fingerprint(guide) {
return `${guide.updatedAt || ''}:${Number.isInteger(guide.revision) ? guide.revision : 0}`;
}
/**
* Reconcile the index against the store at startup: reindex guides that are
* new or changed (by fingerprint), and drop index entries for guides that no
* longer exist. Returns a summary with a recovery status for the UI.
*/
reconcile(store) {
const guides = store.listGuides();
const liveIds = new Set(guides.map((g) => g.guideId));
let reindexed = 0;
let removed = 0;
// Drop docs/fingerprints for guides that are gone.
for (const key of Object.keys(this.fingerprints)) {
if (!liveIds.has(key)) {
this.removeGuide(key, { persist: false });
delete this.fingerprints[key];
removed += 1;
}
}
for (const guide of guides) {
const fp = SearchIndex.fingerprint(guide);
const indexed = this.fingerprints[guide.guideId];
const hasDoc = Boolean(this.docs[`g:${guide.guideId}`]);
if (indexed === fp && hasDoc) continue; // unchanged
try {
this.indexGuide(guide, store.listSteps(guide.guideId), { persist: false });
reindexed += 1;
} catch {
// A single unreadable guide must not abort the whole reconcile.
}
}
this.persist();
if (this.status === 'reset' || reindexed > 0 || removed > 0) {
this.status = this.status === 'reset' ? 'reset' : 'reconciled';
}
return { status: this.status, reindexed, removed, total: guides.length };
}
/** (Re)index one guide and all of its steps. */
indexGuide(guide, stepsMap) {
indexGuide(guide, stepsMap, { persist = true } = {}) {
this.removeGuide(guide.guideId, { persist: false });
const placeholderText = Object.entries(guide.placeholders || {})
@@ -69,13 +131,15 @@ class SearchIndex {
updatedAt: guide.updatedAt,
};
}
this.persist();
this.fingerprints[guide.guideId] = SearchIndex.fingerprint(guide);
if (persist) this.persist();
}
removeGuide(guideId, { persist = true } = {}) {
for (const key of Object.keys(this.docs)) {
if (this.docs[key].guideId === guideId) delete this.docs[key];
}
delete this.fingerprints[guideId];
if (persist) this.persist();
}
+23
View File
@@ -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',
+97 -9
View File
@@ -3,7 +3,8 @@
const fs = require('node:fs');
const path = require('node:path');
const { zipDirSync, extractZipSync } = require('./zip');
const { atomicWriteFileSync } = require('./util');
const { atomicWriteFileSync, readJsonSync } = require('./util');
const { validateGuide } = require('./schema');
/**
* Snapshot backups: a zip of the guide directory (excluding history/) stored
@@ -16,7 +17,11 @@ function snapshotsDir(store, guideId) {
}
function snapshotName(label) {
const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace(/-\d{3}Z$/, 'Z');
// Keep milliseconds: stripping them made two snapshots taken within the same
// second collide on filename (the second silently overwrote the first, so
// rapid automatic backups produced only one file). ms keeps names unique and
// still chronologically sortable.
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
return label ? `${stamp}-${label.replace(/[^A-Za-z0-9_-]+/g, '_')}.zip` : `${stamp}.zip`;
}
@@ -50,20 +55,103 @@ function pruneSnapshots(store, guideId, keepLast) {
/**
* Restore a snapshot: replaces the guide's current content (guide.json and
* steps/) with the snapshot's, keeping the history/ directory intact.
*
* The extraction is staged and validated BEFORE any live content is touched:
* a corrupt or truncated snapshot can no longer destroy the current guide.
* The swap itself moves the old content aside, moves the new content in, then
* deletes the old so a failure mid-swap leaves a recoverable state.
*/
function restoreSnapshot(store, guideId, name) {
const file = path.join(snapshotsDir(store, guideId), path.basename(name));
if (!fs.existsSync(file)) throw new Error(`snapshot not found: ${name}`);
const buf = fs.readFileSync(file);
const guideDir = store.guideDir(guideId);
// Safety: snapshot the pre-restore state too, so a restore is undoable.
createSnapshot(store, guideId, { label: 'pre-restore' });
for (const entry of fs.readdirSync(guideDir)) {
if (entry === 'history') continue;
fs.rmSync(path.join(guideDir, entry), { recursive: true, force: true });
// 1. Extract + validate into a temp staging dir. Nothing live is touched yet.
const staging = `${guideDir}.restoring-${Date.now()}`;
fs.rmSync(staging, { recursive: true, force: true });
try {
fs.mkdirSync(staging, { recursive: true });
extractZipSync(buf, staging);
const guideJson = path.join(staging, 'guide.json');
if (!fs.existsSync(guideJson)) throw new Error('snapshot is missing guide.json');
validateGuide(readJsonSync(guideJson)); // throws on a corrupt snapshot
} catch (err) {
fs.rmSync(staging, { recursive: true, force: true });
throw new Error(`snapshot restore aborted (snapshot invalid): ${err.message}`);
}
extractZipSync(buf, guideDir);
// 2. Snapshot the pre-restore state so the restore is itself undoable.
createSnapshot(store, guideId, { label: 'pre-restore' });
// 3. Swap in the validated content, preserving history/. Move live content
// aside first so we can roll back if a step fails.
const backup = `${guideDir}.prev-${Date.now()}`;
const liveEntries = fs.readdirSync(guideDir).filter((e) => e !== 'history');
fs.mkdirSync(backup, { recursive: true });
try {
for (const entry of liveEntries) {
fs.renameSync(path.join(guideDir, entry), path.join(backup, entry));
}
for (const entry of fs.readdirSync(staging)) {
if (entry === 'history') continue;
fs.renameSync(path.join(staging, entry), path.join(guideDir, entry));
}
} catch (err) {
// Roll back: restore whatever we moved aside.
for (const entry of fs.readdirSync(backup)) {
const dest = path.join(guideDir, entry);
fs.rmSync(dest, { recursive: true, force: true });
fs.renameSync(path.join(backup, entry), dest);
}
fs.rmSync(backup, { recursive: true, force: true });
fs.rmSync(staging, { recursive: true, force: true });
throw err;
}
fs.rmSync(backup, { recursive: true, force: true });
fs.rmSync(staging, { recursive: true, force: true });
return store.getGuide(guideId);
}
module.exports = { createSnapshot, listSnapshots, pruneSnapshots, restoreSnapshot, snapshotsDir };
/**
* Automatic backup policy. Every guide keeps a small save counter in its
* history dir; once `everyNSaves` saves accumulate (and backups.automatic is
* on) an automatic snapshot is taken and old ones pruned to backups.keepLast.
* Returns the snapshot name when one was taken, else null. Never throws a
* backup failure must not break the save that triggered it.
*/
function autoSnapshotIfDue(store, guideId, settings) {
try {
const backups = (settings && settings.get && settings.get('backups')) || {};
if (backups.automatic === false) return null;
const everyN = Number.isInteger(backups.everyNSaves) && backups.everyNSaves > 0 ? backups.everyNSaves : 25;
const keepLast = Number.isInteger(backups.keepLast) && backups.keepLast > 0 ? backups.keepLast : 10;
const dir = path.join(store.guideDir(guideId), 'history');
fs.mkdirSync(dir, { recursive: true });
const counterFile = path.join(dir, 'autosave-counter.json');
let count = 0;
try {
count = JSON.parse(fs.readFileSync(counterFile, 'utf8')).count || 0;
} catch { count = 0; }
count += 1;
if (count >= everyN) {
createSnapshot(store, guideId, { label: 'auto', keepLast });
count = 0;
atomicWriteFileSync(counterFile, JSON.stringify({ count }));
return true;
}
atomicWriteFileSync(counterFile, JSON.stringify({ count }));
return null;
} catch (err) {
// Best effort: report, never break the caller's save.
console.error(`[stepforge] automatic backup failed for ${guideId}: ${err && err.message}`);
return null;
}
}
module.exports = {
createSnapshot, listSnapshots, pruneSnapshots, restoreSnapshot, snapshotsDir,
autoSnapshotIfDue,
};
+88 -10
View File
@@ -12,6 +12,22 @@ const {
} = require('./schema');
const { sanitizeHtml } = require('./sanitize');
/**
* Thrown by revision-aware saves when the on-disk revision no longer matches
* the caller's expectation i.e. someone else wrote in between. Callers that
* pass expectedRevision (background/AI/capture writes) use this to avoid
* clobbering a newer user edit.
*/
class RevisionConflictError extends Error {
constructor(kind, id, expected, actual) {
super(`${kind} ${id} changed since it was read (expected revision ${expected}, found ${actual})`);
this.name = 'RevisionConflictError';
this.code = 'STEPFORGE_REVISION_CONFLICT';
this.expected = expected;
this.actual = actual;
}
}
/**
* Folder-based guide store. One directory per guide, one directory per step,
* all JSON written atomically. This is the only module that knows the
@@ -27,21 +43,49 @@ class GuideStore {
this.guidesDir = path.join(this.libraryDir, 'guides');
this.indexDir = path.join(this.libraryDir, 'index');
this.trashDir = path.join(this.libraryDir, 'trash');
this.quarantineDir = path.join(this.libraryDir, 'quarantine');
this.tempDir = path.join(rootDir, 'temp');
this.sharedLinksDir = path.join(rootDir, 'shared-links');
this.foldersFile = path.join(this.libraryDir, 'folders.json');
// In-memory log of files quarantined this session (corrupt/unreadable),
// surfaced to the UI instead of silently vanishing.
this.recoveryReport = [];
this.ensureLayout();
}
ensureLayout() {
for (const dir of [
this.settingsDir, this.templatesDir, this.guidesDir, this.indexDir,
this.trashDir, this.tempDir, this.sharedLinksDir,
this.trashDir, this.quarantineDir, this.tempDir, this.sharedLinksDir,
]) {
fs.mkdirSync(dir, { recursive: true });
}
}
/**
* Move a corrupt/unreadable file or directory into quarantine (preserving
* the original bytes) and record it, rather than silently dropping it. A
* guide/step never just disappears without an explanation.
*/
quarantine(sourcePath, kind, reason) {
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const dest = path.join(this.quarantineDir, `${kind}-${path.basename(sourcePath)}-${stamp}`);
try {
fs.mkdirSync(this.quarantineDir, { recursive: true });
fs.renameSync(sourcePath, dest);
} catch {
// If we cannot move it (e.g. cross-device or vanished), still record it.
}
const entry = { kind, source: sourcePath, quarantined: dest, reason: String(reason || 'unreadable'), at: nowIso() };
this.recoveryReport.push(entry);
return entry;
}
/** Corrupt files quarantined this session (for a recovery UI). */
getRecoveryReport() {
return [...this.recoveryReport];
}
guideDir(guideId) {
if (!/^[a-zA-Z0-9_-]+$/.test(guideId)) throw new Error(`bad guide id: ${guideId}`);
return path.join(this.guidesDir, guideId);
@@ -70,10 +114,19 @@ class GuideStore {
return normalizeGuide(raw);
}
saveGuide(guide, { touch = true } = {}) {
saveGuide(guide, { touch = true, expectedRevision = null } = {}) {
validateGuide(guide);
// Optimistic concurrency: a caller that read the guide can pass the
// revision it saw; if disk moved on since, refuse rather than clobber.
if (expectedRevision !== null) {
const current = this.guideExists(guide.guideId) ? this.getGuide(guide.guideId).revision : 0;
if (current !== expectedRevision) {
throw new RevisionConflictError('guide', guide.guideId, expectedRevision, current);
}
}
const stored = deepClone(guide);
stored.descriptionHtml = sanitizeHtml(stored.descriptionHtml);
stored.revision = (Number.isInteger(stored.revision) ? stored.revision : 0) + 1;
if (touch) stored.updatedAt = nowIso();
writeJsonSync(path.join(this.guideDir(guide.guideId), 'guide.json'), stored);
return stored;
@@ -83,11 +136,16 @@ class GuideStore {
const out = [];
for (const entry of fs.readdirSync(this.guidesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const file = path.join(this.guidesDir, entry.name, 'guide.json');
const dir = path.join(this.guidesDir, entry.name);
const file = path.join(dir, 'guide.json');
if (!fs.existsSync(file)) continue; // in-progress/empty dir, not corruption
try {
out.push(normalizeGuide(readJsonSync(file)));
} catch {
// skip unreadable entries rather than failing the whole library
} catch (err) {
// A corrupt guide.json used to make the guide silently vanish from the
// library. Quarantine the directory (preserving it) and record it so
// the user can be told, instead of losing it without explanation.
this.quarantine(dir, 'guide', err && err.message);
}
}
out.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
@@ -211,18 +269,38 @@ class GuideStore {
if (!fs.existsSync(stepsRoot)) return map;
for (const entry of fs.readdirSync(stepsRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const dir = path.join(stepsRoot, entry.name);
const file = path.join(dir, 'step.json');
if (!fs.existsSync(file)) continue;
try {
map.set(entry.name, normalizeStep(readJsonSync(path.join(stepsRoot, entry.name, 'step.json'))));
} catch {
// skip unreadable step
map.set(entry.name, normalizeStep(readJsonSync(file)));
} catch (err) {
// Quarantine a corrupt step (preserving it) and record it rather than
// silently dropping it from the guide.
this.quarantine(dir, 'step', err && err.message);
}
}
return map;
}
saveStep(guideId, step) {
saveStep(guideId, step, { expectedRevision = null } = {}) {
// Optimistic concurrency for background/AI/capture writes: refuse to
// overwrite a step that changed since it was read. Direct user edits pass
// no expectedRevision (last-write-wins — the user is the authority).
if (expectedRevision !== null) {
let current = 0;
try {
current = this.getStep(guideId, step.stepId).revision;
} catch {
current = 0; // step vanished; treat as revision 0
}
if (current !== expectedRevision) {
throw new RevisionConflictError('step', step.stepId, expectedRevision, current);
}
}
const stored = normalizeStep(deepClone(step));
stored.descriptionHtml = sanitizeHtml(stored.descriptionHtml);
stored.revision = (Number.isInteger(stored.revision) ? stored.revision : 0) + 1;
validateStep(stored);
writeJsonSync(path.join(this.stepDir(guideId, step.stepId), 'step.json'), stored);
const guide = this.getGuide(guideId);
@@ -352,4 +430,4 @@ class GuideStore {
}
}
module.exports = { GuideStore };
module.exports = { GuideStore, RevisionConflictError };
+56
View File
@@ -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,
+44 -8
View File
@@ -121,8 +121,22 @@ function zipSync(entries, { date = new Date(2026, 0, 1) } = {}) {
return Buffer.concat([...localParts, centralBuf, eocd]);
}
/** Parse a zip buffer into [{ name, data }] with CRC verification. */
function unzipSync(buffer) {
// Resource limits for untrusted archives (share files, snapshots). These cap
// memory and disk work so a ZIP bomb can't exhaust the machine. Callers that
// build archives themselves may relax them; imports use the defaults.
const DEFAULT_UNZIP_LIMITS = {
maxEntries: 50000,
maxTotalCompressed: 1024 * 1024 * 1024, // 1 GiB of stored bytes
maxTotalUncompressed: 4 * 1024 * 1024 * 1024, // 4 GiB inflated total
maxEntryUncompressed: 512 * 1024 * 1024, // 512 MiB per entry
};
/**
* Parse a zip buffer into [{ name, data }] with CRC verification and hard
* resource limits. `limits` overrides DEFAULT_UNZIP_LIMITS.
*/
function unzipSync(buffer, { limits = {} } = {}) {
const lim = { ...DEFAULT_UNZIP_LIMITS, ...limits };
if (!Buffer.isBuffer(buffer) || buffer.length < 22) throw new Error('zip: too small');
// Find end-of-central-directory record (scan backwards over the comment).
let eocd = -1;
@@ -132,11 +146,14 @@ function unzipSync(buffer) {
}
if (eocd < 0) throw new Error('zip: end record not found');
const count = buffer.readUInt16LE(eocd + 10);
if (count > lim.maxEntries) throw new Error(`zip: too many entries (${count} > ${lim.maxEntries})`);
let pos = buffer.readUInt32LE(eocd + 16);
const entries = [];
let totalCompressed = 0;
let totalUncompressed = 0;
for (let i = 0; i < count; i++) {
if (buffer.readUInt32LE(pos) !== 0x02014b50) throw new Error('zip: bad central header');
if (pos + 46 > buffer.length || buffer.readUInt32LE(pos) !== 0x02014b50) throw new Error('zip: bad central header');
const method = buffer.readUInt16LE(pos + 10);
const crc = buffer.readUInt32LE(pos + 16);
const compSize = buffer.readUInt32LE(pos + 20);
@@ -151,17 +168,33 @@ function unzipSync(buffer) {
assertSafeEntryName(name);
if (name.endsWith('/')) continue; // directory entry
// Budget checks BEFORE allocating/inflating: the declared sizes are
// attacker-controlled, so reject oversize claims up front.
if (uncompSize > lim.maxEntryUncompressed) {
throw new Error(`zip: entry too large (${uncompSize} > ${lim.maxEntryUncompressed}): ${name}`);
}
totalCompressed += compSize;
totalUncompressed += uncompSize;
if (totalCompressed > lim.maxTotalCompressed) throw new Error('zip: total compressed size exceeds limit');
if (totalUncompressed > lim.maxTotalUncompressed) throw new Error('zip: total inflated size exceeds limit');
if (buffer.readUInt32LE(localOffset) !== 0x04034b50) throw new Error('zip: bad local header');
const lNameLen = buffer.readUInt16LE(localOffset + 26);
const lExtraLen = buffer.readUInt16LE(localOffset + 28);
const dataStart = localOffset + 30 + lNameLen + lExtraLen;
if (dataStart + compSize > buffer.length) throw new Error(`zip: entry data out of range: ${name}`);
const raw = buffer.subarray(dataStart, dataStart + compSize);
let data;
if (method === 0) data = Buffer.from(raw);
else if (method === 8) data = zlib.inflateRawSync(raw);
else throw new Error(`zip: unsupported method ${method} for ${name}`);
else if (method === 8) {
// Cap inflation so a small deflate stream can't expand to gigabytes —
// even if the declared uncompSize lied, this is the real guard.
data = zlib.inflateRawSync(raw, { maxOutputLength: lim.maxEntryUncompressed });
} else throw new Error(`zip: unsupported method ${method} for ${name}`);
// Exact length match (not "at least"): the inflated bytes must equal the
// declared uncompressed size, and the CRC must verify.
if (data.length !== uncompSize) throw new Error(`zip: size mismatch for ${name}`);
if (crc32(data) !== crc) throw new Error(`zip: CRC mismatch for ${name}`);
entries.push({ name, data });
@@ -170,10 +203,10 @@ function unzipSync(buffer) {
}
/** Extract a zip buffer under destDir; every path is traversal-checked. */
function extractZipSync(buffer, destDir) {
function extractZipSync(buffer, destDir, { limits = {} } = {}) {
const resolvedDest = path.resolve(destDir);
const written = [];
for (const { name, data } of unzipSync(buffer)) {
for (const { name, data } of unzipSync(buffer, { limits })) {
const target = path.resolve(resolvedDest, name);
if (target !== resolvedDest && !target.startsWith(resolvedDest + path.sep)) {
throw new Error(`zip: entry escapes destination: ${name}`);
@@ -203,4 +236,7 @@ function zipDirSync(dir, { filter = () => true, prefix = '' } = {}) {
return zipSync(entries);
}
module.exports = { crc32, zipSync, unzipSync, extractZipSync, zipDirSync, assertSafeEntryName };
module.exports = {
crc32, zipSync, unzipSync, extractZipSync, zipDirSync, assertSafeEntryName,
DEFAULT_UNZIP_LIMITS,
};
+65
View File
@@ -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
View File
@@ -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",
+265
View File
@@ -0,0 +1,265 @@
'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');
});
// ---- stale AI responses cannot overwrite user edits --------------------------
test('an AI patch built from stale data does not clobber a mid-generation user edit', async (t) => {
const { GuideStore } = require('../../core/store');
const root = makeTmpDir('ai-stale-write');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const guide = store.createGuide({ title: 'G' });
const step = store.addStep(guide.guideId, { title: 'original title' });
const service = new TextIntelService({
store,
settings: makeSettings(),
dataDir: root,
fetchImpl: async (url) => {
const pathname = new URL(url).pathname;
if (pathname === '/api/show') {
return { ok: true, json: async () => ({ capabilities: ['completion'] }) };
}
if (pathname === '/api/chat') {
// While the model "thinks", the user edits and saves the step.
const current = store.getStep(guide.guideId, step.stepId);
store.saveStep(guide.guideId, { ...current, title: 'user edit during generation' });
return {
ok: true,
json: async () => ({ message: { content: JSON.stringify({ title: 'AI title from stale context' }) } }),
};
}
throw new Error(`unexpected fetch: ${pathname}`);
},
});
const result = await service.generateStepPatch({ guideId: guide.guideId, stepId: step.stepId, target: 'title' });
assert.equal(result.ok, false);
assert.match(result.reason, /changed while AI was generating/i);
assert.equal(store.getStep(guide.guideId, step.stepId).title, 'user edit during generation');
});
test('an AI patch applies cleanly when nothing changed during generation', async (t) => {
const { GuideStore } = require('../../core/store');
const root = makeTmpDir('ai-clean-write');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const guide = store.createGuide({ title: 'G' });
const step = store.addStep(guide.guideId, { title: '' });
const service = new TextIntelService({
store,
settings: makeSettings(),
dataDir: root,
fetchImpl: async (url) => {
const pathname = new URL(url).pathname;
if (pathname === '/api/show') {
return { ok: true, json: async () => ({ capabilities: ['completion'] }) };
}
if (pathname === '/api/chat') {
return {
ok: true,
json: async () => ({ message: { content: JSON.stringify({ title: 'Generated title' }) } }),
};
}
throw new Error(`unexpected fetch: ${pathname}`);
},
});
const result = await service.generateStepPatch({ guideId: guide.guideId, stepId: step.stepId, target: 'title' });
assert.equal(result.ok, true);
assert.equal(store.getStep(guide.guideId, step.stepId).title, 'Generated title');
});
// ---- 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\) \{/);
});
+120
View File
@@ -0,0 +1,120 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const CaptureService = require('../../app/capture');
function makeService({ settings: settingsOverrides, powerPolicy } = {}) {
const settingsData = {
'capture.mode': 'fullscreen',
'capture.delayMs': 0,
...settingsOverrides,
};
return new CaptureService({
store: {},
settings: { get: (k) => (k in settingsData ? settingsData[k] : null) },
getWindow: () => null,
notify: () => {},
powerPolicy,
screenApi: {
getCursorScreenPoint: () => ({ x: 0, y: 0 }),
getAllDisplays: () => [],
},
});
}
// ---- region rect clamping (bug: out-of-bounds / negative drags) -------------
test('overlayRectToImageRect scales, clamps, and normalizes selections', () => {
const svc = makeService();
const display = { bounds: { x: 0, y: 0, width: 100, height: 100 } };
const imgSize = { width: 200, height: 200 }; // 2x DPI
// Simple selection: display px -> image px (2x).
assert.deepEqual(
svc.overlayRectToImageRect({ x: 10, y: 20, w: 30, h: 40 }, display, imgSize),
{ x: 20, y: 40, width: 60, height: 80 }
);
// Negative-size drag (drawn up/left) normalizes to a positive rect.
assert.deepEqual(
svc.overlayRectToImageRect({ x: 40, y: 40, w: -20, h: -20 }, display, imgSize),
{ x: 40, y: 40, width: 40, height: 40 }
);
// Selection larger than the screen is clamped to the image bounds.
const clamped = svc.overlayRectToImageRect({ x: -10, y: -10, w: 200, h: 200 }, display, imgSize);
assert.deepEqual(clamped, { x: 0, y: 0, width: 200, height: 200 });
// Degenerate selections return null instead of an out-of-bounds crop.
assert.equal(svc.overlayRectToImageRect({ x: 0, y: 0, w: 0, h: 0 }, display, imgSize), null);
assert.equal(svc.overlayRectToImageRect({ x: 999, y: 999, w: 10, h: 10 }, display, imgSize), null);
assert.equal(svc.overlayRectToImageRect(null, display, imgSize), null);
});
// ---- power ownership follows recording state --------------------------------
test('the power blocker is held only while actively recording', () => {
const calls = [];
const powerPolicy = { setRecording: (on) => calls.push(on) };
const svc = makeService({ powerPolicy });
// startSession begins PAUSED — must not hold power.
svc.startSession('g1', { intervalSec: 0 });
assert.deepEqual(calls, [], 'a paused new session must not start the power blocker');
// Resume records -> power on. (togglePause(false) arms recording.)
svc.togglePause(false);
assert.deepEqual(calls, [true]);
// Pause -> power off. This is the tray/second-instance path that used to leak.
svc.togglePause(true);
assert.deepEqual(calls, [true, false]);
// Resume again -> on, finish -> off.
svc.togglePause(false);
svc.finishSession();
assert.deepEqual(calls, [true, false, true, false]);
});
test('finishSession releases power even if it was recording', () => {
const calls = [];
const svc = makeService({ powerPolicy: { setRecording: (on) => calls.push(on) } });
svc.startSession('g1', { intervalSec: 0 });
svc.togglePause(false);
calls.length = 0;
svc.finishSession();
assert.deepEqual(calls, [false]);
});
// ---- explicit click-source reporting ----------------------------------------
test('click source is unavailable outside a session and after stop', () => {
const svc = makeService();
assert.equal(svc.state().clickSource, 'unavailable');
assert.equal(svc.state().clickCapture, undefined); // no session -> no field
svc.clickSource = 'evdev-x11';
svc.stopClickWatcher();
assert.equal(svc.clickSource, 'unavailable');
});
test('state reports clickCapture true for a non-process (evdev) source', () => {
const svc = makeService();
svc.session = { guideId: 'g', paused: false, count: 0, intervalSec: 0 };
// evdev has no child process; the old Boolean(clickWatcher) reported false.
svc.clickSource = 'evdev-wayland';
const st = svc.state();
assert.equal(st.clickCapture, true);
assert.equal(st.clickSource, 'evdev-wayland');
});
// ---- drain never hangs quit -------------------------------------------------
test('drainPendingClicks resolves within the deadline even if the queue hangs', async () => {
const svc = makeService();
svc.clickQueue = new Promise(() => {}); // never settles
const start = Date.now();
await svc.drainPendingClicks(60);
assert.ok(Date.now() - start < 1000, 'drain must not block on a hung queue');
});
+26 -11
View File
@@ -68,7 +68,9 @@ function makeFrame(name, ageMs = 0, overrides = {}) {
// ---- fresh-shot fallback path ----------------------------------------------
test('click-triggered session capture uses the low-latency hide pause', async () => {
const service = makeService();
// The fresh-shot fallback only runs in non-strict (balanced) mode; strict
// mode skips rather than storing a post-click shot.
const service = makeService({ settings: { 'capture.strictClickFrames': false } });
service.session = { guideId: 'guide-1', paused: false, count: 0, intervalSec: 0 };
let seenOptions = null;
@@ -1006,7 +1008,9 @@ test('a buffered frame from a different display is ignored for click capture', a
});
test('a stale buffered frame is not reused — the click falls back to a fresh shot', async () => {
const service = makeService();
// Balanced (non-strict) mode: a stale frame is rejected and the click takes
// the fresh-shot fallback. (Strict mode skips instead — see below.)
const service = makeService({ settings: { 'capture.strictClickFrames': false } });
service.session = { guideId: 'guide-stale', paused: false, count: 0, intervalSec: 0 };
service.latestFrame = makeFrame('stale-png', 10_000);
@@ -1022,12 +1026,12 @@ test('a stale buffered frame is not reused — the click falls back to a fresh s
assert.equal(shootCalled, true, 'a stale buffered frame must not be reused');
});
test('strict mode: a frame whose grab started after the click is rejected', async () => {
// This replaces the old "idle click waits for the imminent loop frame"
// behavior: a grab that begins after the click can already show the
// click's effects, so strict mode takes the explicit fresh-shot fallback
// instead of passing it off as the click-time screen.
const service = makeService();
test('strict mode: no pre-click frame is skipped, never stored as a post-click shot', async () => {
// A grab that begins after the click can already show the click's effects.
// Strict mode's promise is that a stored step never shows the post-click
// screen, so when no pre-click frame qualifies it SKIPS with a diagnostic
// rather than taking a fresh (post-click) shot.
const service = makeService(); // strict is the default
service.session = { guideId: 'guide-strict', paused: false, count: 0, intervalSec: 0 };
service.frameLoopRunning = true;
service.frameLoopInFlight = false; // nothing in flight at click time
@@ -1041,11 +1045,18 @@ test('strict mode: a frame whose grab started after the click is rejected', asyn
shootCalled = true;
return { ok: true, step: { stepId: 'fresh-step' } };
};
const diagnostics = [];
service.notify = (channel, payload) => {
if (channel === 'capture:diagnostic') diagnostics.push(payload);
};
const result = await service.sessionCapture('click', { x: 1, y: 1 }, { at: clickAt });
assert.equal(result.ok, true);
assert.equal(shootCalled, true);
assert.equal(result.ok, false);
assert.match(result.reason, /strict/i);
assert.equal(shootCalled, false, 'strict mode must not store a post-click shot');
assert.equal(diagnostics.length, 1);
assert.equal(diagnostics[0].kind, 'strict-click-skipped');
});
test('balanced mode keeps the legacy slack: an imminent post-click frame is accepted', async () => {
@@ -1183,7 +1194,9 @@ test('click frames come from the stream backend when it is active', async () =>
});
test('a stream backend with no qualifying frame falls through to the fresh-shot path', async () => {
const service = makeService();
// Balanced (non-strict) mode: the fresh-shot fallback runs. Strict mode
// would skip rather than store a post-click shot.
const service = makeService({ settings: { 'capture.strictClickFrames': false } });
service.session = { guideId: 'guide-stream-miss', paused: false, count: 0, intervalSec: 0 };
service.streamBackend = {
isActive: () => true,
@@ -1279,6 +1292,8 @@ test('click capture marks the click-time cursor position', async () => {
if (key === 'capture.clickMarker') return true;
if (key === 'capture.clickMarkerColor') return '#E5484D';
if (key === 'editor.focusedViewDefaultForNewSteps') return false;
// Exercise the fresh-shot fallback: strict mode would skip instead.
if (key === 'capture.strictClickFrames') return false;
return null;
};
service.session = { guideId: 'guide-4', paused: false, count: 0, intervalSec: 0 };
+299
View File
@@ -0,0 +1,299 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const zlib = require('node:zlib');
const { GuideStore } = require('../../core/store');
const { SearchIndex } = require('../../core/search');
const { unzipSync, zipSync, crc32 } = require('../../core/zip');
const { exportGuideArchive, importGuideArchive } = require('../../core/archive');
const { createSnapshot, restoreSnapshot, autoSnapshotIfDue } = require('../../core/snapshots');
const { acquireLock, releaseLock } = require('../../core/locks');
const { makeTmpDir, rmrf, TINY_PNG } = require('./helpers');
// ---- ZIP bomb / resource limits ---------------------------------------------
test('unzip rejects an archive that declares too many entries', () => {
const many = [];
for (let i = 0; i < 20; i += 1) many.push({ name: `f${i}.txt`, data: 'x' });
const buf = zipSync(many);
assert.throws(() => unzipSync(buf, { limits: { maxEntries: 5 } }), /too many entries/);
});
test('unzip rejects an entry whose declared size exceeds the per-entry limit', () => {
const buf = zipSync([{ name: 'big.txt', data: Buffer.alloc(1000, 65) }]);
assert.throws(() => unzipSync(buf, { limits: { maxEntryUncompressed: 100 } }), /entry too large/);
});
test('unzip caps inflation so a deflate bomb cannot exhaust memory', () => {
// A hand-built entry whose deflate stream expands far past the cap. The
// maxOutputLength guard must abort inflation rather than allocating it all.
const bomb = Buffer.alloc(10 * 1024 * 1024, 0); // 10 MiB of zeros -> tiny deflate
const raw = zlib.deflateRawSync(bomb, { level: 9 });
const name = 'bomb';
const nameBuf = Buffer.from(name);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0x0800, 6);
local.writeUInt16LE(8, 8); // deflate
local.writeUInt32LE(crc32(bomb), 14);
local.writeUInt32LE(raw.length, 18);
local.writeUInt32LE(bomb.length, 22);
local.writeUInt16LE(nameBuf.length, 26);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(20, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(0x0800, 8);
central.writeUInt16LE(8, 10);
central.writeUInt32LE(crc32(bomb), 16);
central.writeUInt32LE(raw.length, 20);
central.writeUInt32LE(bomb.length, 24);
central.writeUInt16LE(nameBuf.length, 28);
central.writeUInt32LE(0, 42);
const localBlock = Buffer.concat([local, nameBuf, raw]);
const centralBlock = Buffer.concat([central, nameBuf]);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(1, 8);
eocd.writeUInt16LE(1, 10);
eocd.writeUInt32LE(centralBlock.length, 12);
eocd.writeUInt32LE(localBlock.length, 16);
const buf = Buffer.concat([localBlock, centralBlock, eocd]);
assert.throws(() => unzipSync(buf, { limits: { maxEntryUncompressed: 64 * 1024 } }));
});
// ---- transactional archive import -------------------------------------------
test('a corrupt step aborts the import leaving no partial guide', (t) => {
const root = makeTmpDir('import-atomic');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const guide = store.createGuide({ title: 'Src' });
store.addStep(guide.guideId, { title: 'S1' }, TINY_PNG, { width: 1, height: 1 });
const archiveFile = path.join(root, 'out.sfgz');
exportGuideArchive(store, guide.guideId, archiveFile);
// Corrupt the exported step so validation fails during import.
const { unzipSync: uz } = require('../../core/zip');
const entries = uz(fs.readFileSync(archiveFile));
const tampered = entries.map((e) => {
if (e.name.endsWith('step.json')) {
const obj = JSON.parse(e.data.toString('utf8'));
// Corrupt the image size to non-finite values — validateStep rejects
// an image step with an invalid size.
obj.image = { originalPath: 'original.png', workingPath: 'working.png', size: { width: 'x', height: null } };
return { name: e.name, data: Buffer.from(JSON.stringify(obj)) };
}
return { name: e.name, data: e.data };
});
fs.writeFileSync(archiveFile, zipSync(tampered));
const before = store.listGuides().length;
assert.throws(() => importGuideArchive(store, archiveFile, { mode: 'copy' }));
// No partial guide was left behind, and no staging dir remains.
assert.equal(store.listGuides().length, before);
const leftover = fs.readdirSync(store.guidesDir).filter((n) => n.includes('.importing'));
assert.deepEqual(leftover, []);
});
test('a valid archive imports cleanly', (t) => {
const root = makeTmpDir('import-ok');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const guide = store.createGuide({ title: 'Src' });
store.addStep(guide.guideId, { title: 'S1' }, TINY_PNG, { width: 1, height: 1 });
const archiveFile = path.join(root, 'out.sfgz');
exportGuideArchive(store, guide.guideId, archiveFile);
const imported = importGuideArchive(store, archiveFile, { mode: 'copy' });
assert.equal(imported.title, 'Src');
assert.equal(imported.stepsOrder.length, 1);
});
// ---- atomic snapshot restore ------------------------------------------------
test('restoring a corrupt snapshot never destroys the live guide', (t) => {
const root = makeTmpDir('snap-atomic');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const guide = store.createGuide({ title: 'Live' });
store.addStep(guide.guideId, { title: 'keep me' }, TINY_PNG, { width: 1, height: 1 });
const snap = createSnapshot(store, guide.guideId, { label: 'good' });
// Corrupt the snapshot zip so restore must abort.
const snapFile = path.join(store.guideDir(guide.guideId), 'history', 'snapshots', snap);
fs.writeFileSync(snapFile, Buffer.from('not a zip at all'));
assert.throws(() => restoreSnapshot(store, guide.guideId, snap), /restore aborted|invalid|zip/i);
// The live guide and its step are intact.
const after = store.getGuide(guide.guideId);
assert.equal(after.title, 'Live');
assert.equal(after.stepsOrder.length, 1);
});
test('restoring a valid snapshot swaps content and keeps history', (t) => {
const root = makeTmpDir('snap-ok');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const guide = store.createGuide({ title: 'V1' });
const s1 = store.addStep(guide.guideId, { title: 'first' }, TINY_PNG, { width: 1, height: 1 });
const snap = createSnapshot(store, guide.guideId, { label: 'v1' });
// Change the guide, then restore.
store.saveGuide({ ...store.getGuide(guide.guideId), title: 'V2' });
store.deleteStep(guide.guideId, s1.stepId);
assert.equal(store.getGuide(guide.guideId).title, 'V2');
const restored = restoreSnapshot(store, guide.guideId, snap);
assert.equal(restored.title, 'V1');
assert.equal(restored.stepsOrder.length, 1);
// history/ survived the restore (pre-restore snapshot exists too).
assert.ok(fs.existsSync(path.join(store.guideDir(guide.guideId), 'history')));
});
// ---- atomic locks -----------------------------------------------------------
test('another process holding a fresh lock is a conflict; release-by-token frees ours', (t) => {
const root = makeTmpDir('lock');
t.after(() => rmrf(root));
const { lockPathFor } = require('../../core/locks');
const target = path.join(root, 'shared.sfgz');
fs.writeFileSync(target, 'x');
// Simulate a different process already holding a fresh lock.
fs.writeFileSync(lockPathFor(target), JSON.stringify({
host: 'other-host', user: 'someone-else', pid: 999999,
token: 'their-token', acquiredAt: new Date().toISOString(),
}));
const attempt = acquireLock(target);
assert.equal(attempt.acquired, false);
assert.ok(attempt.conflict);
// We must not be able to release their lock with a guessed/absent token.
assert.equal(releaseLock(target, { token: 'wrong' }), false);
// Force-steal (user confirmed), then release by our own acquisition token.
const stolen = acquireLock(target, { force: true });
assert.equal(stolen.acquired, true);
assert.equal(releaseLock(target, { lock: stolen.lock }), true);
assert.equal(acquireLock(target).acquired, true);
});
test('the same process can re-acquire its own lock', (t) => {
const root = makeTmpDir('lock-reacquire');
t.after(() => rmrf(root));
const target = path.join(root, 'shared.sfgz');
fs.writeFileSync(target, 'x');
assert.equal(acquireLock(target).acquired, true);
// Same process, second acquire: not a conflict.
assert.equal(acquireLock(target).acquired, true);
});
test('force steal takes over a held lock', (t) => {
const root = makeTmpDir('lock-force');
t.after(() => rmrf(root));
const target = path.join(root, 'shared.sfgz');
fs.writeFileSync(target, 'x');
acquireLock(target);
const stolen = acquireLock(target, { force: true });
assert.equal(stolen.acquired, true);
});
// ---- search reconcile -------------------------------------------------------
test('reconcile rebuilds a missing index from the store', (t) => {
const root = makeTmpDir('search-rebuild');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const guide = store.createGuide({ title: 'Password reset guide' });
store.addStep(guide.guideId, { title: 'Open admin portal' }, TINY_PNG, { width: 1, height: 1 });
// A brand-new index (nothing persisted) must recover by reconciling.
const index = new SearchIndex(store.indexDir);
const summary = index.reconcile(store);
assert.equal(summary.reindexed, 1);
assert.ok(index.search('password').length > 0);
});
test('reconcile drops entries for deleted guides and reindexes changed ones', (t) => {
const root = makeTmpDir('search-reconcile');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const g1 = store.createGuide({ title: 'alpha guide' });
const g2 = store.createGuide({ title: 'beta guide' });
const index = new SearchIndex(store.indexDir);
index.reconcile(store);
assert.ok(index.search('alpha').length > 0);
// Delete g1 out from under the index and change g2's title.
store.deleteGuide(g1.guideId);
store.saveGuide({ ...store.getGuide(g2.guideId), title: 'beta renamed gamma' });
const summary = index.reconcile(store);
assert.equal(index.search('alpha').length, 0, 'deleted guide is gone from search');
assert.ok(index.search('gamma').length > 0, 'changed guide is reindexed');
assert.equal(summary.removed, 1);
});
test('a corrupt index file resets to a recoverable status', (t) => {
const root = makeTmpDir('search-corrupt');
t.after(() => rmrf(root));
const store = new GuideStore(root);
store.createGuide({ title: 'recoverable' });
fs.mkdirSync(store.indexDir, { recursive: true });
fs.writeFileSync(path.join(store.indexDir, 'search-index.json'), '{ corrupt json');
const index = new SearchIndex(store.indexDir);
const summary = index.reconcile(store);
assert.equal(summary.status, 'reset');
assert.ok(index.search('recoverable').length > 0);
});
// ---- automatic backups ------------------------------------------------------
test('autoSnapshotIfDue takes a snapshot every N saves and prunes', (t) => {
const root = makeTmpDir('auto-backup');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const guide = store.createGuide({ title: 'G' });
const settings = {
get: (k) => ({ automatic: true, everyNSaves: 3, keepLast: 2 }[k.replace('backups.', '')] ?? ({ backups: { automatic: true, everyNSaves: 3, keepLast: 2 } }[k])),
};
// The helper reads settings.get('backups'):
const s = { get: (k) => (k === 'backups' ? { automatic: true, everyNSaves: 3, keepLast: 2 } : null) };
const dir = path.join(store.guideDir(guide.guideId), 'history', 'snapshots');
const count = () => (fs.existsSync(dir) ? fs.readdirSync(dir).filter((n) => n.endsWith('.zip')).length : 0);
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), null); // 1
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), null); // 2
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), true); // 3 -> snapshot
assert.equal(count(), 1);
autoSnapshotIfDue(store, guide.guideId, s); // 1
autoSnapshotIfDue(store, guide.guideId, s); // 2
autoSnapshotIfDue(store, guide.guideId, s); // 3 -> snapshot
assert.equal(count(), 2);
autoSnapshotIfDue(store, guide.guideId, s);
autoSnapshotIfDue(store, guide.guideId, s);
autoSnapshotIfDue(store, guide.guideId, s); // 3rd snapshot, pruned to keepLast=2
assert.equal(count(), 2, 'pruned to keepLast');
});
test('autoSnapshotIfDue is a no-op when automatic backups are off', (t) => {
const root = makeTmpDir('auto-backup-off');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const guide = store.createGuide({ title: 'G' });
const s = { get: () => ({ automatic: false, everyNSaves: 1 }) };
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), null);
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), null);
const dir = path.join(store.guideDir(guide.guideId), 'history', 'snapshots');
assert.equal(fs.existsSync(dir) ? fs.readdirSync(dir).length : 0, 0);
});
+218
View File
@@ -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})`
);
}
});
+143
View File
@@ -0,0 +1,143 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { GuideStore, RevisionConflictError } = require('../../core/store');
const { makeTmpDir, rmrf, TINY_PNG } = require('./helpers');
// ---- revisions --------------------------------------------------------------
test('revisions start at 0 and increment on every save', (t) => {
const root = makeTmpDir('store-rev');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const guide = store.createGuide({ title: 'G' });
assert.equal(guide.revision, 0);
const step = store.addStep(guide.guideId, { title: 'S' }, TINY_PNG, { width: 1, height: 1 });
const r0 = store.getStep(guide.guideId, step.stepId).revision;
const saved1 = store.saveStep(guide.guideId, { ...step, title: 'S1' });
assert.equal(saved1.revision, r0 + 1);
const saved2 = store.saveStep(guide.guideId, { ...saved1, title: 'S2' });
assert.equal(saved2.revision, r0 + 2);
});
test('compare-and-swap: a stale expectedRevision is rejected, not clobbered', (t) => {
const root = makeTmpDir('store-cas');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const guide = store.createGuide({ title: 'G' });
const step = store.addStep(guide.guideId, { title: 'S' }, TINY_PNG, { width: 1, height: 1 });
const base = store.getStep(guide.guideId, step.stepId);
// A user edit lands first (no expectedRevision -> last-write-wins).
store.saveStep(guide.guideId, { ...base, title: 'user edit' });
// A background writer that read `base` tries to save with the stale revision.
assert.throws(
() => store.saveStep(guide.guideId, { ...base, title: 'stale background' }, { expectedRevision: base.revision }),
(err) => err instanceof RevisionConflictError && err.code === 'STEPFORGE_REVISION_CONFLICT'
);
// The user edit survived.
assert.equal(store.getStep(guide.guideId, step.stepId).title, 'user edit');
});
test('compare-and-swap succeeds when the revision still matches', (t) => {
const root = makeTmpDir('store-cas-ok');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const guide = store.createGuide({ title: 'G' });
const step = store.addStep(guide.guideId, { title: 'S' }, TINY_PNG, { width: 1, height: 1 });
const base = store.getStep(guide.guideId, step.stepId);
const saved = store.saveStep(guide.guideId, { ...base, title: 'ok' }, { expectedRevision: base.revision });
assert.equal(saved.title, 'ok');
assert.equal(saved.revision, base.revision + 1);
});
test('guide saves are revision-aware too', (t) => {
const root = makeTmpDir('store-guide-cas');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const guide = store.createGuide({ title: 'G' });
const base = store.getGuide(guide.guideId);
store.saveGuide({ ...base, title: 'first' });
assert.throws(
() => store.saveGuide({ ...base, title: 'stale' }, { expectedRevision: base.revision }),
RevisionConflictError
);
});
test('v1 data without a revision field reads as revision 0 and upgrades on save', (t) => {
const root = makeTmpDir('store-v1');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const guide = store.createGuide({ title: 'G' });
// Simulate legacy on-disk data: strip the revision field.
const file = path.join(store.guidesDir, guide.guideId, 'guide.json');
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
delete raw.revision;
fs.writeFileSync(file, JSON.stringify(raw));
const loaded = store.getGuide(guide.guideId);
assert.equal(loaded.revision, 0);
const saved = store.saveGuide(loaded);
assert.equal(saved.revision, 1);
});
// ---- corruption quarantine --------------------------------------------------
test('a corrupt guide is quarantined and reported, not silently dropped', (t) => {
const root = makeTmpDir('store-quarantine-guide');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const good = store.createGuide({ title: 'Good' });
const bad = store.createGuide({ title: 'Bad' });
// Corrupt the bad guide's JSON.
fs.writeFileSync(path.join(store.guidesDir, bad.guideId, 'guide.json'), '{ not valid json');
const listed = store.listGuides();
assert.deepEqual(listed.map((g) => g.guideId), [good.guideId]);
const report = store.getRecoveryReport();
assert.equal(report.length, 1);
assert.equal(report[0].kind, 'guide');
// The original bytes are preserved in quarantine, not deleted.
assert.ok(fs.existsSync(report[0].quarantined));
// The bad guide dir is gone from the live library.
assert.equal(fs.existsSync(path.join(store.guidesDir, bad.guideId)), false);
});
test('a corrupt step is quarantined and reported', (t) => {
const root = makeTmpDir('store-quarantine-step');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const guide = store.createGuide({ title: 'G' });
const good = store.addStep(guide.guideId, { title: 'good' }, TINY_PNG, { width: 1, height: 1 });
const bad = store.addStep(guide.guideId, { title: 'bad' }, TINY_PNG, { width: 1, height: 1 });
fs.writeFileSync(path.join(store.stepDir(guide.guideId, bad.stepId), 'step.json'), 'nonsense');
const steps = store.listSteps(guide.guideId);
assert.ok(steps.has(good.stepId));
assert.equal(steps.has(bad.stepId), false);
const report = store.getRecoveryReport();
assert.equal(report.some((r) => r.kind === 'step'), true);
});
test('an empty/in-progress guide directory is not treated as corruption', (t) => {
const root = makeTmpDir('store-empty-dir');
t.after(() => rmrf(root));
const store = new GuideStore(root);
// A directory with no guide.json (e.g. mid-create) must be skipped quietly.
fs.mkdirSync(path.join(store.guidesDir, 'orphan-dir'), { recursive: true });
assert.doesNotThrow(() => store.listGuides());
assert.equal(store.getRecoveryReport().length, 0);
});