Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f31f1407a5 | ||
|
|
c1ccb5739b | ||
|
|
dd71cffac5 | ||
|
|
f62e3e19cb | ||
|
|
c916234ae8 | ||
|
|
ccbb9b03dc | ||
|
|
6ffef69705 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
+165
-27
@@ -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). */
|
||||||
@@ -2025,31 +2166,28 @@ public static class SFHook {
|
|||||||
});
|
});
|
||||||
// The overlay may only display region.html; deny navigation/popups.
|
// The overlay may only display region.html; deny navigation/popups.
|
||||||
require('./security').installWindowSecurity(overlay, 'region');
|
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));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-30
@@ -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,
|
||||||
@@ -204,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();
|
||||||
@@ -228,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
|
||||||
@@ -514,7 +521,12 @@ function setupIpc() {
|
|||||||
});
|
});
|
||||||
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)
|
validate: (a) => c.id(a.guideId) && c.id(a.stepId)
|
||||||
&& (a.which === undefined || a.which === null || c.oneOf(a.which, ['original', 'working'])),
|
&& (a.which === undefined || a.which === null || c.oneOf(a.which, ['original', 'working'])),
|
||||||
@@ -599,6 +611,12 @@ function setupIpc() {
|
|||||||
validate: (a) => c.string(a.text, 200000)
|
validate: (a) => c.string(a.text, 200000)
|
||||||
&& c.optionalString(a.guideTitle, 1000) && c.optionalString(a.stepTitle, 1000),
|
&& 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));
|
||||||
|
|
||||||
@@ -645,43 +663,20 @@ function setupIpc() {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, { validate: (a) => c.id(a.guideId) });
|
}, { 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);
|
||||||
}
|
}
|
||||||
@@ -848,7 +843,7 @@ function setupIpc() {
|
|||||||
});
|
});
|
||||||
const result = runExport(format, ast, previewDir, options || {});
|
const result = runExport(format, ast, previewDir, options || {});
|
||||||
producedFiles.add(result.file);
|
producedFiles.add(result.file);
|
||||||
return { ok: true, file: result.file, fileUrl: `file://${result.file}` };
|
return { ok: true, file: result.file, fileUrl: pathToFileURL(result.file).href };
|
||||||
}, {
|
}, {
|
||||||
validate: (a) => c.id(a.guideId) && validFormat(a.format)
|
validate: (a) => c.id(a.guideId) && validFormat(a.format)
|
||||||
&& (a.options === undefined || security.isPlainArgs(a.options)),
|
&& (a.options === undefined || security.isPlainArgs(a.options)),
|
||||||
@@ -961,12 +956,36 @@ 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Deny-by-default permission policy. The only grant in the entire app is
|
// Deny-by-default permission policy. The only grant in the entire app is
|
||||||
@@ -1019,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) {
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
+1
-1
@@ -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', {
|
||||||
|
|||||||
+37
-2
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+174
-25
@@ -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.',
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# StepForge privacy and network contract
|
||||||
|
|
||||||
|
StepForge is **local-first**. Guides, screenshots, and settings live on your
|
||||||
|
machine and are never uploaded on their own. This document describes exactly
|
||||||
|
what data StepForge collects locally and the one situation in which data
|
||||||
|
leaves your device.
|
||||||
|
|
||||||
|
## What never happens
|
||||||
|
|
||||||
|
- No telemetry or analytics.
|
||||||
|
- No update checks, license checks, or "phone home".
|
||||||
|
- No cloud storage or sync.
|
||||||
|
- No dependency downloads at runtime (dependencies are installed only by you,
|
||||||
|
via `npm ci`).
|
||||||
|
|
||||||
|
## Data StepForge collects locally
|
||||||
|
|
||||||
|
When you capture a step, StepForge may record, **stored only on disk in your
|
||||||
|
data directory**, capture context to help title and describe the step:
|
||||||
|
|
||||||
|
- The screenshot image.
|
||||||
|
- OCR text read from the region around your click (via the bundled Tesseract
|
||||||
|
engine — this runs locally, it is not a network call).
|
||||||
|
- The foreground window title and application name.
|
||||||
|
- The accessibility label/role/value of the clicked UI element (Windows).
|
||||||
|
- Keyboard shortcuts you pressed (for example `Ctrl+T`).
|
||||||
|
|
||||||
|
### Raw typed text is OFF by default
|
||||||
|
|
||||||
|
StepForge can additionally record the **raw printable characters** you type
|
||||||
|
between captures. Because this can capture passwords or other secrets, it is
|
||||||
|
**disabled by default**. It is only recorded when you explicitly enable
|
||||||
|
`capture.captureTypedText`, and even then the characters are used only to
|
||||||
|
title the current step and are not retained beyond it. With the setting off,
|
||||||
|
raw characters are never read or stored (on Windows they never even leave the
|
||||||
|
keyboard-hook process).
|
||||||
|
|
||||||
|
## The one outbound feature: optional AI
|
||||||
|
|
||||||
|
StepForge has an **optional** AI integration that generates step titles and
|
||||||
|
descriptions with a local large-language-model runtime
|
||||||
|
([Ollama](https://ollama.com)). It is **off by default**. When you turn it on
|
||||||
|
and configure an endpoint:
|
||||||
|
|
||||||
|
- StepForge sends the step **screenshot** (only to vision-capable models, only
|
||||||
|
when "Attach screenshots" is on, and only if within the size limit) and the
|
||||||
|
step **text/capture context** to the configured Ollama endpoint.
|
||||||
|
- By default the endpoint must be a **local (loopback) address** — for example
|
||||||
|
`http://127.0.0.1:11434`. StepForge refuses to send data to a non-loopback
|
||||||
|
host unless you explicitly enable **"Allow remote AI host"**. Enabling that
|
||||||
|
option means your screenshots and text are sent to the remote host you
|
||||||
|
configured; StepForge cannot control what that host does with them.
|
||||||
|
- Every AI request has a timeout, can be cancelled (closing the guide cancels
|
||||||
|
in-flight requests), and runs under a bounded concurrency limit.
|
||||||
|
|
||||||
|
## Bundled dependencies
|
||||||
|
|
||||||
|
Beyond the Electron desktop shell, StepForge bundles the Tesseract OCR engine
|
||||||
|
and its English language data as production dependencies. All OCR runs locally.
|
||||||
|
|
||||||
|
## Where your data lives
|
||||||
|
|
||||||
|
- Windows: `%APPDATA%\stepforge`
|
||||||
|
- Linux: `~/.local/share/stepforge` (or `$XDG_DATA_HOME/stepforge`)
|
||||||
|
- Override with the `STEPFORGE_DATA_DIR` environment variable.
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "stepforge",
|
"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",
|
||||||
|
|||||||
@@ -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\) \{/);
|
||||||
|
});
|
||||||
@@ -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
@@ -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 };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user