Author SHA1 Message Date
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
20 changed files with 1994 additions and 174 deletions
+15 -6
View File
@@ -1,17 +1,26 @@
# StepForge # 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 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 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), exports the result to Markdown, DOCX, PPTX, PDF, HTML (WIP), GIF (WIP),
confluence (WIP), Wiki.js (WIP), and image bundles (WIP). The current confluence (WIP), Wiki.js (WIP), and image bundles (WIP). The current
reconmendations for exporting is Markdown and PDF. reconmendations for exporting is Markdown and PDF.
It is an independent offline desktop guide-capture tool inspired by publicly It is an independent desktop guide-capture tool inspired by publicly
documented workflow patterns of commercial documentation tools like Folge. It contains no documented workflow patterns of commercial documentation tools like Folge. It
third-party branding, assets, or code from those tools, and it never talks to contains no third-party branding, assets, or code from those tools.
the network: no telemetry, no update checks, no license checks, no cloud, no
remote AI. **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 ## Overview
+169 -27
View File
@@ -198,11 +198,18 @@ class CaptureService {
notify, notify,
screenApi = screen, screenApi = screen,
textIntel = null, textIntel = null,
powerPolicy = null,
}) { }) {
this.store = store; this.store = store;
this.settings = settings; this.settings = settings;
this.getWindow = getWindow; this.getWindow = getWindow;
this.notify = notify; this.notify = notify;
// 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 // Injectable for tests; the click/coordinate paths must never reach for
// the global `screen` directly so coordinate handling stays testable. // the global `screen` directly so coordinate handling stays testable.
this.screen = screenApi; this.screen = screenApi;
@@ -213,6 +220,10 @@ class CaptureService {
this.session = null; // { guideId, paused, count, intervalSec } this.session = null; // { guideId, paused, count, intervalSec }
this.intervalTimer = null; this.intervalTimer = null;
this.clickWatcher = 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.frameLoopTimer = null;
this.frameLoopRunning = false; this.frameLoopRunning = false;
this.frameWaiters = []; this.frameWaiters = [];
@@ -240,6 +251,22 @@ class CaptureService {
this.warmingUp = false; 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() { state() {
return this.session return this.session
? { ? {
@@ -248,12 +275,21 @@ class CaptureService {
guideId: this.session.guideId, guideId: this.session.guideId,
count: this.session.count, count: this.session.count,
intervalSec: this.session.intervalSec || 0, 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(), clickCaptureAvailable: this.clickCaptureAvailable(),
clickFrameSource: this.streamBackend ? 'stream' : (this.frameLoopRunning ? 'loop' : 'idle'), clickFrameSource: this.streamBackend ? 'stream' : (this.frameLoopRunning ? 'loop' : 'idle'),
strictClickFrames: this.strictClickFrames(), 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 }; this.session = { guideId, paused: true, count: 0, intervalSec: interval };
if (this.settings.get('capture.captureOutsideClicks') !== false) this.startClickWatcher(); if (this.settings.get('capture.captureOutsideClicks') !== false) this.startClickWatcher();
this.applyInterval(); 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()); this.notify('capture:state', this.state());
// (Skipped for the dev screenshot hook, which needs a visible page.) // (Skipped for the dev screenshot hook, which needs a visible page.)
@@ -476,6 +515,9 @@ class CaptureService {
this.stopFrameLoop(); this.stopFrameLoop();
this.stopClickFrameBackend(); 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(); if (this.rebuildTrayMenu) this.rebuildTrayMenu();
this.notify('capture:state', this.state()); this.notify('capture:state', this.state());
} }
@@ -555,6 +597,9 @@ class CaptureService {
this.stopClickFrameBackend(); this.stopClickFrameBackend();
this.destroySessionTray(); this.destroySessionTray();
this.session = null; 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) { if (this.hiddenForSession) {
this.hiddenForSession = false; this.hiddenForSession = false;
this.showWindow(); this.showWindow();
@@ -562,6 +607,23 @@ class CaptureService {
this.notify('capture:state', this.state()); 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 * True when the user is interacting with StepForge itself. Deliberately
* based on cursor position over the visible window, not isFocused(): * 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); if (result.ok) this.noteStepAdded(result.step, trigger, guideId);
return result; 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' }; 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 // For non-click triggers (interval, hotkey, manual) pull the latest frame
@@ -1009,6 +1082,7 @@ class CaptureService {
? ['stdbuf', '-oL', 'xinput', 'test-xi2', '--root'] ? ['stdbuf', '-oL', 'xinput', 'test-xi2', '--root']
: ['xinput', 'test-xi2', '--root']; : ['xinput', 'test-xi2', '--root'];
this.clickWatcher = spawn(argv[0], argv.slice(1), { stdio: ['ignore', 'pipe', 'ignore'] }); this.clickWatcher = spawn(argv[0], argv.slice(1), { stdio: ['ignore', 'pipe', 'ignore'] });
this.clickSource = 'x11';
this.clickWatcher.stdout.on('data', (chunk) => { this.clickWatcher.stdout.on('data', (chunk) => {
this.ingestClickWatcherChunk(chunk.toString(), 'linux'); this.ingestClickWatcherChunk(chunk.toString(), 'linux');
}); });
@@ -1025,6 +1099,11 @@ class CaptureService {
// by other processes and a polling loop can miss short clicks under // 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 // load; WH_MOUSE_LL gives us one event for each button-down, with the
// hook-time cursor position and timestamp. // 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 = ` const ps = `
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
Add-Type -TypeDefinition @' Add-Type -TypeDefinition @'
@@ -1054,6 +1133,7 @@ public static class SFHook {
private const uint PROCESS_POWER_THROTTLING_EXECUTION_SPEED = 0x1; private const uint PROCESS_POWER_THROTTLING_EXECUTION_SPEED = 0x1;
private const uint HIGH_PRIORITY_CLASS = 0x00000080; private const uint HIGH_PRIORITY_CLASS = 0x00000080;
private static readonly bool CaptureTypedText = ${captureTypedText};
private static IntPtr hook = IntPtr.Zero; private static IntPtr hook = IntPtr.Zero;
private static IntPtr keyHook = IntPtr.Zero; private static IntPtr keyHook = IntPtr.Zero;
private static LowLevelMouseProc proc = MouseHookCallback; private static LowLevelMouseProc proc = MouseHookCallback;
@@ -1306,8 +1386,10 @@ public static class SFHook {
queue.Enqueue("KEY Escape " + unixMs); signal.Set(); queue.Enqueue("KEY Escape " + unixMs); signal.Set();
} else if (vk == 0x0D) { } else if (vk == 0x0D) {
queue.Enqueue("KEY Enter " + unixMs); signal.Set(); queue.Enqueue("KEY Enter " + unixMs); signal.Set();
} else { } else if (CaptureTypedText) {
// Map to Unicode character using current keyboard layout + shift state. // 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]; byte[] ks = new byte[256];
GetKeyboardState(ks); GetKeyboardState(ks);
var sb = new System.Text.StringBuilder(4); var sb = new System.Text.StringBuilder(4);
@@ -1366,6 +1448,7 @@ public static class SFHook {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true, windowsHide: true,
}); });
this.clickSource = 'windows-hook';
this.clickWatcher.stdout.on('data', (chunk) => { this.clickWatcher.stdout.on('data', (chunk) => {
this.ingestClickWatcherChunk(chunk.toString(), 'win32'); this.ingestClickWatcherChunk(chunk.toString(), 'win32');
}); });
@@ -1417,6 +1500,7 @@ public static class SFHook {
this.clickWatcher = null; this.clickWatcher = null;
} }
this.stopEvdevWatcher(); this.stopEvdevWatcher();
this.clickSource = 'unavailable';
this.clickWatcherBuf = ''; this.clickWatcherBuf = '';
this.linuxEvent = null; this.linuxEvent = null;
this.discardPendingRawClick(); this.discardPendingRawClick();
@@ -1442,26 +1526,48 @@ public static class SFHook {
buf = rest; buf = rest;
for (const button of presses) this.onOsClick(Date.now(), null, button); for (const button of presses) this.onOsClick(Date.now(), null, button);
}); });
// A device can disappear (unplugged); just drop that stream. // A device can disappear (unplugged): drop that stream, and if it was
stream.on('error', () => {}); // 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); this.evdevStreams.push(stream);
} catch { } catch {
// Node became unreadable between enumeration and open — skip it. // Node became unreadable between enumeration and open — skip it.
} }
} }
if (!this.evdevStreams.length) { 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 { } 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)' : ''}`); 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() { stopEvdevWatcher() {
if (!this.evdevStreams) return; 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 */ } try { stream.destroy(); } catch { /* already closed */ }
} }
this.evdevStreams = null;
} }
/** /**
@@ -1790,6 +1896,11 @@ public static class SFHook {
this._keyLastAt = now; this._keyLastAt = now;
if (type === 'CHAR') { 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); const ch = typeof data === 'number' ? String.fromCharCode(data) : String(data);
this._keyBuffer = (this._keyBuffer + ch).slice(-200); this._keyBuffer = (this._keyBuffer + ch).slice(-200);
} else if (type === 'KEY') { } else if (type === 'KEY') {
@@ -1994,13 +2105,43 @@ public static class SFHook {
const cropped = image.crop(rect); const cropped = image.crop(rect);
const size = cropped.getSize(); const size = cropped.getSize();
if (!size.width || !size.height) return { ok: false, reason: 'empty selection' }; 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, image: cropped,
size, size,
display, display,
cursor: null, cursor: null,
}, null, 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). */ /** Fullscreen overlay window that resolves with a crop rect (image px). */
@@ -2019,33 +2160,34 @@ public static class SFHook {
webPreferences: { webPreferences: {
preload: path.join(__dirname, 'region-preload.js'), preload: path.join(__dirname, 'region-preload.js'),
contextIsolation: true, 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; 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) => { const finish = (rect) => {
if (settled) return; if (settled) return;
settled = true; settled = true;
ipcMain.removeListener('region:picked', onPick);
if (!overlay.isDestroyed()) overlay.close(); if (!overlay.isDestroyed()) overlay.close();
resolve(rect); resolve(rect);
}; };
const { ipcMain } = require('electron');
const onPick = (event, rect) => { const onPick = (event, rect) => {
if (event.sender !== overlay.webContents) return; if (event.sender !== overlay.webContents) return;
ipcMain.removeListener('region:picked', onPick);
if (!rect) return finish(null); if (!rect) return finish(null);
const imgSize = image.getSize(); finish(this.overlayRectToImageRect(rect, display, 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),
});
}; };
ipcMain.on('region:picked', onPick); ipcMain.on('region:picked', onPick);
overlay.on('closed', () => finish(null)); 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));
}); });
} }
} }
+257 -87
View File
@@ -3,6 +3,7 @@
const path = require('node:path'); const path = require('node:path');
const fs = require('node:fs'); const fs = require('node:fs');
const os = require('node:os'); const os = require('node:os');
const { pathToFileURL } = require('node:url');
const { const {
app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, globalShortcut, app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, globalShortcut,
clipboard, nativeImage, screen, powerSaveBlocker, session, desktopCapturer, clipboard, nativeImage, screen, powerSaveBlocker, session, desktopCapturer,
@@ -15,12 +16,13 @@ const { TemplateManager, FORMATS, FORMAT_LABELS } = require('../core/templates')
const { buildRenderAst } = require('../core/renderast'); const { buildRenderAst } = require('../core/renderast');
const { runExport, EXPORTERS } = require('../exporters'); const { runExport, EXPORTERS } = require('../exporters');
const { runExportInWorker } = require('./export-runner'); const { runExportInWorker } = require('./export-runner');
const { exportGuideArchive, importGuideArchive, saveLinkedGuide, readArchive } = require('../core/archive'); const { exportGuideArchive, importGuideArchive, saveLinkedGuide } = require('../core/archive');
const { createSnapshot, listSnapshots, restoreSnapshot } = require('../core/snapshots'); const { createSnapshot, listSnapshots, restoreSnapshot } = require('../core/snapshots');
const { readLock } = require('../core/locks'); const { readLock } = require('../core/locks');
const CaptureService = require('./capture'); const CaptureService = require('./capture');
const { TextIntelService } = require('./text-intel'); const { TextIntelService } = require('./text-intel');
const { keepProcessesResponsive } = require('./win-power'); const { keepProcessesResponsive } = require('./win-power');
const security = require('./security');
const PACKAGE_JSON = require(path.join(__dirname, '..', 'package.json')); const PACKAGE_JSON = require(path.join(__dirname, '..', 'package.json'));
const APP_ID = 'com.stepforge.app'; const APP_ID = 'com.stepforge.app';
@@ -95,6 +97,7 @@ function createWindow() {
preload: path.join(__dirname, 'preload.js'), preload: path.join(__dirname, 'preload.js'),
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
sandbox: true,
spellcheck: Boolean(settings.get('spellcheck')), spellcheck: Boolean(settings.get('spellcheck')),
// During a recording the window is minimized (Linux) or hidden (Windows). // During a recording the window is minimized (Linux) or hidden (Windows).
// A throttled renderer stops processing capture:added events, so the step // A throttled renderer stops processing capture:added events, so the step
@@ -103,6 +106,10 @@ function createWindow() {
backgroundThrottling: false, 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.loadFile(path.join(__dirname, 'renderer', 'index.html'));
mainWindow.once('ready-to-show', () => { mainWindow.once('ready-to-show', () => {
mainWindow.show(); mainWindow.show();
@@ -198,7 +205,12 @@ function createWindow() {
// Second scenario, reproducing the "I clicked many times but only // Second scenario, reproducing the "I clicked many times but only
// got two screenshots" report: a fast burst of clicks immediately // got two screenshots" report: a fast burst of clicks immediately
// followed by finishing the session, so most clicks are still // 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' }); const burstGuide = store.createGuide({ title: 'burst selftest' });
capture.startSession(burstGuide.guideId, { intervalSec: 0 }); capture.startSession(burstGuide.guideId, { intervalSec: 0 });
capture.stopClickWatcher(); capture.stopClickWatcher();
@@ -222,6 +234,7 @@ function createWindow() {
const burstSteps = store.getGuide(burstGuide.guideId).stepsOrder.length; const burstSteps = store.getGuide(burstGuide.guideId).stepsOrder.length;
console.log('CLICK-SELFTEST burst:', burstSteps, 'of', burstCount, console.log('CLICK-SELFTEST burst:', burstSteps, 'of', burstCount,
burstSteps === burstCount ? 'OK — no clicks dropped on finish' : 'FAIL — clicks lost'); 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 // Helper: wait until armRecording has finished warming (window
// hidden, buffer primed) so an injected click counts as a real // hidden, buffer primed) so an injected click counts as a real
@@ -375,7 +388,37 @@ function sendToRenderer(channel, payload) {
// ---- IPC ------------------------------------------------------------------ // ---- IPC ------------------------------------------------------------------
function setupIpc() { 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 // library
h('library:list', () => ({ h('library:list', () => ({
@@ -393,60 +436,71 @@ function setupIpc() {
}); });
reindex(guide.guideId); reindex(guide.guideId);
return guide; return guide;
}); }, { validate: (a) => c.optionalString(a.title, 500) });
h('library:duplicate', ({ guideId }) => { h('library:duplicate', ({ guideId }) => {
const copy = store.duplicateGuide(guideId); const copy = store.duplicateGuide(guideId);
reindex(copy.guideId); reindex(copy.guideId);
return copy; return copy;
}); }, { validate: (a) => c.id(a.guideId) });
h('library:delete', ({ guideId }) => { h('library:delete', ({ guideId }) => {
store.deleteGuide(guideId); store.deleteGuide(guideId);
searchIndex.removeGuide(guideId); searchIndex.removeGuide(guideId);
return true; return true;
}); }, { validate: (a) => c.id(a.guideId) });
h('library:setFavorite', ({ guideId, favorite }) => store.setFavorite(guideId, favorite)); h('library:setFavorite', ({ guideId, favorite }) => store.setFavorite(guideId, favorite),
{ validate: (a) => c.id(a.guideId) });
h('library:trash:list', () => store.listTrash()); h('library:trash:list', () => store.listTrash());
h('library:trash:restore', ({ name }) => { h('library:trash:restore', ({ name }) => {
const id = store.restoreFromTrash(name); const id = store.restoreFromTrash(name);
reindex(id); reindex(id);
return id; return id;
}); }, { validate: (a) => c.fileName(a.name) });
h('library:trash:purge', ({ names } = {}) => { h('library:trash:purge', ({ names } = {}) => {
if (names && names.length) store.purgeTrashItems(names); if (names && names.length) store.purgeTrashItems(names);
else store.purgeTrash(); else store.purgeTrash();
return true; 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:create', ({ name, parentId }) => store.createFolder(name, parentId || null),
h('folders:rename', ({ folderId, name }) => store.renameFolder(folderId, name)); { validate: (a) => c.string(a.name, 200) && c.optionalId(a.parentId) });
h('folders:delete', ({ folderId }) => store.deleteFolder(folderId)); h('folders:rename', ({ folderId, name }) => store.renameFolder(folderId, name),
h('folders:moveGuide', ({ guideId, folderId }) => store.moveGuideToFolder(guideId, folderId || null)); { 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 // guide + steps
h('guide:get', ({ guideId }) => ({ h('guide:get', ({ guideId }) => ({
guide: store.getGuide(guideId), guide: store.getGuide(guideId),
steps: orderedSteps(guideId), steps: orderedSteps(guideId),
})); }), { validate: (a) => c.id(a.guideId) });
h('guide:save', ({ guide }) => { h('guide:save', ({ guide }) => {
const saved = store.saveGuide(guide); const saved = store.saveGuide(guide);
reindex(guide.guideId); reindex(guide.guideId);
return saved; return saved;
}); }, { validate: (a) => security.isPlainArgs(a.guide) && a.guide && c.id(a.guide.guideId) });
h('step:add', ({ guideId, fields, imageBase64, size, position }) => { h('step:add', ({ guideId, fields, imageBase64, size, position }) => {
const buf = imageBase64 ? Buffer.from(imageBase64, 'base64') : null; const buf = imageBase64 ? Buffer.from(imageBase64, 'base64') : null;
const step = store.addStep(guideId, fields || {}, buf, size || null, { position }); const step = store.addStep(guideId, fields || {}, buf, size || null, { position });
reindex(guideId); reindex(guideId);
return step; 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 }) => { h('step:save', ({ guideId, step }) => {
const saved = store.saveStep(guideId, step); const saved = store.saveStep(guideId, step);
reindex(guideId); reindex(guideId);
return saved; return saved;
}); }, { validate: (a) => c.id(a.guideId) && security.isPlainArgs(a.step) && a.step && c.id(a.step.stepId) });
h('step:delete', ({ guideId, stepId }) => { h('step:delete', ({ guideId, stepId }) => {
store.deleteStep(guideId, stepId); store.deleteStep(guideId, stepId);
reindex(guideId); reindex(guideId);
return true; return true;
}); }, { validate: (a) => c.id(a.guideId) && c.id(a.stepId) });
h('step:restore', ({ guideId, step, originalBase64, workingBase64, position }) => { h('step:restore', ({ guideId, step, originalBase64, workingBase64, position }) => {
const images = { const images = {
original: originalBase64 ? Buffer.from(originalBase64, 'base64') : null, original: originalBase64 ? Buffer.from(originalBase64, 'base64') : null,
@@ -455,20 +509,39 @@ function setupIpc() {
const restored = store.restoreStep(guideId, step, images, position); const restored = store.restoreStep(guideId, step, images, position);
reindex(guideId); reindex(guideId);
return restored; 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 }) => { h('step:imagePath', ({ guideId, stepId, which }) => {
const p = store.stepImagePath(guideId, stepId, which || 'working'); 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 }) => 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 }) => { h('step:resetWorkingImage', ({ guideId, stepId }) => {
const p = store.stepImagePath(guideId, stepId, 'original'); const p = store.stepImagePath(guideId, stepId, 'original');
const img = nativeImage.createFromPath(p); const img = nativeImage.createFromPath(p);
const { width, height } = img.getSize(); const { width, height } = img.getSize();
return store.resetWorkingImage(guideId, stepId, { width, height }); return store.resetWorkingImage(guideId, stepId, { width, height });
}); }, { validate: (a) => c.id(a.guideId) && c.id(a.stepId) });
h('step:fromClipboard', ({ guideId, position }) => { h('step:fromClipboard', ({ guideId, position }) => {
const img = clipboard.readImage(); const img = clipboard.readImage();
if (img.isEmpty()) return { ok: false, reason: 'clipboard has no image' }; if (img.isEmpty()) return { ok: false, reason: 'clipboard has no image' };
@@ -479,7 +552,7 @@ function setupIpc() {
}, img.toPNG(), { width, height }, { position }); }, img.toPNG(), { width, height }, { position });
reindex(guideId); reindex(guideId);
return { ok: true, step }; return { ok: true, step };
}); }, { validate: (a) => c.id(a.guideId) && c.optionalNumber(a.position, 0, 100000) });
h('step:importImage', async ({ guideId }) => { h('step:importImage', async ({ guideId }) => {
const res = await dialog.showOpenDialog(mainWindow, { const res = await dialog.showOpenDialog(mainWindow, {
title: 'Import images as steps', title: 'Import images as steps',
@@ -497,11 +570,13 @@ function setupIpc() {
} }
reindex(guideId); reindex(guideId);
return { ok: true, steps }; return { ok: true, steps };
}); }, { validate: (a) => c.id(a.guideId) });
// search // search
h('search:query', ({ q, guideId }) => searchIndex.search(q, { guideId: guideId || null })); h('search:query', ({ q, guideId }) => searchIndex.search(q, { guideId: guideId || null }),
h('search:titles', ({ q }) => searchIndex.searchTitles(q)); { 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 // settings + placeholders
h('settings:all', () => settings.data); h('settings:all', () => settings.data);
@@ -510,13 +585,13 @@ function setupIpc() {
if (keyPath === 'appearance') applyTheme(); if (keyPath === 'appearance') applyTheme();
if (keyPath.startsWith('capture.hotkey')) registerHotkeys(); if (keyPath.startsWith('capture.hotkey')) registerHotkeys();
return settings.data; return settings.data;
}); }, { validate: (a) => c.settingsKeyPath(a.keyPath) });
h('ai:test', async ({ enabled = null, ollama = null } = {}) => { h('ai:test', async ({ enabled = null, ollama = null } = {}) => {
return textIntel.testAiConnection({ return textIntel.testAiConnection({
enabled, enabled,
ollama, ollama,
}); });
}); }, { validate: (a) => (a.ollama === undefined || a.ollama === null || security.isPlainArgs(a.ollama)) });
h('ai:fillStep', async ({ guideId, stepId, target = 'all', blockId = null } = {}) => { h('ai:fillStep', async ({ guideId, stepId, target = 'all', blockId = null } = {}) => {
const result = await textIntel.generateStepPatch({ const result = await textIntel.generateStepPatch({
guideId, guideId,
@@ -526,10 +601,22 @@ function setupIpc() {
}); });
if (result.ok) reindex(guideId); if (result.ok) reindex(guideId);
return result; 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 = '' } = {}) => { h('ai:rewriteText', async ({ text, guideTitle = '', stepTitle = '' } = {}) => {
return textIntel.rewriteText({ 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:get', () => settings.getGlobalPlaceholders());
h('placeholders:globals:set', (values) => settings.setGlobalPlaceholders(values)); h('placeholders:globals:set', (values) => settings.setGlobalPlaceholders(values));
@@ -552,6 +639,10 @@ function setupIpc() {
} }
} }
return result; 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 }) => { h('capture:region', async ({ guideId }) => {
const result = await capture.regionCapture(guideId); const result = await capture.regionCapture(guideId);
@@ -571,50 +662,31 @@ function setupIpc() {
} }
} }
return result; return result;
}); }, { validate: (a) => c.id(a.guideId) });
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 */ }
};
// 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 }) => { h('capture:session', async ({ action, guideId, intervalSec }) => {
if (action === 'start') { if (action === 'start') {
capture.startSession(guideId, { intervalSec: intervalSec ?? null }); capture.startSession(guideId, { intervalSec: intervalSec ?? null });
startCapturePower();
keepCaptureProcessesResponsive();
} else if (action === 'pause') { } else if (action === 'pause') {
capture.togglePause(true); capture.togglePause(true);
stopCapturePower();
} else if (action === 'resume') { } else if (action === 'resume') {
capture.togglePause(false); capture.togglePause(false);
startCapturePower();
keepCaptureProcessesResponsive();
} else if (action === 'finish') { } else if (action === 'finish') {
capture.finishSession(); capture.finishSession();
stopCapturePower();
} else if (action === 'interval') { } else if (action === 'interval') {
capture.setInterval(intervalSec); capture.setInterval(intervalSec);
} }
const state = capture.state(); const state = capture.state();
sendToRenderer('capture:state', state); sendToRenderer('capture:state', state);
return 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()); h('capture:state', () => capture.state());
@@ -629,7 +701,7 @@ function setupIpc() {
if (res.canceled) return { ok: false }; if (res.canceled) return { ok: false };
exportGuideArchive(store, guideId, res.filePath); exportGuideArchive(store, guideId, res.filePath);
return { ok: true, path: res.filePath }; return { ok: true, path: res.filePath };
}); }, { validate: (a) => c.id(a.guideId) });
h('archive:open', async ({ mode }) => { h('archive:open', async ({ mode }) => {
const res = await dialog.showOpenDialog(mainWindow, { const res = await dialog.showOpenDialog(mainWindow, {
title: 'Open guide archive', title: 'Open guide archive',
@@ -644,30 +716,38 @@ function setupIpc() {
} catch (err) { } catch (err) {
return { ok: false, reason: err.message }; return { ok: false, reason: err.message };
} }
}); }, { validate: (a) => a.mode === undefined || c.oneOf(a.mode, ['copy', 'linked']) });
h('archive:peek', ({ file }) => { // archive:peek was removed: nothing in the renderer used it, and it let a
const { manifest } = readArchive(file); // compromised renderer read arbitrary local archives by path.
return manifest; h('archive:saveLinked', ({ guideId, force }) => saveLinkedGuide(store, guideId, { force: Boolean(force) }),
}); { validate: (a) => c.id(a.guideId) });
h('archive:saveLinked', ({ guideId, force }) => saveLinkedGuide(store, guideId, { force: Boolean(force) }));
// snapshots // 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 }) => 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 }) => { h('snapshots:restore', ({ guideId, name }) => {
const guide = restoreSnapshot(store, guideId, name); const guide = restoreSnapshot(store, guideId, name);
reindex(guideId); reindex(guideId);
return guide; return guide;
}); }, { validate: (a) => c.id(a.guideId) && c.fileName(a.name) });
// templates // templates
h('templates:list', ({ format }) => templates.list(format)); const validFormat = (v) => c.oneOf(v, FORMATS);
h('templates:load', ({ format, name }) => templates.load(format, name)); h('templates:list', ({ format }) => templates.list(format),
h('templates:save', ({ format, name, options }) => templates.save(format, name, options)); { validate: (a) => validFormat(a.format) });
h('templates:delete', ({ format, name }) => templates.remove(format, name)); h('templates:load', ({ format, name }) => templates.load(format, name),
h('templates:rename', ({ format, name, newName }) => templates.rename(format, name, newName)); { validate: (a) => validFormat(a.format) && c.fileName(a.name) });
h('templates:duplicate', ({ format, name }) => templates.duplicate(format, 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 }) => { h('templates:export', async ({ format, name }) => {
const res = await dialog.showSaveDialog(mainWindow, { const res = await dialog.showSaveDialog(mainWindow, {
defaultPath: `${name}.sfglt`, defaultPath: `${name}.sfglt`,
@@ -676,7 +756,7 @@ function setupIpc() {
if (res.canceled) return { ok: false }; if (res.canceled) return { ok: false };
templates.exportTemplate(format, name, res.filePath); templates.exportTemplate(format, name, res.filePath);
return { ok: true }; return { ok: true };
}); }, { validate: (a) => validFormat(a.format) && c.fileName(a.name) });
h('templates:import', async () => { h('templates:import', async () => {
const res = await dialog.showOpenDialog(mainWindow, { const res = await dialog.showOpenDialog(mainWindow, {
filters: [{ name: 'StepForge template', extensions: ['sfglt'] }], filters: [{ name: 'StepForge template', extensions: ['sfglt'] }],
@@ -709,15 +789,24 @@ function setupIpc() {
}[format]; }[format];
if (!mod) return {}; if (!mod) return {};
return { ...require(mod).DEFAULT_TEMPLATE }; return { ...require(mod).DEFAULT_TEMPLATE };
}); }, { validate: (a) => c.string(a.format, 40) });
h('export:run', async ({ guideId, format, options, outDir }) => { 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) { if (!dir) {
const res = await dialog.showOpenDialog(mainWindow, { const res = await dialog.showOpenDialog(mainWindow, {
title: 'Choose output folder', properties: ['openDirectory', 'createDirectory'], title: 'Choose output folder', properties: ['openDirectory', 'createDirectory'],
}); });
if (res.canceled) return { ok: false }; if (res.canceled) return { ok: false };
dir = res.filePaths[0]; dir = res.filePaths[0];
chosenOutputDirs.add(dir);
} }
settings.set(`exports.lastOutputDirs.${format}`, dir); settings.set(`exports.lastOutputDirs.${format}`, dir);
const result = await runExportInWorker({ const result = await runExportInWorker({
@@ -728,17 +817,23 @@ function setupIpc() {
outDir: dir, outDir: dir,
globals: settings.getGlobalPlaceholders(), globals: settings.getGlobalPlaceholders(),
}); });
producedFiles.add(result.file);
if (settings.get('exports.openFolderAfterExport')) shell.showItemInFolder(result.file); if (settings.get('exports.openFolderAfterExport')) shell.showItemInFolder(result.file);
return { ok: true, ...result }; 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 }) => { h('export:chooseDir', async ({ format }) => {
const res = await dialog.showOpenDialog(mainWindow, { const res = await dialog.showOpenDialog(mainWindow, {
title: 'Choose output folder', properties: ['openDirectory', 'createDirectory'], title: 'Choose output folder', properties: ['openDirectory', 'createDirectory'],
}); });
if (res.canceled) return null; if (res.canceled) return null;
chosenOutputDirs.add(res.filePaths[0]);
settings.set(`exports.lastOutputDirs.${format}`, res.filePaths[0]); settings.set(`exports.lastOutputDirs.${format}`, res.filePaths[0]);
return res.filePaths[0]; return res.filePaths[0];
}); }, { validate: (a) => validFormat(a.format) });
h('export:preview', ({ guideId, format, options }) => { h('export:preview', ({ guideId, format, options }) => {
const previewDir = path.join(store.tempDir, `preview-${guideId}-${format}`); const previewDir = path.join(store.tempDir, `preview-${guideId}-${format}`);
fs.rmSync(previewDir, { recursive: true, force: true }); fs.rmSync(previewDir, { recursive: true, force: true });
@@ -747,7 +842,11 @@ function setupIpc() {
maxSteps: settings.get('exports.previewStepCount') || 3, maxSteps: settings.get('exports.previewStepCount') || 3,
}); });
const result = runExport(format, ast, previewDir, options || {}); 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', () => { h('preview:cleanup', () => {
for (const entry of fs.readdirSync(store.tempDir)) { for (const entry of fs.readdirSync(store.tempDir)) {
@@ -758,9 +857,32 @@ function setupIpc() {
return true; return true;
}); });
// shell helpers // shell helpers — intent-specific, no arbitrary paths from the renderer.
h('shell:openPath', ({ target }) => shell.openPath(target)); // Only files this main process produced (exports/previews) may be opened.
h('shell:showItemInFolder', ({ target }) => shell.showItemInFolder(target)); 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', () => ({ h('app:info', () => ({
version: app.getVersion(), version: app.getVersion(),
buildVersion: PACKAGE_JSON.buildVersion || app.getVersion(), buildVersion: PACKAGE_JSON.buildVersion || app.getVersion(),
@@ -834,23 +956,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({ capture = new CaptureService({
store, store,
settings, settings,
getWindow: () => mainWindow, getWindow: () => mainWindow,
notify: captureNotify, notify: captureNotify,
textIntel, textIntel,
powerPolicy: capturePowerPolicy,
}); });
// Allow the hidden capture-worker renderer to open a desktop media stream. // Deny-by-default permission policy. The only grant in the entire app is
// Electron 29+ requires an explicit permission grant for display-capture in // display capture (and the media permission getDisplayMedia consults) for
// renderer windows; without it getUserMedia/getDisplayMedia fails, the // the dedicated hidden capture-worker page. Electron 29+ requires that
// stream backend never starts, and every capture falls back to // explicit grant; everything else — including our own main window — is
// desktopCapturer.getSources() — which triggers the portal dialog on Linux // rejected. "Content is local" is not a security control.
// on every single capture. StepForge is fully local/offline so allowing session.defaultSession.setPermissionCheckHandler((wc, permission, requestingOrigin, details) => {
// all permissions for our own content is safe. const url = (details && details.requestingUrl) || (wc && wc.getURL()) || requestingOrigin;
session.defaultSession.setPermissionCheckHandler(() => true); return security.permissionAllowed(permission, url);
session.defaultSession.setPermissionRequestHandler((_wc, _perm, cb) => cb(true)); });
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 // On GNOME Wayland the only working screen-capture path is the portal-backed
// getDisplayMedia (desktopCapturer source ids fail with "device not found"). // getDisplayMedia (desktopCapturer source ids fail with "device not found").
@@ -860,6 +1010,13 @@ if (!gotLock) {
// the chosen source then streams for the whole session. (useSystemPicker is // the chosen source then streams for the whole session. (useSystemPicker is
// macOS-only today, harmless elsewhere.) // macOS-only today, harmless elsewhere.)
session.defaultSession.setDisplayMediaRequestHandler((request, callback) => { 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'] }) desktopCapturer.getSources({ types: ['screen'] })
.then((sources) => { .then((sources) => {
console.log(`[stepforge] display-media request resolved: ${sources.length} screen source(s)`); console.log(`[stepforge] display-media request resolved: ${sources.length} screen source(s)`);
@@ -881,6 +1038,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', () => { app.on('will-quit', () => {
globalShortcut.unregisterAll(); globalShortcut.unregisterAll();
if (capture) { if (capture) {
+6 -2
View File
@@ -56,6 +56,7 @@ const api = {
test: invoke('ai:test'), test: invoke('ai:test'),
fillStep: invoke('ai:fillStep'), fillStep: invoke('ai:fillStep'),
rewriteText: invoke('ai:rewriteText'), rewriteText: invoke('ai:rewriteText'),
cancel: invoke('ai:cancel'),
}, },
capture: { capture: {
shoot: invoke('capture:shoot'), shoot: invoke('capture:shoot'),
@@ -95,8 +96,11 @@ const api = {
cleanupPreviews: invoke('preview:cleanup'), cleanupPreviews: invoke('preview:cleanup'),
}, },
shell: { shell: {
openPath: invoke('shell:openPath'), // Intent-specific shell access only: files the main process produced,
showItemInFolder: invoke('shell:showItemInFolder'), // the guide's linked archive, and scheme-validated external links.
openProduced: invoke('shell:openProduced'),
revealLinkedArchive: invoke('shell:revealLinkedArchive'),
openExternal: invoke('shell:openExternal'),
}, },
app: { app: {
info: invoke('app:info'), info: invoke('app:info'),
+19 -1
View File
@@ -172,7 +172,7 @@ class StepForgeApp {
el('div.welcome', {}, el('div.welcome', {},
el('div.welcome-title', {}, el('div.welcome-title', {},
el('h1', {}, 'StepForge'), 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('div.welcome-actions', {},
el('button.welcome-btn.primary', { el('button.welcome-btn.primary', {
@@ -934,6 +934,24 @@ class StepForgeApp {
window.StepForgeApp = 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() { function boot() {
const app = new StepForgeApp(); const app = new StepForgeApp();
app.init(); app.init();
+39 -4
View File
@@ -124,6 +124,7 @@ class GuideEditor {
this.currentZoom = 'fit'; this.currentZoom = 'fit';
this.pendingSave = false; this.pendingSave = false;
this.pendingGuideSave = false; this.pendingGuideSave = false;
this.saveError = null;
this.canvasHistory = []; this.canvasHistory = [];
this.canvasFuture = []; this.canvasFuture = [];
this.beforeCanvasSnapshot = null; this.beforeCanvasSnapshot = null;
@@ -151,6 +152,16 @@ class GuideEditor {
setActive(active) { setActive(active) {
this.active = Boolean(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) { setSettings(settings) {
@@ -314,6 +325,7 @@ class GuideEditor {
selectedAnnotationId: this.selectedAnnotationId, selectedAnnotationId: this.selectedAnnotationId,
linked: Boolean(this.guide && this.guide.linkedSource), linked: Boolean(this.guide && this.guide.linkedSource),
dirty: this.pendingSave || this.pendingGuideSave || this.descriptionDirty || this.titleDirty, dirty: this.pendingSave || this.pendingGuideSave || this.descriptionDirty || this.titleDirty,
saveError: this.saveError || null,
view: 'editor', view: 'editor',
}; };
} }
@@ -1419,8 +1431,22 @@ class GuideEditor {
async flushStep(step = this.currentStep) { async flushStep(step = this.currentStep) {
if (!step) return; 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; this.pendingSave = false;
const saved = await api.step.save({ guideId: this.guideId, step }); this.saveError = null;
const committed = this.commitSavedStep(saved); const committed = this.commitSavedStep(saved);
if (this.selectedStepId === committed.stepId) { if (this.selectedStepId === committed.stepId) {
this.renderStepList(); this.renderStepList();
@@ -1465,8 +1491,17 @@ class GuideEditor {
async flushGuide() { async flushGuide() {
if (!this.guide) return; if (!this.guide) return;
this.pendingGuideSave = false; try {
await api.guide.save({ guide: this.guide }); 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;
this.saveError = null;
this.emitMeta(); this.emitMeta();
} }
@@ -1899,7 +1934,7 @@ class GuideEditor {
onPreview: async ({ format, options }) => { onPreview: async ({ format, options }) => {
const preview = await api.export.preview({ guideId: this.guideId, format, options }); const preview = await api.export.preview({ guideId: this.guideId, format, options });
if (preview && preview.file) { 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).'); this.onToast('Preview opened (first steps only).');
} }
return true; return true;
@@ -1935,7 +1970,7 @@ class GuideEditor {
else this.onToast('Could not save linked archive.', { error: true }); else this.onToast('Could not save linked archive.', { error: true });
}, },
onOpenArchive: async () => { 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'), preload: path.join(__dirname, 'capture-worker-preload.js'),
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
sandbox: true,
// The worker must keep sampling while hidden — throttling a hidden // The worker must keep sampling while hidden — throttling a hidden
// window is exactly the wrong default for a frame recorder. // window is exactly the wrong default for a frame recorder.
backgroundThrottling: false, backgroundThrottling: false,
}, },
}); });
// The worker may only display capture-worker.html; deny navigation/popups.
require('./security').installWindowSecurity(win, 'captureWorker');
const listener = (event, msg) => { const listener = (event, msg) => {
if (event.sender === win.webContents) onEvent(msg); if (event.sender === win.webContents) onEvent(msg);
}; };
+174 -25
View File
@@ -8,6 +8,7 @@ const {
DEFAULT_CAPTURE_TITLES, DEFAULT_CAPTURE_TITLES,
buildCaptureTitle, buildCaptureTitle,
normalizeOllamaHost, normalizeOllamaHost,
validateOllamaHost,
normalizeAiPatch, normalizeAiPatch,
buildAiPrompt, buildAiPrompt,
applyAiPatchToStep, applyAiPatchToStep,
@@ -74,9 +75,101 @@ class TextIntelService {
this.workerQueue = Promise.resolve(); this.workerQueue = Promise.resolve();
this.ocrDataDir = path.join(dataDir, 'ocr', 'eng'); this.ocrDataDir = path.join(dataDir, 'ocr', 'eng');
this.modelCapabilityCache = new Map(); 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() { async shutdown() {
this.cancelInflight();
if (this.worker) { if (this.worker) {
try { try {
await this.worker.terminate(); await this.worker.terminate();
@@ -374,8 +467,19 @@ public static class Win32 {
if (!config.ollama.host) { if (!config.ollama.host) {
return { ok: false, reason: 'Set an Ollama host first.' }; return { ok: false, reason: 'Set an Ollama host first.' };
} }
const tagsUrl = new URL('/api/tags', `${config.ollama.host.replace(/\/+$/, '')}/`); let host;
const res = await this.fetch(tagsUrl, { method: 'GET' }); 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) { if (!res.ok) {
return { ok: false, reason: `Ollama check failed (${res.status})` }; 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 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 installed = config.ollama.model ? models.includes(config.ollama.model) : false;
const vision = installed ? await this.modelSupportsVision({ const vision = installed ? await this.modelSupportsVision({
host: config.ollama.host, host,
model: config.ollama.model, model: config.ollama.model,
}) : false; }) : false;
return { return {
@@ -391,7 +495,7 @@ public static class Win32 {
installed, installed,
vision, vision,
models, models,
host: config.ollama.host, host,
model: config.ollama.model, model: config.ollama.model,
}; };
} }
@@ -407,10 +511,9 @@ public static class Win32 {
const url = new URL('/api/show', `${normalizedHost.replace(/\/+$/, '')}/`); const url = new URL('/api/show', `${normalizedHost.replace(/\/+$/, '')}/`);
let capabilities = []; let capabilities = [];
try { try {
const response = await this.fetch(url, { const response = await this.fetchJson(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: { model: normalizedModel },
body: JSON.stringify({ model: normalizedModel }),
}); });
if (response.ok) { if (response.ok) {
const payload = await response.json(); const payload = await response.json();
@@ -439,12 +542,12 @@ public static class Win32 {
return fs.readFileSync(imagePath).toString('base64'); 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 url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`);
const response = await this.fetch(url, { const response = await this.withConcurrency(() => this.fetchJson(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, guideId,
body: JSON.stringify({ body: {
model, model,
stream: false, stream: false,
messages: [ messages: [
@@ -452,8 +555,8 @@ public static class Win32 {
{ role: 'user', content: prompt }, { role: 'user', content: prompt },
], ],
options: { temperature: 0.4 }, options: { temperature: 0.4 },
}), },
}); }));
if (!response.ok) throw new Error(`Ollama request failed (${response.status})`); if (!response.ok) throw new Error(`Ollama request failed (${response.status})`);
const payload = await response.json(); const payload = await response.json();
const content = payload?.message?.content; const content = payload?.message?.content;
@@ -461,16 +564,16 @@ public static class Win32 {
return content.trim(); 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 url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`);
const userMessage = { role: 'user', content: prompt }; const userMessage = { role: 'user', content: prompt };
if (Array.isArray(images) && images.length) { if (Array.isArray(images) && images.length) {
userMessage.images = images; userMessage.images = images;
} }
const response = await this.fetch(url, { const response = await this.withConcurrency(() => this.fetchJson(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, guideId,
body: JSON.stringify({ body: {
model, model,
stream: false, stream: false,
format: 'json', format: 'json',
@@ -481,8 +584,8 @@ public static class Win32 {
options: { options: {
temperature: 0.2, temperature: 0.2,
}, },
}), },
}); }));
if (!response.ok) { if (!response.ok) {
throw new Error(`Ollama request failed (${response.status})`); throw new Error(`Ollama request failed (${response.status})`);
} }
@@ -508,12 +611,23 @@ public static class Win32 {
if (!config.ollama.host || !config.ollama.model) { if (!config.ollama.host || !config.ollama.model) {
return { ok: false, reason: 'Configure Ollama host and model in Settings.' }; 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 guide = this.store.getGuide(guideId);
const step = this.store.getStep(guideId, stepId); const step = this.store.getStep(guideId, stepId);
if (!guide || !step) { if (!guide || !step) {
return { ok: false, reason: 'Guide or step not found.' }; 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 const currentBlock = blockId
? [...(step.textBlocks || []), ...(step.codeBlocks || []), ...(step.tableBlocks || [])].find((b) => b.id === blockId) || null ? [...(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.' }; 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) const screenshotAttached = Boolean(screenshotBase64)
? await this.modelSupportsVision({ ? await this.modelSupportsVision({
host: config.ollama.host, host,
model: config.ollama.model, model: config.ollama.model,
}) })
: false; : false;
@@ -588,15 +712,34 @@ public static class Win32 {
}); });
const raw = await this.callOllama({ const raw = await this.callOllama({
host: config.ollama.host, host,
model: config.ollama.model, model: config.ollama.model,
prompt, prompt,
systemPrompt, systemPrompt,
images: screenshotAttached ? [screenshotBase64] : [], images: screenshotAttached ? [screenshotBase64] : [],
guideId,
}); });
const patch = normalizeAiPatch(raw); const patch = normalizeAiPatch(raw);
const updated = applyAiPatchToStep(step, patch, { target, blockId }); // Re-read the step: while generation ran, a capture auto-doc or another
const saved = this.store.saveStep(guideId, updated); // 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 }; return { ok: true, step: saved, patch };
} catch (err) { } catch (err) {
return { ok: false, reason: err && err.message ? err.message : 'AI generation failed.' }; 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) { if (!config.ollama.host || !config.ollama.model) {
return { ok: false, reason: 'Configure Ollama host and model in Settings.' }; 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); const trimmed = normalizeWhitespace(text);
if (!trimmed) return { ok: false, reason: 'No text to rewrite.' }; if (!trimmed) return { ok: false, reason: 'No text to rewrite.' };
@@ -628,7 +777,7 @@ public static class Win32 {
].filter((l) => l !== null).join('\n'); ].filter((l) => l !== null).join('\n');
const result = await this.callOllamaText({ const result = await this.callOllamaText({
host: config.ollama.host, host,
model: config.ollama.model, model: config.ollama.model,
prompt, prompt,
systemPrompt: 'You are a documentation editor. Return only the improved text, nothing else.', systemPrompt: 'You are a documentation editor. Return only the improved text, nothing else.',
+5
View File
@@ -52,6 +52,9 @@ function createGuide(fields = {}) {
favorite: Boolean(fields.favorite), favorite: Boolean(fields.favorite),
linkedSource: fields.linkedSource || null, linkedSource: fields.linkedSource || null,
exportProfiles: { ...(fields.exportProfiles || {}) }, 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)) captureMetadata: (fields.captureMetadata && typeof fields.captureMetadata === 'object' && !Array.isArray(fields.captureMetadata))
? { ...fields.captureMetadata } ? { ...fields.captureMetadata }
: null, : null,
// Monotonic revision for optimistic concurrency (see createGuide).
revision: Number.isInteger(fields.revision) && fields.revision >= 0 ? fields.revision : 0,
}; };
} }
+23
View File
@@ -45,6 +45,13 @@ const DEFAULT_SETTINGS = {
// user is likely to click so the buffer holds frames of the now-visible // user is likely to click so the buffer holds frames of the now-visible
// screen rather than the just-dismissed app window. // screen rather than the just-dismissed app window.
postHideSettleMs: 150, 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: { editor: {
focusedViewDefaultForNewSteps: false, focusedViewDefaultForNewSteps: false,
@@ -52,6 +59,22 @@ const DEFAULT_SETTINGS = {
}, },
ai: { ai: {
enabled: false, 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: { ollama: {
host: 'http://127.0.0.1:11434', host: 'http://127.0.0.1:11434',
model: 'llama3.2:1b', model: 'llama3.2:1b',
+88 -10
View File
@@ -12,6 +12,22 @@ const {
} = require('./schema'); } = require('./schema');
const { sanitizeHtml } = require('./sanitize'); 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, * Folder-based guide store. One directory per guide, one directory per step,
* all JSON written atomically. This is the only module that knows the * 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.guidesDir = path.join(this.libraryDir, 'guides');
this.indexDir = path.join(this.libraryDir, 'index'); this.indexDir = path.join(this.libraryDir, 'index');
this.trashDir = path.join(this.libraryDir, 'trash'); this.trashDir = path.join(this.libraryDir, 'trash');
this.quarantineDir = path.join(this.libraryDir, 'quarantine');
this.tempDir = path.join(rootDir, 'temp'); this.tempDir = path.join(rootDir, 'temp');
this.sharedLinksDir = path.join(rootDir, 'shared-links'); this.sharedLinksDir = path.join(rootDir, 'shared-links');
this.foldersFile = path.join(this.libraryDir, 'folders.json'); 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(); this.ensureLayout();
} }
ensureLayout() { ensureLayout() {
for (const dir of [ for (const dir of [
this.settingsDir, this.templatesDir, this.guidesDir, this.indexDir, 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 }); 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) { guideDir(guideId) {
if (!/^[a-zA-Z0-9_-]+$/.test(guideId)) throw new Error(`bad guide id: ${guideId}`); if (!/^[a-zA-Z0-9_-]+$/.test(guideId)) throw new Error(`bad guide id: ${guideId}`);
return path.join(this.guidesDir, guideId); return path.join(this.guidesDir, guideId);
@@ -70,10 +114,19 @@ class GuideStore {
return normalizeGuide(raw); return normalizeGuide(raw);
} }
saveGuide(guide, { touch = true } = {}) { saveGuide(guide, { touch = true, expectedRevision = null } = {}) {
validateGuide(guide); 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); const stored = deepClone(guide);
stored.descriptionHtml = sanitizeHtml(stored.descriptionHtml); stored.descriptionHtml = sanitizeHtml(stored.descriptionHtml);
stored.revision = (Number.isInteger(stored.revision) ? stored.revision : 0) + 1;
if (touch) stored.updatedAt = nowIso(); if (touch) stored.updatedAt = nowIso();
writeJsonSync(path.join(this.guideDir(guide.guideId), 'guide.json'), stored); writeJsonSync(path.join(this.guideDir(guide.guideId), 'guide.json'), stored);
return stored; return stored;
@@ -83,11 +136,16 @@ class GuideStore {
const out = []; const out = [];
for (const entry of fs.readdirSync(this.guidesDir, { withFileTypes: true })) { for (const entry of fs.readdirSync(this.guidesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue; 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 { try {
out.push(normalizeGuide(readJsonSync(file))); out.push(normalizeGuide(readJsonSync(file)));
} catch { } catch (err) {
// skip unreadable entries rather than failing the whole library // 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)); out.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
@@ -211,18 +269,38 @@ class GuideStore {
if (!fs.existsSync(stepsRoot)) return map; if (!fs.existsSync(stepsRoot)) return map;
for (const entry of fs.readdirSync(stepsRoot, { withFileTypes: true })) { for (const entry of fs.readdirSync(stepsRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue; if (!entry.isDirectory()) continue;
const dir = path.join(stepsRoot, entry.name);
const file = path.join(dir, 'step.json');
if (!fs.existsSync(file)) continue;
try { try {
map.set(entry.name, normalizeStep(readJsonSync(path.join(stepsRoot, entry.name, 'step.json')))); map.set(entry.name, normalizeStep(readJsonSync(file)));
} catch { } catch (err) {
// skip unreadable step // 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; 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)); const stored = normalizeStep(deepClone(step));
stored.descriptionHtml = sanitizeHtml(stored.descriptionHtml); stored.descriptionHtml = sanitizeHtml(stored.descriptionHtml);
stored.revision = (Number.isInteger(stored.revision) ? stored.revision : 0) + 1;
validateStep(stored); validateStep(stored);
writeJsonSync(path.join(this.stepDir(guideId, step.stepId), 'step.json'), stored); writeJsonSync(path.join(this.stepDir(guideId, step.stepId), 'step.json'), stored);
const guide = this.getGuide(guideId); 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(/\/+$/, '')}`; 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) { function normalizeAiLevel(level) {
const key = normalizeWhitespace(level).toLowerCase(); const key = normalizeWhitespace(level).toLowerCase();
return AI_LEVEL_ALIASES.get(key) || (TEXTBLOCK_LEVELS.includes(key) ? key : 'info'); return AI_LEVEL_ALIASES.get(key) || (TEXTBLOCK_LEVELS.includes(key) ? key : 'info');
@@ -845,6 +899,8 @@ module.exports = {
buildCaptureTitle, buildCaptureTitle,
plainTextToHtml, plainTextToHtml,
normalizeOllamaHost, normalizeOllamaHost,
isLoopbackHost,
validateOllamaHost,
normalizeAiPatch, normalizeAiPatch,
buildAiPrompt, buildAiPrompt,
applyAiPatchToStep, applyAiPatchToStep,
+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", "name": "stepforge",
"version": "0.3.2", "version": "0.3.2",
"buildVersion": "0.3.2.1", "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", "main": "app/main.js",
"author": "StepForge [email protected]", "author": "StepForge [email protected]",
"license": "MPL-2.0", "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 ---------------------------------------------- // ---- fresh-shot fallback path ----------------------------------------------
test('click-triggered session capture uses the low-latency hide pause', async () => { 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 }; service.session = { guideId: 'guide-1', paused: false, count: 0, intervalSec: 0 };
let seenOptions = null; 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 () => { 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.session = { guideId: 'guide-stale', paused: false, count: 0, intervalSec: 0 };
service.latestFrame = makeFrame('stale-png', 10_000); 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'); 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 () => { test('strict mode: no pre-click frame is skipped, never stored as a post-click shot', async () => {
// This replaces the old "idle click waits for the imminent loop frame" // A grab that begins after the click can already show the click's effects.
// behavior: a grab that begins after the click can already show the // Strict mode's promise is that a stored step never shows the post-click
// click's effects, so strict mode takes the explicit fresh-shot fallback // screen, so when no pre-click frame qualifies it SKIPS with a diagnostic
// instead of passing it off as the click-time screen. // rather than taking a fresh (post-click) shot.
const service = makeService(); const service = makeService(); // strict is the default
service.session = { guideId: 'guide-strict', paused: false, count: 0, intervalSec: 0 }; service.session = { guideId: 'guide-strict', paused: false, count: 0, intervalSec: 0 };
service.frameLoopRunning = true; service.frameLoopRunning = true;
service.frameLoopInFlight = false; // nothing in flight at click time 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; shootCalled = true;
return { ok: true, step: { stepId: 'fresh-step' } }; 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 }); const result = await service.sessionCapture('click', { x: 1, y: 1 }, { at: clickAt });
assert.equal(result.ok, true); assert.equal(result.ok, false);
assert.equal(shootCalled, true); 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 () => { 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 () => { 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.session = { guideId: 'guide-stream-miss', paused: false, count: 0, intervalSec: 0 };
service.streamBackend = { service.streamBackend = {
isActive: () => true, 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.clickMarker') return true;
if (key === 'capture.clickMarkerColor') return '#E5484D'; if (key === 'capture.clickMarkerColor') return '#E5484D';
if (key === 'editor.focusedViewDefaultForNewSteps') return false; if (key === 'editor.focusedViewDefaultForNewSteps') return false;
// Exercise the fresh-shot fallback: strict mode would skip instead.
if (key === 'capture.strictClickFrames') return false;
return null; return null;
}; };
service.session = { guideId: 'guide-4', paused: false, count: 0, intervalSec: 0 }; service.session = { guideId: 'guide-4', paused: false, count: 0, intervalSec: 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);
});