Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fedf1d24c0 | ||
|
|
901940993c | ||
|
|
970d76a780 | ||
|
|
37079304c2 | ||
|
|
8aa9756b8a |
@@ -73,7 +73,12 @@ using only Node built-ins.
|
|||||||
|
|
||||||
For a Windows installation, see [docs/windows_installation](docs/windows_installation.md) or for a developer/more in depth walkthrough, see [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md).
|
For a Windows installation, see [docs/windows_installation](docs/windows_installation.md) or for a developer/more in depth walkthrough, see [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md).
|
||||||
|
|
||||||
On **Linux** (⚠️ work in progress — X11 vs Wayland, enabling per-click capture, the screen-share prompt), see [docs/GETTING_STARTED_WITH_LINUX.md](docs/GETTING_STARTED_WITH_LINUX.md).
|
On **Linux**, install from a package built for your distro family:
|
||||||
|
apt-based (Debian/Ubuntu) → [docs/linux/apt.md](docs/linux/apt.md); dnf-based
|
||||||
|
(Fedora) → [docs/linux/dnf.md](docs/linux/dnf.md). Wayland uses the XDG portal
|
||||||
|
for screen capture and a hotkey/interval trigger (per-click capture with a
|
||||||
|
marker needs X11 + xinput). The general developer walkthrough is
|
||||||
|
[docs/GETTING_STARTED_WITH_LINUX.md](docs/GETTING_STARTED_WITH_LINUX.md).
|
||||||
|
|
||||||
Requirements: Node.js 22.12+ and npm (pinned in `.nvmrc`; installs are
|
Requirements: Node.js 22.12+ and npm (pinned in `.nvmrc`; installs are
|
||||||
refused on older Nodes because the packaging toolchain needs 22.12+).
|
refused on older Nodes because the packaging toolchain needs 22.12+).
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const { buildRenderAst } = require('../core/renderast');
|
|||||||
const { runExport, EXPORTERS } = require('../exporters');
|
const { runExport, EXPORTERS } = require('../exporters');
|
||||||
const { runExportInWorker } = require('./export-runner');
|
const { runExportInWorker } = require('./export-runner');
|
||||||
const { exportGuideArchive, importGuideArchive, saveLinkedGuide } = require('../core/archive');
|
const { exportGuideArchive, importGuideArchive, saveLinkedGuide } = require('../core/archive');
|
||||||
const { createSnapshot, listSnapshots, restoreSnapshot } = require('../core/snapshots');
|
const { createSnapshot, listSnapshots, restoreSnapshot, autoSnapshotIfDue } = require('../core/snapshots');
|
||||||
const { readLock } = require('../core/locks');
|
const { readLock } = require('../core/locks');
|
||||||
const CaptureService = require('./capture');
|
const CaptureService = require('./capture');
|
||||||
const { TextIntelService } = require('./text-intel');
|
const { TextIntelService } = require('./text-intel');
|
||||||
@@ -72,6 +72,9 @@ function reindex(guideId) {
|
|||||||
} catch {
|
} catch {
|
||||||
// index failures must never block saves
|
// index failures must never block saves
|
||||||
}
|
}
|
||||||
|
// Automatic backup policy runs on the same save choke point. It is
|
||||||
|
// self-contained and never throws, so it can't affect the save either.
|
||||||
|
autoSnapshotIfDue(store, guideId, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
function orderedSteps(guideId) {
|
function orderedSteps(guideId) {
|
||||||
@@ -734,6 +737,13 @@ function setupIpc() {
|
|||||||
return guide;
|
return guide;
|
||||||
}, { validate: (a) => c.id(a.guideId) && c.fileName(a.name) });
|
}, { validate: (a) => c.id(a.guideId) && c.fileName(a.name) });
|
||||||
|
|
||||||
|
// recovery status: corrupt files quarantined this session and search index
|
||||||
|
// health, so the UI can surface them instead of data silently vanishing.
|
||||||
|
h('recovery:status', () => ({
|
||||||
|
quarantined: store.getRecoveryReport(),
|
||||||
|
searchStatus: searchIndex.status,
|
||||||
|
}));
|
||||||
|
|
||||||
// templates
|
// templates
|
||||||
const validFormat = (v) => c.oneOf(v, FORMATS);
|
const validFormat = (v) => c.oneOf(v, FORMATS);
|
||||||
h('templates:list', ({ format }) => templates.list(format),
|
h('templates:list', ({ format }) => templates.list(format),
|
||||||
@@ -889,6 +899,9 @@ function setupIpc() {
|
|||||||
dataDir: store.root,
|
dataDir: store.root,
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
}));
|
}));
|
||||||
|
// Platform capture-capability profile (session type, portal/PipeWire,
|
||||||
|
// xinput, click source, actionable messages) for the diagnostics UI.
|
||||||
|
h('platform:capabilities', () => require('./platform').detectCapabilities());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- lifecycle --------------------------------------------------------------
|
// ---- lifecycle --------------------------------------------------------------
|
||||||
@@ -916,6 +929,17 @@ if (!gotLock) {
|
|||||||
store = new GuideStore(dataDir);
|
store = new GuideStore(dataDir);
|
||||||
settings = new Settings(store.settingsDir);
|
settings = new Settings(store.settingsDir);
|
||||||
searchIndex = new SearchIndex(store.indexDir);
|
searchIndex = new SearchIndex(store.indexDir);
|
||||||
|
// Rebuild/reconcile the index against the library at startup so a missing,
|
||||||
|
// corrupt, or version-mismatched index recovers instead of silently
|
||||||
|
// returning nothing.
|
||||||
|
try {
|
||||||
|
const summary = searchIndex.reconcile(store);
|
||||||
|
if (summary.reindexed || summary.removed || summary.status !== 'ok') {
|
||||||
|
console.log(`[stepforge] search index reconciled: ${JSON.stringify(summary)}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[stepforge] search reconcile failed: ${err && err.message}`);
|
||||||
|
}
|
||||||
templates = new TemplateManager(store.templatesDir);
|
templates = new TemplateManager(store.templatesDir);
|
||||||
textIntel = new TextIntelService({
|
textIntel = new TextIntelService({
|
||||||
store,
|
store,
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { execFileSync } = require('node:child_process');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* macOS WindowContextProvider using AppleScript / System Events. Extracted
|
||||||
|
* verbatim from text-intel.js. macOS is not a primary support target, but the
|
||||||
|
* adapter is kept so the shared code has no `process.platform` branch and the
|
||||||
|
* behavior is preserved where it exists. Never throws.
|
||||||
|
*/
|
||||||
|
function createDarwinWindowContextProvider() {
|
||||||
|
return {
|
||||||
|
async collect() {
|
||||||
|
const script = `
|
||||||
|
set appName to ""
|
||||||
|
set windowTitle to ""
|
||||||
|
tell application "System Events"
|
||||||
|
try
|
||||||
|
set frontApp to first application process whose frontmost is true
|
||||||
|
set appName to name of frontApp
|
||||||
|
try
|
||||||
|
set windowTitle to name of front window of frontApp
|
||||||
|
end try
|
||||||
|
end try
|
||||||
|
end tell
|
||||||
|
return appName & linefeed & windowTitle
|
||||||
|
`;
|
||||||
|
try {
|
||||||
|
const result = execFileSync('osascript', ['-e', script], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
timeout: 1200,
|
||||||
|
}).trimEnd();
|
||||||
|
const [appName = '', windowTitle = ''] = result.split(/\r?\n/);
|
||||||
|
return { appName, windowTitle };
|
||||||
|
} catch {
|
||||||
|
return { appName: '', windowTitle: '' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createDarwinWindowContextProvider };
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single factory that selects a platform implementation. The rest of the
|
||||||
|
* app depends on the interfaces in ./interfaces.js and asks this module for a
|
||||||
|
* concrete adapter — it never branches on `process.platform` itself.
|
||||||
|
*
|
||||||
|
* As Linux runtime capture is implemented, its ClickSource / ScreenFrameSource
|
||||||
|
* adapters are added here; today this provides the WindowContextProvider for
|
||||||
|
* every platform and the Linux capability diagnostics.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { assertWindowContextProvider } = require('./interfaces');
|
||||||
|
|
||||||
|
function detectPlatform(platform = process.platform) {
|
||||||
|
if (platform === 'win32') return 'windows';
|
||||||
|
if (platform === 'darwin') return 'darwin';
|
||||||
|
if (platform === 'linux') return 'linux';
|
||||||
|
return 'unsupported';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the WindowContextProvider for the current OS. `platform` is injectable
|
||||||
|
* so the selection logic is unit-testable off the target OS.
|
||||||
|
*/
|
||||||
|
function createWindowContextProvider({ platform = process.platform } = {}) {
|
||||||
|
const os = detectPlatform(platform);
|
||||||
|
let provider;
|
||||||
|
switch (os) {
|
||||||
|
case 'windows':
|
||||||
|
provider = require('./windows/window-context').createWindowsWindowContextProvider();
|
||||||
|
break;
|
||||||
|
case 'darwin':
|
||||||
|
provider = require('./darwin/window-context').createDarwinWindowContextProvider();
|
||||||
|
break;
|
||||||
|
case 'linux':
|
||||||
|
provider = require('./linux/window-context').createLinuxWindowContextProvider();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Unsupported OS: a null-object provider so callers still work.
|
||||||
|
provider = { async collect() { return { appName: '', windowTitle: '' }; } };
|
||||||
|
}
|
||||||
|
return assertWindowContextProvider(provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capability profile for the current OS (used by diagnostics UI). Only Linux
|
||||||
|
* has a rich profile today; other platforms report their OS and a capable
|
||||||
|
* baseline.
|
||||||
|
*/
|
||||||
|
function detectCapabilities({ platform = process.platform, env = process.env } = {}) {
|
||||||
|
const os = detectPlatform(platform);
|
||||||
|
if (os === 'linux') {
|
||||||
|
return require('./linux/diagnostics').detectLinuxCapabilities({ env });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
os,
|
||||||
|
sessionType: os,
|
||||||
|
isWayland: false,
|
||||||
|
clickCapture: os === 'windows' ? 'windows-hook' : os,
|
||||||
|
screenCapture: os,
|
||||||
|
messages: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { detectPlatform, createWindowContextProvider, detectCapabilities };
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform adapter interfaces (documentation + light runtime shape checks).
|
||||||
|
*
|
||||||
|
* The platform-neutral capture/text-intel code consumes these interfaces and
|
||||||
|
* never inspects `process.platform` itself. `app/platform/index.js` is the
|
||||||
|
* only module that selects a concrete implementation. New OS support is a new
|
||||||
|
* set of files under `app/platform/<os>/`, not more conditionals inside the
|
||||||
|
* shared code.
|
||||||
|
*
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* WindowContextProvider
|
||||||
|
* collect(osPoint?: {x,y}) -> Promise<{
|
||||||
|
* appName, windowTitle,
|
||||||
|
* elementLabel?, elementRole?, elementClass?, elementValue?
|
||||||
|
* }>
|
||||||
|
* Best-effort foreground window / clicked-element context. Never throws;
|
||||||
|
* returns {} (or partial) when unavailable.
|
||||||
|
*
|
||||||
|
* ClickSource (runtime capture — implemented incrementally per platform)
|
||||||
|
* describe() -> { source, coordinates: boolean, keyboard: boolean }
|
||||||
|
* source ∈ 'windows-hook' | 'x11' | 'evdev-x11' | 'evdev-wayland' |
|
||||||
|
* 'wayland-portal' | 'hotkey' | 'interval' | 'unavailable'
|
||||||
|
*
|
||||||
|
* PowerPolicy
|
||||||
|
* setRecording(recording: boolean) -> void
|
||||||
|
* Holds/releases OS power + throttling state for the recording lifecycle.
|
||||||
|
*
|
||||||
|
* PlatformCapabilities (from index.detectCapabilities())
|
||||||
|
* { os, sessionType, isWayland, hasXinput, canSandbox, ... }
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Interface names, exported so adapters and tests can reference a single
|
||||||
|
// source of truth for the contract identifiers.
|
||||||
|
const INTERFACES = Object.freeze([
|
||||||
|
'WindowContextProvider',
|
||||||
|
'ClickSource',
|
||||||
|
'PowerPolicy',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const CLICK_SOURCES = Object.freeze([
|
||||||
|
'windows-hook',
|
||||||
|
'x11',
|
||||||
|
'evdev-x11',
|
||||||
|
'evdev-wayland',
|
||||||
|
'wayland-portal',
|
||||||
|
'hotkey',
|
||||||
|
'interval',
|
||||||
|
'unavailable',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Assert a value looks like a WindowContextProvider (has async collect()). */
|
||||||
|
function assertWindowContextProvider(provider) {
|
||||||
|
if (!provider || typeof provider.collect !== 'function') {
|
||||||
|
throw new Error('platform: WindowContextProvider must implement collect()');
|
||||||
|
}
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { INTERFACES, CLICK_SOURCES, assertWindowContextProvider };
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const { execFileSync } = require('node:child_process');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linux capture-capability diagnostics. Detects the session type, portal /
|
||||||
|
* PipeWire availability, xinput, readable input devices, and the sandbox
|
||||||
|
* situation, and turns them into an actionable capability profile the UI can
|
||||||
|
* show instead of console-only failures.
|
||||||
|
*
|
||||||
|
* Pure detection with injectable probes so it is unit-testable without a real
|
||||||
|
* desktop session.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function defaultHasBinary(name) {
|
||||||
|
try {
|
||||||
|
execFileSync('which', [name], { stdio: 'pipe' });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectSessionType(env = process.env) {
|
||||||
|
const t = String(env.XDG_SESSION_TYPE || '').toLowerCase();
|
||||||
|
if (t === 'wayland' || t === 'x11') return t;
|
||||||
|
if (env.WAYLAND_DISPLAY) return 'wayland';
|
||||||
|
if (env.DISPLAY) return 'x11';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectLinuxCapabilities({
|
||||||
|
env = process.env,
|
||||||
|
hasBinary = defaultHasBinary,
|
||||||
|
existsSync = fs.existsSync,
|
||||||
|
readdirSync = fs.readdirSync,
|
||||||
|
} = {}) {
|
||||||
|
const sessionType = detectSessionType(env);
|
||||||
|
const isWayland = sessionType === 'wayland';
|
||||||
|
|
||||||
|
// XDG Desktop Portal + PipeWire are how Wayland screen capture works.
|
||||||
|
const hasPortalBus = Boolean(env.DBUS_SESSION_BUS_ADDRESS);
|
||||||
|
let hasPipeWire = false;
|
||||||
|
try {
|
||||||
|
hasPipeWire = hasBinary('pipewire') || existsSync(`/run/user/${process.getuid ? process.getuid() : ''}/pipewire-0`);
|
||||||
|
} catch {
|
||||||
|
hasPipeWire = hasBinary('pipewire');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasXinput = hasBinary('xinput');
|
||||||
|
const hasXprop = hasBinary('xprop');
|
||||||
|
|
||||||
|
// Readable /dev/input event nodes gate the evdev click fallback.
|
||||||
|
let readableInputDevices = 0;
|
||||||
|
try {
|
||||||
|
for (const name of readdirSync('/dev/input')) {
|
||||||
|
if (!/^event\d+$/.test(name)) continue;
|
||||||
|
try { fs.accessSync(`/dev/input/${name}`, fs.constants.R_OK); readableInputDevices += 1; } catch { /* not readable */ }
|
||||||
|
}
|
||||||
|
} catch { /* /dev/input not present */ }
|
||||||
|
|
||||||
|
// Determine the click-capture profile for this session.
|
||||||
|
let clickCapture;
|
||||||
|
if (!isWayland && hasXinput) clickCapture = 'x11-xinput';
|
||||||
|
else if (readableInputDevices > 0) clickCapture = isWayland ? 'evdev-wayland' : 'evdev-x11';
|
||||||
|
else clickCapture = 'hotkey-or-interval-only';
|
||||||
|
|
||||||
|
const messages = [];
|
||||||
|
if (isWayland && !hasPipeWire) {
|
||||||
|
messages.push('Wayland screen capture needs PipeWire and the XDG Desktop Portal. Install pipewire and xdg-desktop-portal.');
|
||||||
|
}
|
||||||
|
if (isWayland && !hasPortalBus) {
|
||||||
|
messages.push('No D-Bus session bus detected; the screen-share portal cannot be reached.');
|
||||||
|
}
|
||||||
|
if (!isWayland && !hasXinput) {
|
||||||
|
messages.push('xinput not found: per-click capture with a marker is unavailable on X11 without it.');
|
||||||
|
}
|
||||||
|
if (clickCapture === 'hotkey-or-interval-only') {
|
||||||
|
messages.push('No global click source available. Recording falls back to a hotkey or interval trigger.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
os: 'linux',
|
||||||
|
sessionType,
|
||||||
|
isWayland,
|
||||||
|
hasPortalBus,
|
||||||
|
hasPipeWire,
|
||||||
|
hasXinput,
|
||||||
|
hasXprop,
|
||||||
|
readableInputDevices,
|
||||||
|
clickCapture,
|
||||||
|
// Portal capture is the safe Wayland baseline; X11 can grab directly.
|
||||||
|
screenCapture: isWayland ? 'wayland-portal' : 'x11-direct',
|
||||||
|
messages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { detectLinuxCapabilities, detectSessionType };
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { execFileSync } = require('node:child_process');
|
||||||
|
|
||||||
|
function hasBinary(name) {
|
||||||
|
try {
|
||||||
|
execFileSync('which', [name], { stdio: 'pipe' });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linux (X11) WindowContextProvider using xprop on the active window. On
|
||||||
|
* Wayland xprop only sees XWayland clients, so context is best-effort; the
|
||||||
|
* portal-based capture path does not depend on it. Extracted verbatim from
|
||||||
|
* text-intel.js. Never throws.
|
||||||
|
*/
|
||||||
|
function createLinuxWindowContextProvider() {
|
||||||
|
return {
|
||||||
|
async collect() {
|
||||||
|
try {
|
||||||
|
if (!hasBinary('xprop')) return { appName: '', windowTitle: '' };
|
||||||
|
const active = execFileSync('xprop', ['-root', '_NET_ACTIVE_WINDOW'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
timeout: 1200,
|
||||||
|
});
|
||||||
|
const activeMatch = active.match(/window id # (0x[0-9a-fA-F]+)/);
|
||||||
|
if (!activeMatch) return { appName: '', windowTitle: '' };
|
||||||
|
const winId = activeMatch[1];
|
||||||
|
const details = execFileSync('xprop', ['-id', winId, '_NET_WM_NAME', 'WM_NAME', 'WM_CLASS'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
timeout: 1200,
|
||||||
|
});
|
||||||
|
const titleMatch = details.match(/(?:_NET_WM_NAME\(UTF8_STRING\)|WM_NAME\(STRING\)|WM_NAME\(UTF8_STRING\)) = "([^"]*)"/);
|
||||||
|
const classMatch = details.match(/WM_CLASS\(STRING\) = "([^"]*)"(?:, "([^"]*)")?/);
|
||||||
|
return {
|
||||||
|
appName: classMatch ? (classMatch[2] || classMatch[1] || '') : '',
|
||||||
|
windowTitle: titleMatch ? titleMatch[1] : '',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { appName: '', windowTitle: '' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createLinuxWindowContextProvider, hasBinary };
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { execFile } = require('node:child_process');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windows WindowContextProvider. Reads the foreground window (Win32) and, when
|
||||||
|
* a click point is given, the UI Automation element under it. Best-effort:
|
||||||
|
* resolves {} on any failure. Extracted verbatim from text-intel.js so the
|
||||||
|
* shared code carries no `process.platform` branch.
|
||||||
|
*/
|
||||||
|
function createWindowsWindowContextProvider() {
|
||||||
|
return {
|
||||||
|
async collect(osPoint = null) {
|
||||||
|
const hasPoint = osPoint && Number.isFinite(osPoint.x) && Number.isFinite(osPoint.y);
|
||||||
|
const clickX = hasPoint ? Number(osPoint.x) : 0;
|
||||||
|
const clickY = hasPoint ? Number(osPoint.y) : 0;
|
||||||
|
const script = `
|
||||||
|
$clickX = ${clickX};
|
||||||
|
$clickY = ${clickY};
|
||||||
|
$elementLabel = '';
|
||||||
|
$elementRole = '';
|
||||||
|
$elementClass = '';
|
||||||
|
$elementProcessId = 0;
|
||||||
|
$elementValue = '';
|
||||||
|
if (${hasPoint ? '$true' : '$false'}) {
|
||||||
|
try {
|
||||||
|
Add-Type -AssemblyName UIAutomationClient,UIAutomationTypes,WindowsBase | Out-Null
|
||||||
|
$point = New-Object System.Windows.Point($clickX, $clickY);
|
||||||
|
$element = [System.Windows.Automation.AutomationElement]::FromPoint($point);
|
||||||
|
if ($element) {
|
||||||
|
$current = $element.Current;
|
||||||
|
$elementLabel = $current.Name;
|
||||||
|
$elementRole = $current.LocalizedControlType;
|
||||||
|
$elementClass = $current.ClassName;
|
||||||
|
$elementProcessId = $current.ProcessId;
|
||||||
|
try {
|
||||||
|
$valPattern = [System.Windows.Automation.ValuePattern]::Pattern;
|
||||||
|
if ($element.GetSupportedPatterns() -contains $valPattern) {
|
||||||
|
$elementValue = $element.GetCurrentPattern($valPattern).Current.Value;
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
Add-Type @"
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
public static class Win32 {
|
||||||
|
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
|
||||||
|
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
||||||
|
}
|
||||||
|
"@;
|
||||||
|
$hWnd = [Win32]::GetForegroundWindow();
|
||||||
|
$sb = New-Object System.Text.StringBuilder 512;
|
||||||
|
[void][Win32]::GetWindowText($hWnd, $sb, $sb.Capacity);
|
||||||
|
$pid = 0;
|
||||||
|
[void][Win32]::GetWindowThreadProcessId($hWnd, [ref]$pid);
|
||||||
|
$proc = Get-Process -Id $pid -ErrorAction SilentlyContinue | Select-Object -First 1;
|
||||||
|
$out = [ordered]@{
|
||||||
|
appName = if ($proc) { $proc.ProcessName } else { '' };
|
||||||
|
windowTitle = $sb.ToString();
|
||||||
|
elementLabel = $elementLabel;
|
||||||
|
elementRole = $elementRole;
|
||||||
|
elementClass = $elementClass;
|
||||||
|
elementValue = $elementValue;
|
||||||
|
elementProcessId = $elementProcessId;
|
||||||
|
pid = $pid;
|
||||||
|
};
|
||||||
|
$out | ConvertTo-Json -Compress;
|
||||||
|
`;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 4000,
|
||||||
|
windowsHide: true,
|
||||||
|
}, (err, stdout) => {
|
||||||
|
if (err) { resolve({}); return; }
|
||||||
|
try { resolve(JSON.parse(stdout.trim() || '{}')); } catch { resolve({}); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createWindowsWindowContextProvider };
|
||||||
@@ -77,6 +77,9 @@ const api = {
|
|||||||
create: invoke('snapshots:create'),
|
create: invoke('snapshots:create'),
|
||||||
restore: invoke('snapshots:restore'),
|
restore: invoke('snapshots:restore'),
|
||||||
},
|
},
|
||||||
|
recovery: {
|
||||||
|
status: invoke('recovery:status'),
|
||||||
|
},
|
||||||
templates: {
|
templates: {
|
||||||
list: invoke('templates:list'),
|
list: invoke('templates:list'),
|
||||||
load: invoke('templates:load'),
|
load: invoke('templates:load'),
|
||||||
@@ -104,6 +107,7 @@ const api = {
|
|||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
info: invoke('app:info'),
|
info: invoke('app:info'),
|
||||||
|
platformCapabilities: invoke('platform:capabilities'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const { execFileSync, execFile } = require('node:child_process');
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
DEFAULT_CAPTURE_TITLES,
|
DEFAULT_CAPTURE_TITLES,
|
||||||
@@ -23,15 +22,6 @@ const OCR_CROP = {
|
|||||||
height: 220,
|
height: 220,
|
||||||
};
|
};
|
||||||
|
|
||||||
function hasBinary(name) {
|
|
||||||
try {
|
|
||||||
execFileSync('which', [name], { stdio: 'pipe' });
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clamp(v, min, max) {
|
function clamp(v, min, max) {
|
||||||
return Math.min(max, Math.max(min, v));
|
return Math.min(max, Math.max(min, v));
|
||||||
}
|
}
|
||||||
@@ -63,6 +53,7 @@ class TextIntelService {
|
|||||||
dataDir,
|
dataDir,
|
||||||
fetchImpl = global.fetch,
|
fetchImpl = global.fetch,
|
||||||
screenApi = null,
|
screenApi = null,
|
||||||
|
windowContextProvider = null,
|
||||||
}) {
|
}) {
|
||||||
this.store = store;
|
this.store = store;
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
@@ -70,6 +61,10 @@ class TextIntelService {
|
|||||||
this.dataDir = dataDir;
|
this.dataDir = dataDir;
|
||||||
this.fetch = fetchImpl;
|
this.fetch = fetchImpl;
|
||||||
this.screen = screenApi;
|
this.screen = screenApi;
|
||||||
|
// OS-specific foreground-window/element detection is a platform adapter.
|
||||||
|
// This code no longer branches on process.platform; the factory selects it.
|
||||||
|
this.windowContext = windowContextProvider
|
||||||
|
|| require('./platform').createWindowContextProvider();
|
||||||
this.worker = null;
|
this.worker = null;
|
||||||
this.workerPromise = null;
|
this.workerPromise = null;
|
||||||
this.workerQueue = Promise.resolve();
|
this.workerQueue = Promise.resolve();
|
||||||
@@ -271,135 +266,13 @@ class TextIntelService {
|
|||||||
|
|
||||||
async collectForegroundWindowContext(osPoint = null) {
|
async collectForegroundWindowContext(osPoint = null) {
|
||||||
try {
|
try {
|
||||||
if (process.platform === 'win32') return this.collectWindowsWindowContext(osPoint);
|
return await this.windowContext.collect(osPoint);
|
||||||
if (process.platform === 'darwin') return this.collectMacWindowContext();
|
|
||||||
if (process.platform === 'linux') return this.collectLinuxWindowContext();
|
|
||||||
} catch {
|
} catch {
|
||||||
// best effort only
|
// best effort only
|
||||||
}
|
|
||||||
return { appName: '', windowTitle: '' };
|
return { appName: '', windowTitle: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
async collectWindowsWindowContext(osPoint = null) {
|
|
||||||
const hasPoint = osPoint && Number.isFinite(osPoint.x) && Number.isFinite(osPoint.y);
|
|
||||||
const clickX = hasPoint ? Number(osPoint.x) : 0;
|
|
||||||
const clickY = hasPoint ? Number(osPoint.y) : 0;
|
|
||||||
const script = `
|
|
||||||
$clickX = ${clickX};
|
|
||||||
$clickY = ${clickY};
|
|
||||||
$elementLabel = '';
|
|
||||||
$elementRole = '';
|
|
||||||
$elementClass = '';
|
|
||||||
$elementProcessId = 0;
|
|
||||||
$elementValue = '';
|
|
||||||
if (${hasPoint ? '$true' : '$false'}) {
|
|
||||||
try {
|
|
||||||
Add-Type -AssemblyName UIAutomationClient,UIAutomationTypes,WindowsBase | Out-Null
|
|
||||||
$point = New-Object System.Windows.Point($clickX, $clickY);
|
|
||||||
$element = [System.Windows.Automation.AutomationElement]::FromPoint($point);
|
|
||||||
if ($element) {
|
|
||||||
$current = $element.Current;
|
|
||||||
$elementLabel = $current.Name;
|
|
||||||
$elementRole = $current.LocalizedControlType;
|
|
||||||
$elementClass = $current.ClassName;
|
|
||||||
$elementProcessId = $current.ProcessId;
|
|
||||||
try {
|
|
||||||
$valPattern = [System.Windows.Automation.ValuePattern]::Pattern;
|
|
||||||
if ($element.GetSupportedPatterns() -contains $valPattern) {
|
|
||||||
$elementValue = $element.GetCurrentPattern($valPattern).Current.Value;
|
|
||||||
}
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
Add-Type @"
|
|
||||||
using System;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Text;
|
|
||||||
public static class Win32 {
|
|
||||||
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
|
||||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
|
||||||
public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
|
|
||||||
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
|
||||||
}
|
|
||||||
"@;
|
|
||||||
$hWnd = [Win32]::GetForegroundWindow();
|
|
||||||
$sb = New-Object System.Text.StringBuilder 512;
|
|
||||||
[void][Win32]::GetWindowText($hWnd, $sb, $sb.Capacity);
|
|
||||||
$pid = 0;
|
|
||||||
[void][Win32]::GetWindowThreadProcessId($hWnd, [ref]$pid);
|
|
||||||
$proc = Get-Process -Id $pid -ErrorAction SilentlyContinue | Select-Object -First 1;
|
|
||||||
$out = [ordered]@{
|
|
||||||
appName = if ($proc) { $proc.ProcessName } else { '' };
|
|
||||||
windowTitle = $sb.ToString();
|
|
||||||
elementLabel = $elementLabel;
|
|
||||||
elementRole = $elementRole;
|
|
||||||
elementClass = $elementClass;
|
|
||||||
elementValue = $elementValue;
|
|
||||||
elementProcessId = $elementProcessId;
|
|
||||||
pid = $pid;
|
|
||||||
};
|
|
||||||
$out | ConvertTo-Json -Compress;
|
|
||||||
`;
|
|
||||||
return new Promise(resolve => {
|
|
||||||
execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
|
|
||||||
encoding: 'utf8',
|
|
||||||
timeout: 4000,
|
|
||||||
windowsHide: true,
|
|
||||||
}, (err, stdout) => {
|
|
||||||
if (err) { resolve({}); return; }
|
|
||||||
try { resolve(JSON.parse(stdout.trim() || '{}')); }
|
|
||||||
catch { resolve({}); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
collectMacWindowContext() {
|
|
||||||
const script = `
|
|
||||||
set appName to ""
|
|
||||||
set windowTitle to ""
|
|
||||||
tell application "System Events"
|
|
||||||
try
|
|
||||||
set frontApp to first application process whose frontmost is true
|
|
||||||
set appName to name of frontApp
|
|
||||||
try
|
|
||||||
set windowTitle to name of front window of frontApp
|
|
||||||
end try
|
|
||||||
end try
|
|
||||||
end tell
|
|
||||||
return appName & linefeed & windowTitle
|
|
||||||
`;
|
|
||||||
const result = execFileSync('osascript', ['-e', script], {
|
|
||||||
encoding: 'utf8',
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
timeout: 1200,
|
|
||||||
}).trimEnd();
|
|
||||||
const [appName = '', windowTitle = ''] = result.split(/\r?\n/);
|
|
||||||
return { appName, windowTitle };
|
|
||||||
}
|
|
||||||
|
|
||||||
collectLinuxWindowContext() {
|
|
||||||
if (!hasBinary('xprop')) return { appName: '', windowTitle: '' };
|
|
||||||
const active = execFileSync('xprop', ['-root', '_NET_ACTIVE_WINDOW'], {
|
|
||||||
encoding: 'utf8',
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
timeout: 1200,
|
|
||||||
});
|
|
||||||
const activeMatch = active.match(/window id # (0x[0-9a-fA-F]+)/);
|
|
||||||
if (!activeMatch) return { appName: '', windowTitle: '' };
|
|
||||||
const winId = activeMatch[1];
|
|
||||||
const details = execFileSync('xprop', ['-id', winId, '_NET_WM_NAME', 'WM_NAME', 'WM_CLASS'], {
|
|
||||||
encoding: 'utf8',
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
timeout: 1200,
|
|
||||||
});
|
|
||||||
const titleMatch = details.match(/(?:_NET_WM_NAME\(UTF8_STRING\)|WM_NAME\(STRING\)|WM_NAME\(UTF8_STRING\)) = "([^"]*)"/);
|
|
||||||
const classMatch = details.match(/WM_CLASS\(STRING\) = "([^"]*)"(?:, "([^"]*)")?/);
|
|
||||||
return {
|
|
||||||
appName: classMatch ? (classMatch[2] || classMatch[1] || '') : '',
|
|
||||||
windowTitle: titleMatch ? titleMatch[1] : '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async buildCaptureTitle({ mode, frame, clickPos, clickMeta = null }) {
|
async buildCaptureTitle({ mode, frame, clickPos, clickMeta = null }) {
|
||||||
const ctx = await this.buildCaptureContext({ mode, frame, clickPos, clickMeta });
|
const ctx = await this.buildCaptureContext({ mode, frame, clickPos, clickMeta });
|
||||||
|
|||||||
@@ -124,19 +124,41 @@ function importGuideArchive(store, file, { mode = 'copy' } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function finalizeImport(store, newGuide, idMap, stepJsons, stepFiles) {
|
function finalizeImport(store, newGuide, idMap, stepJsons, stepFiles) {
|
||||||
|
// Transactional import: validate the guide and EVERY step first, then write
|
||||||
|
// the whole guide into a temporary staging directory, and only publish it
|
||||||
|
// with a single atomic rename. Previously guide.json was written before the
|
||||||
|
// steps validated, so a bad step left a partial guide in the library.
|
||||||
validateGuide(newGuide);
|
validateGuide(newGuide);
|
||||||
writeJsonSync(path.join(store.guideDir(newGuide.guideId), 'guide.json'), newGuide);
|
const normalizedSteps = [];
|
||||||
|
|
||||||
for (const [stepId, { raw }] of stepJsons) {
|
for (const [stepId, { raw }] of stepJsons) {
|
||||||
const step = normalizeStep({ ...raw, stepId });
|
const step = normalizeStep({ ...raw, stepId });
|
||||||
step.parentStepId = raw.parentStepId ? idMap.get(raw.parentStepId) || null : null;
|
step.parentStepId = raw.parentStepId ? idMap.get(raw.parentStepId) || null : null;
|
||||||
validateStep(step);
|
validateStep(step); // throws before anything is written on a bad step
|
||||||
const dir = store.stepDir(newGuide.guideId, stepId);
|
normalizedSteps.push([stepId, step]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalDir = store.guideDir(newGuide.guideId);
|
||||||
|
if (fs.existsSync(finalDir)) throw new Error(`guide already exists: ${newGuide.guideId}`);
|
||||||
|
const stagingDir = `${finalDir}.importing-${Date.now()}`;
|
||||||
|
fs.rmSync(stagingDir, { recursive: true, force: true });
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(stagingDir, { recursive: true });
|
||||||
|
writeJsonSync(path.join(stagingDir, 'guide.json'), newGuide);
|
||||||
|
for (const [stepId, step] of normalizedSteps) {
|
||||||
|
const dir = path.join(stagingDir, 'steps', stepId);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
writeJsonSync(path.join(dir, 'step.json'), step);
|
writeJsonSync(path.join(dir, 'step.json'), step);
|
||||||
for (const { name, data } of stepFiles.get(stepId) || []) {
|
for (const { name, data } of stepFiles.get(stepId) || []) {
|
||||||
atomicWriteFileSync(path.join(dir, name), data);
|
atomicWriteFileSync(path.join(dir, name), data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Publish atomically. If the final dir appeared meanwhile, fail cleanly.
|
||||||
|
if (fs.existsSync(finalDir)) throw new Error(`guide already exists: ${newGuide.guideId}`);
|
||||||
|
fs.renameSync(stagingDir, finalDir);
|
||||||
|
} catch (err) {
|
||||||
|
fs.rmSync(stagingDir, { recursive: true, force: true });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
return store.getGuide(newGuide.guideId);
|
return store.getGuide(newGuide.guideId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +182,9 @@ function saveLinkedGuide(store, guideId, { force = false } = {}) {
|
|||||||
store.saveGuide(guide, { touch: false });
|
store.saveGuide(guide, { touch: false });
|
||||||
return { saved: true, path: target };
|
return { saved: true, path: target };
|
||||||
} finally {
|
} finally {
|
||||||
releaseLock(target);
|
// Release by our acquisition token so we never remove a lock a concurrent
|
||||||
|
// force-steal replaced with theirs.
|
||||||
|
releaseLock(target, { lock: result.lock });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,18 +20,39 @@ function lockPathFor(archivePath) {
|
|||||||
return path.join(dir, `${stem}.lock-sfgz`);
|
return path.join(dir, `${stem}.lock-sfgz`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentHolder() {
|
function currentProcess() {
|
||||||
return { host: os.hostname(), user: os.userInfo().username, pid: process.pid };
|
return { host: os.hostname(), user: os.userInfo().username, pid: process.pid };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentHolder() {
|
||||||
|
return {
|
||||||
|
...currentProcess(),
|
||||||
|
// Random per-acquisition token so two processes that happen to share
|
||||||
|
// host+user+pid space (containers, pid reuse) still compare distinctly,
|
||||||
|
// and so a steal can be detected by the previous holder.
|
||||||
|
token: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function readLock(archivePath) {
|
function readLock(archivePath) {
|
||||||
return readJsonIfExists(lockPathFor(archivePath), null);
|
return readJsonIfExists(lockPathFor(archivePath), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sameHolder(a, b) {
|
// Process identity (host+user+pid). Used to decide whether an existing lock is
|
||||||
|
// held by *this process* (safe to re-acquire) or someone else (a conflict).
|
||||||
|
function sameProcess(a, b) {
|
||||||
return a && b && a.host === b.host && a.user === b.user && a.pid === b.pid;
|
return a && b && a.host === b.host && a.user === b.user && a.pid === b.pid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exact-acquisition identity via the per-acquisition token. Used by release so
|
||||||
|
// a caller only removes the lock it actually took (never one a force-steal
|
||||||
|
// replaced with its own).
|
||||||
|
function sameAcquisition(existing, owner) {
|
||||||
|
if (!existing || !owner) return false;
|
||||||
|
if (owner.token) return existing.token === owner.token;
|
||||||
|
return sameProcess(existing, owner);
|
||||||
|
}
|
||||||
|
|
||||||
function isStale(lock, now = Date.now()) {
|
function isStale(lock, now = Date.now()) {
|
||||||
const t = Date.parse(lock && lock.acquiredAt);
|
const t = Date.parse(lock && lock.acquiredAt);
|
||||||
return !Number.isFinite(t) || now - t > STALE_AFTER_MS;
|
return !Number.isFinite(t) || now - t > STALE_AFTER_MS;
|
||||||
@@ -44,24 +65,49 @@ function isStale(lock, now = Date.now()) {
|
|||||||
*/
|
*/
|
||||||
function acquireLock(archivePath, { force = false } = {}) {
|
function acquireLock(archivePath, { force = false } = {}) {
|
||||||
const file = lockPathFor(archivePath);
|
const file = lockPathFor(archivePath);
|
||||||
const existing = readLock(archivePath);
|
|
||||||
const me = currentHolder();
|
const me = currentHolder();
|
||||||
if (existing && !sameHolder(existing, me) && !isStale(existing) && !force) {
|
const lock = { ...me, acquiredAt: nowIso() };
|
||||||
|
const payload = JSON.stringify(lock, null, 2);
|
||||||
|
|
||||||
|
// Fast path: exclusive create. Only one writer wins the O_CREAT|O_EXCL race,
|
||||||
|
// so two processes can't both believe they hold the lock (the old
|
||||||
|
// read-then-write left exactly that window open).
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(file, payload, { flag: 'wx' });
|
||||||
|
return { acquired: true, lock };
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'EEXIST') throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A lock already exists. We may take it over only if this process already
|
||||||
|
// holds it, it is stale, or the caller is force-stealing (user confirmed).
|
||||||
|
const existing = readLock(archivePath);
|
||||||
|
if (existing && !sameProcess(existing, me) && !isStale(existing) && !force) {
|
||||||
return { acquired: false, conflict: existing };
|
return { acquired: false, conflict: existing };
|
||||||
}
|
}
|
||||||
const lock = { ...me, acquiredAt: nowIso() };
|
// Overwrite to claim ownership (our token now identifies the lock).
|
||||||
fs.writeFileSync(file, JSON.stringify(lock, null, 2));
|
fs.writeFileSync(file, payload);
|
||||||
return { acquired: true, lock };
|
return { acquired: true, lock };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Release only if we are the holder (or force). */
|
/**
|
||||||
function releaseLock(archivePath, { force = false } = {}) {
|
* Release only if we are the holder (or force). Pass the `lock` (or its
|
||||||
|
* `token`) returned by acquireLock so ownership is matched by token — the
|
||||||
|
* per-acquisition token means a fresh currentHolder() would not match.
|
||||||
|
*/
|
||||||
|
function releaseLock(archivePath, { force = false, lock = null, token = null } = {}) {
|
||||||
const file = lockPathFor(archivePath);
|
const file = lockPathFor(archivePath);
|
||||||
const existing = readLock(archivePath);
|
const existing = readLock(archivePath);
|
||||||
if (!existing) return true;
|
if (!existing) return true;
|
||||||
if (!force && !sameHolder(existing, currentHolder())) return false;
|
// With no explicit lock/token, fall back to process identity (the legacy
|
||||||
|
// "release my own lock" path) rather than a fresh token that can't match.
|
||||||
|
const owner = lock || (token ? { token } : currentProcess());
|
||||||
|
if (!force && !sameAcquisition(existing, owner)) return false;
|
||||||
fs.rmSync(file, { force: true });
|
fs.rmSync(file, { force: true });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { lockPathFor, readLock, acquireLock, releaseLock, isStale, STALE_AFTER_MS };
|
module.exports = {
|
||||||
|
lockPathFor, readLock, acquireLock, releaseLock, isStale, STALE_AFTER_MS,
|
||||||
|
sameProcess, sameAcquisition,
|
||||||
|
};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const { blockText } = require('./blocks');
|
|||||||
* specific step in the editor.
|
* specific step in the editor.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const INDEX_VERSION = 1;
|
const INDEX_VERSION = 2;
|
||||||
|
|
||||||
function tokenize(text) {
|
function tokenize(text) {
|
||||||
if (!text) return [];
|
if (!text) return [];
|
||||||
@@ -27,20 +27,82 @@ function tokenize(text) {
|
|||||||
class SearchIndex {
|
class SearchIndex {
|
||||||
constructor(indexDir) {
|
constructor(indexDir) {
|
||||||
this.file = path.join(indexDir, 'search-index.json');
|
this.file = path.join(indexDir, 'search-index.json');
|
||||||
|
// Per-guide source fingerprints so a startup reconcile can tell which
|
||||||
|
// guides changed while the app was closed, without re-reading every step.
|
||||||
|
this.fingerprints = {}; // guideId -> fingerprint string
|
||||||
|
// Recovery status surfaced to the UI: 'ok' | 'reset' (missing/corrupt/
|
||||||
|
// version mismatch) | 'reconciled' (rebuilt from the store at startup).
|
||||||
|
this.status = 'ok';
|
||||||
|
const fileExisted = require('node:fs').existsSync(this.file);
|
||||||
const stored = readJsonIfExists(this.file, null);
|
const stored = readJsonIfExists(this.file, null);
|
||||||
if (stored && stored.version === INDEX_VERSION) {
|
if (stored && stored.version === INDEX_VERSION && stored.docs && typeof stored.docs === 'object') {
|
||||||
this.docs = stored.docs;
|
this.docs = stored.docs;
|
||||||
|
this.fingerprints = stored.fingerprints || {};
|
||||||
} else {
|
} else {
|
||||||
|
// Missing, corrupt, or an older index version: start empty and mark it,
|
||||||
|
// so reconcile() rebuilds from the store instead of silently staying
|
||||||
|
// blank (which made search "work" but return nothing). A file that
|
||||||
|
// existed but could not be used is a 'reset' (recovery-worthy); a
|
||||||
|
// genuinely absent index on first run is just 'ok'.
|
||||||
this.docs = {}; // docKey -> { guideId, stepId, title, text, updatedAt }
|
this.docs = {}; // docKey -> { guideId, stepId, title, text, updatedAt }
|
||||||
|
this.status = fileExisted ? 'reset' : 'ok';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
persist() {
|
persist() {
|
||||||
writeJsonSync(this.file, { version: INDEX_VERSION, docs: this.docs });
|
writeJsonSync(this.file, {
|
||||||
|
version: INDEX_VERSION,
|
||||||
|
docs: this.docs,
|
||||||
|
fingerprints: this.fingerprints,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static fingerprint(guide) {
|
||||||
|
return `${guide.updatedAt || ''}:${Number.isInteger(guide.revision) ? guide.revision : 0}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile the index against the store at startup: reindex guides that are
|
||||||
|
* new or changed (by fingerprint), and drop index entries for guides that no
|
||||||
|
* longer exist. Returns a summary with a recovery status for the UI.
|
||||||
|
*/
|
||||||
|
reconcile(store) {
|
||||||
|
const guides = store.listGuides();
|
||||||
|
const liveIds = new Set(guides.map((g) => g.guideId));
|
||||||
|
let reindexed = 0;
|
||||||
|
let removed = 0;
|
||||||
|
|
||||||
|
// Drop docs/fingerprints for guides that are gone.
|
||||||
|
for (const key of Object.keys(this.fingerprints)) {
|
||||||
|
if (!liveIds.has(key)) {
|
||||||
|
this.removeGuide(key, { persist: false });
|
||||||
|
delete this.fingerprints[key];
|
||||||
|
removed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const guide of guides) {
|
||||||
|
const fp = SearchIndex.fingerprint(guide);
|
||||||
|
const indexed = this.fingerprints[guide.guideId];
|
||||||
|
const hasDoc = Boolean(this.docs[`g:${guide.guideId}`]);
|
||||||
|
if (indexed === fp && hasDoc) continue; // unchanged
|
||||||
|
try {
|
||||||
|
this.indexGuide(guide, store.listSteps(guide.guideId), { persist: false });
|
||||||
|
reindexed += 1;
|
||||||
|
} catch {
|
||||||
|
// A single unreadable guide must not abort the whole reconcile.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.persist();
|
||||||
|
if (this.status === 'reset' || reindexed > 0 || removed > 0) {
|
||||||
|
this.status = this.status === 'reset' ? 'reset' : 'reconciled';
|
||||||
|
}
|
||||||
|
return { status: this.status, reindexed, removed, total: guides.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** (Re)index one guide and all of its steps. */
|
/** (Re)index one guide and all of its steps. */
|
||||||
indexGuide(guide, stepsMap) {
|
indexGuide(guide, stepsMap, { persist = true } = {}) {
|
||||||
this.removeGuide(guide.guideId, { persist: false });
|
this.removeGuide(guide.guideId, { persist: false });
|
||||||
|
|
||||||
const placeholderText = Object.entries(guide.placeholders || {})
|
const placeholderText = Object.entries(guide.placeholders || {})
|
||||||
@@ -69,13 +131,15 @@ class SearchIndex {
|
|||||||
updatedAt: guide.updatedAt,
|
updatedAt: guide.updatedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
this.persist();
|
this.fingerprints[guide.guideId] = SearchIndex.fingerprint(guide);
|
||||||
|
if (persist) this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
removeGuide(guideId, { persist = true } = {}) {
|
removeGuide(guideId, { persist = true } = {}) {
|
||||||
for (const key of Object.keys(this.docs)) {
|
for (const key of Object.keys(this.docs)) {
|
||||||
if (this.docs[key].guideId === guideId) delete this.docs[key];
|
if (this.docs[key].guideId === guideId) delete this.docs[key];
|
||||||
}
|
}
|
||||||
|
delete this.fingerprints[guideId];
|
||||||
if (persist) this.persist();
|
if (persist) this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const { zipDirSync, extractZipSync } = require('./zip');
|
const { zipDirSync, extractZipSync } = require('./zip');
|
||||||
const { atomicWriteFileSync } = require('./util');
|
const { atomicWriteFileSync, readJsonSync } = require('./util');
|
||||||
|
const { validateGuide } = require('./schema');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snapshot backups: a zip of the guide directory (excluding history/) stored
|
* Snapshot backups: a zip of the guide directory (excluding history/) stored
|
||||||
@@ -16,7 +17,11 @@ function snapshotsDir(store, guideId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function snapshotName(label) {
|
function snapshotName(label) {
|
||||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace(/-\d{3}Z$/, 'Z');
|
// Keep milliseconds: stripping them made two snapshots taken within the same
|
||||||
|
// second collide on filename (the second silently overwrote the first, so
|
||||||
|
// rapid automatic backups produced only one file). ms keeps names unique and
|
||||||
|
// still chronologically sortable.
|
||||||
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
return label ? `${stamp}-${label.replace(/[^A-Za-z0-9_-]+/g, '_')}.zip` : `${stamp}.zip`;
|
return label ? `${stamp}-${label.replace(/[^A-Za-z0-9_-]+/g, '_')}.zip` : `${stamp}.zip`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,20 +55,103 @@ function pruneSnapshots(store, guideId, keepLast) {
|
|||||||
/**
|
/**
|
||||||
* Restore a snapshot: replaces the guide's current content (guide.json and
|
* Restore a snapshot: replaces the guide's current content (guide.json and
|
||||||
* steps/) with the snapshot's, keeping the history/ directory intact.
|
* steps/) with the snapshot's, keeping the history/ directory intact.
|
||||||
|
*
|
||||||
|
* The extraction is staged and validated BEFORE any live content is touched:
|
||||||
|
* a corrupt or truncated snapshot can no longer destroy the current guide.
|
||||||
|
* The swap itself moves the old content aside, moves the new content in, then
|
||||||
|
* deletes the old — so a failure mid-swap leaves a recoverable state.
|
||||||
*/
|
*/
|
||||||
function restoreSnapshot(store, guideId, name) {
|
function restoreSnapshot(store, guideId, name) {
|
||||||
const file = path.join(snapshotsDir(store, guideId), path.basename(name));
|
const file = path.join(snapshotsDir(store, guideId), path.basename(name));
|
||||||
if (!fs.existsSync(file)) throw new Error(`snapshot not found: ${name}`);
|
if (!fs.existsSync(file)) throw new Error(`snapshot not found: ${name}`);
|
||||||
const buf = fs.readFileSync(file);
|
const buf = fs.readFileSync(file);
|
||||||
const guideDir = store.guideDir(guideId);
|
const guideDir = store.guideDir(guideId);
|
||||||
// Safety: snapshot the pre-restore state too, so a restore is undoable.
|
|
||||||
createSnapshot(store, guideId, { label: 'pre-restore' });
|
// 1. Extract + validate into a temp staging dir. Nothing live is touched yet.
|
||||||
for (const entry of fs.readdirSync(guideDir)) {
|
const staging = `${guideDir}.restoring-${Date.now()}`;
|
||||||
if (entry === 'history') continue;
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
fs.rmSync(path.join(guideDir, entry), { recursive: true, force: true });
|
try {
|
||||||
|
fs.mkdirSync(staging, { recursive: true });
|
||||||
|
extractZipSync(buf, staging);
|
||||||
|
const guideJson = path.join(staging, 'guide.json');
|
||||||
|
if (!fs.existsSync(guideJson)) throw new Error('snapshot is missing guide.json');
|
||||||
|
validateGuide(readJsonSync(guideJson)); // throws on a corrupt snapshot
|
||||||
|
} catch (err) {
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
throw new Error(`snapshot restore aborted (snapshot invalid): ${err.message}`);
|
||||||
}
|
}
|
||||||
extractZipSync(buf, guideDir);
|
|
||||||
|
// 2. Snapshot the pre-restore state so the restore is itself undoable.
|
||||||
|
createSnapshot(store, guideId, { label: 'pre-restore' });
|
||||||
|
|
||||||
|
// 3. Swap in the validated content, preserving history/. Move live content
|
||||||
|
// aside first so we can roll back if a step fails.
|
||||||
|
const backup = `${guideDir}.prev-${Date.now()}`;
|
||||||
|
const liveEntries = fs.readdirSync(guideDir).filter((e) => e !== 'history');
|
||||||
|
fs.mkdirSync(backup, { recursive: true });
|
||||||
|
try {
|
||||||
|
for (const entry of liveEntries) {
|
||||||
|
fs.renameSync(path.join(guideDir, entry), path.join(backup, entry));
|
||||||
|
}
|
||||||
|
for (const entry of fs.readdirSync(staging)) {
|
||||||
|
if (entry === 'history') continue;
|
||||||
|
fs.renameSync(path.join(staging, entry), path.join(guideDir, entry));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Roll back: restore whatever we moved aside.
|
||||||
|
for (const entry of fs.readdirSync(backup)) {
|
||||||
|
const dest = path.join(guideDir, entry);
|
||||||
|
fs.rmSync(dest, { recursive: true, force: true });
|
||||||
|
fs.renameSync(path.join(backup, entry), dest);
|
||||||
|
}
|
||||||
|
fs.rmSync(backup, { recursive: true, force: true });
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
fs.rmSync(backup, { recursive: true, force: true });
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
return store.getGuide(guideId);
|
return store.getGuide(guideId);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createSnapshot, listSnapshots, pruneSnapshots, restoreSnapshot, snapshotsDir };
|
/**
|
||||||
|
* Automatic backup policy. Every guide keeps a small save counter in its
|
||||||
|
* history dir; once `everyNSaves` saves accumulate (and backups.automatic is
|
||||||
|
* on) an automatic snapshot is taken and old ones pruned to backups.keepLast.
|
||||||
|
* Returns the snapshot name when one was taken, else null. Never throws — a
|
||||||
|
* backup failure must not break the save that triggered it.
|
||||||
|
*/
|
||||||
|
function autoSnapshotIfDue(store, guideId, settings) {
|
||||||
|
try {
|
||||||
|
const backups = (settings && settings.get && settings.get('backups')) || {};
|
||||||
|
if (backups.automatic === false) return null;
|
||||||
|
const everyN = Number.isInteger(backups.everyNSaves) && backups.everyNSaves > 0 ? backups.everyNSaves : 25;
|
||||||
|
const keepLast = Number.isInteger(backups.keepLast) && backups.keepLast > 0 ? backups.keepLast : 10;
|
||||||
|
|
||||||
|
const dir = path.join(store.guideDir(guideId), 'history');
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const counterFile = path.join(dir, 'autosave-counter.json');
|
||||||
|
let count = 0;
|
||||||
|
try {
|
||||||
|
count = JSON.parse(fs.readFileSync(counterFile, 'utf8')).count || 0;
|
||||||
|
} catch { count = 0; }
|
||||||
|
count += 1;
|
||||||
|
|
||||||
|
if (count >= everyN) {
|
||||||
|
createSnapshot(store, guideId, { label: 'auto', keepLast });
|
||||||
|
count = 0;
|
||||||
|
atomicWriteFileSync(counterFile, JSON.stringify({ count }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
atomicWriteFileSync(counterFile, JSON.stringify({ count }));
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
// Best effort: report, never break the caller's save.
|
||||||
|
console.error(`[stepforge] automatic backup failed for ${guideId}: ${err && err.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createSnapshot, listSnapshots, pruneSnapshots, restoreSnapshot, snapshotsDir,
|
||||||
|
autoSnapshotIfDue,
|
||||||
|
};
|
||||||
|
|||||||
@@ -121,8 +121,22 @@ function zipSync(entries, { date = new Date(2026, 0, 1) } = {}) {
|
|||||||
return Buffer.concat([...localParts, centralBuf, eocd]);
|
return Buffer.concat([...localParts, centralBuf, eocd]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse a zip buffer into [{ name, data }] with CRC verification. */
|
// Resource limits for untrusted archives (share files, snapshots). These cap
|
||||||
function unzipSync(buffer) {
|
// memory and disk work so a ZIP bomb can't exhaust the machine. Callers that
|
||||||
|
// build archives themselves may relax them; imports use the defaults.
|
||||||
|
const DEFAULT_UNZIP_LIMITS = {
|
||||||
|
maxEntries: 50000,
|
||||||
|
maxTotalCompressed: 1024 * 1024 * 1024, // 1 GiB of stored bytes
|
||||||
|
maxTotalUncompressed: 4 * 1024 * 1024 * 1024, // 4 GiB inflated total
|
||||||
|
maxEntryUncompressed: 512 * 1024 * 1024, // 512 MiB per entry
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a zip buffer into [{ name, data }] with CRC verification and hard
|
||||||
|
* resource limits. `limits` overrides DEFAULT_UNZIP_LIMITS.
|
||||||
|
*/
|
||||||
|
function unzipSync(buffer, { limits = {} } = {}) {
|
||||||
|
const lim = { ...DEFAULT_UNZIP_LIMITS, ...limits };
|
||||||
if (!Buffer.isBuffer(buffer) || buffer.length < 22) throw new Error('zip: too small');
|
if (!Buffer.isBuffer(buffer) || buffer.length < 22) throw new Error('zip: too small');
|
||||||
// Find end-of-central-directory record (scan backwards over the comment).
|
// Find end-of-central-directory record (scan backwards over the comment).
|
||||||
let eocd = -1;
|
let eocd = -1;
|
||||||
@@ -132,11 +146,14 @@ function unzipSync(buffer) {
|
|||||||
}
|
}
|
||||||
if (eocd < 0) throw new Error('zip: end record not found');
|
if (eocd < 0) throw new Error('zip: end record not found');
|
||||||
const count = buffer.readUInt16LE(eocd + 10);
|
const count = buffer.readUInt16LE(eocd + 10);
|
||||||
|
if (count > lim.maxEntries) throw new Error(`zip: too many entries (${count} > ${lim.maxEntries})`);
|
||||||
let pos = buffer.readUInt32LE(eocd + 16);
|
let pos = buffer.readUInt32LE(eocd + 16);
|
||||||
|
|
||||||
const entries = [];
|
const entries = [];
|
||||||
|
let totalCompressed = 0;
|
||||||
|
let totalUncompressed = 0;
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
if (buffer.readUInt32LE(pos) !== 0x02014b50) throw new Error('zip: bad central header');
|
if (pos + 46 > buffer.length || buffer.readUInt32LE(pos) !== 0x02014b50) throw new Error('zip: bad central header');
|
||||||
const method = buffer.readUInt16LE(pos + 10);
|
const method = buffer.readUInt16LE(pos + 10);
|
||||||
const crc = buffer.readUInt32LE(pos + 16);
|
const crc = buffer.readUInt32LE(pos + 16);
|
||||||
const compSize = buffer.readUInt32LE(pos + 20);
|
const compSize = buffer.readUInt32LE(pos + 20);
|
||||||
@@ -151,17 +168,33 @@ function unzipSync(buffer) {
|
|||||||
assertSafeEntryName(name);
|
assertSafeEntryName(name);
|
||||||
if (name.endsWith('/')) continue; // directory entry
|
if (name.endsWith('/')) continue; // directory entry
|
||||||
|
|
||||||
|
// Budget checks BEFORE allocating/inflating: the declared sizes are
|
||||||
|
// attacker-controlled, so reject oversize claims up front.
|
||||||
|
if (uncompSize > lim.maxEntryUncompressed) {
|
||||||
|
throw new Error(`zip: entry too large (${uncompSize} > ${lim.maxEntryUncompressed}): ${name}`);
|
||||||
|
}
|
||||||
|
totalCompressed += compSize;
|
||||||
|
totalUncompressed += uncompSize;
|
||||||
|
if (totalCompressed > lim.maxTotalCompressed) throw new Error('zip: total compressed size exceeds limit');
|
||||||
|
if (totalUncompressed > lim.maxTotalUncompressed) throw new Error('zip: total inflated size exceeds limit');
|
||||||
|
|
||||||
if (buffer.readUInt32LE(localOffset) !== 0x04034b50) throw new Error('zip: bad local header');
|
if (buffer.readUInt32LE(localOffset) !== 0x04034b50) throw new Error('zip: bad local header');
|
||||||
const lNameLen = buffer.readUInt16LE(localOffset + 26);
|
const lNameLen = buffer.readUInt16LE(localOffset + 26);
|
||||||
const lExtraLen = buffer.readUInt16LE(localOffset + 28);
|
const lExtraLen = buffer.readUInt16LE(localOffset + 28);
|
||||||
const dataStart = localOffset + 30 + lNameLen + lExtraLen;
|
const dataStart = localOffset + 30 + lNameLen + lExtraLen;
|
||||||
|
if (dataStart + compSize > buffer.length) throw new Error(`zip: entry data out of range: ${name}`);
|
||||||
const raw = buffer.subarray(dataStart, dataStart + compSize);
|
const raw = buffer.subarray(dataStart, dataStart + compSize);
|
||||||
|
|
||||||
let data;
|
let data;
|
||||||
if (method === 0) data = Buffer.from(raw);
|
if (method === 0) data = Buffer.from(raw);
|
||||||
else if (method === 8) data = zlib.inflateRawSync(raw);
|
else if (method === 8) {
|
||||||
else throw new Error(`zip: unsupported method ${method} for ${name}`);
|
// Cap inflation so a small deflate stream can't expand to gigabytes —
|
||||||
|
// even if the declared uncompSize lied, this is the real guard.
|
||||||
|
data = zlib.inflateRawSync(raw, { maxOutputLength: lim.maxEntryUncompressed });
|
||||||
|
} else throw new Error(`zip: unsupported method ${method} for ${name}`);
|
||||||
|
|
||||||
|
// Exact length match (not "at least"): the inflated bytes must equal the
|
||||||
|
// declared uncompressed size, and the CRC must verify.
|
||||||
if (data.length !== uncompSize) throw new Error(`zip: size mismatch for ${name}`);
|
if (data.length !== uncompSize) throw new Error(`zip: size mismatch for ${name}`);
|
||||||
if (crc32(data) !== crc) throw new Error(`zip: CRC mismatch for ${name}`);
|
if (crc32(data) !== crc) throw new Error(`zip: CRC mismatch for ${name}`);
|
||||||
entries.push({ name, data });
|
entries.push({ name, data });
|
||||||
@@ -170,10 +203,10 @@ function unzipSync(buffer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Extract a zip buffer under destDir; every path is traversal-checked. */
|
/** Extract a zip buffer under destDir; every path is traversal-checked. */
|
||||||
function extractZipSync(buffer, destDir) {
|
function extractZipSync(buffer, destDir, { limits = {} } = {}) {
|
||||||
const resolvedDest = path.resolve(destDir);
|
const resolvedDest = path.resolve(destDir);
|
||||||
const written = [];
|
const written = [];
|
||||||
for (const { name, data } of unzipSync(buffer)) {
|
for (const { name, data } of unzipSync(buffer, { limits })) {
|
||||||
const target = path.resolve(resolvedDest, name);
|
const target = path.resolve(resolvedDest, name);
|
||||||
if (target !== resolvedDest && !target.startsWith(resolvedDest + path.sep)) {
|
if (target !== resolvedDest && !target.startsWith(resolvedDest + path.sep)) {
|
||||||
throw new Error(`zip: entry escapes destination: ${name}`);
|
throw new Error(`zip: entry escapes destination: ${name}`);
|
||||||
@@ -203,4 +236,7 @@ function zipDirSync(dir, { filter = () => true, prefix = '' } = {}) {
|
|||||||
return zipSync(entries);
|
return zipSync(entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { crc32, zipSync, unzipSync, extractZipSync, zipDirSync, assertSafeEntryName };
|
module.exports = {
|
||||||
|
crc32, zipSync, unzipSync, extractZipSync, zipDirSync, assertSafeEntryName,
|
||||||
|
DEFAULT_UNZIP_LIMITS,
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# StepForge on apt-based Linux (Debian / Ubuntu)
|
||||||
|
|
||||||
|
This is the setup and packaging guide for **apt-based** distributions. Fedora
|
||||||
|
and other dnf-based systems have a separate guide: [dnf.md](dnf.md).
|
||||||
|
|
||||||
|
## Install from the .deb
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install ./stepforge_<version>_amd64.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
apt pulls the required runtime libraries automatically (they are declared as
|
||||||
|
`Depends`). The package installs:
|
||||||
|
|
||||||
|
- the app and a fixed Electron runtime under `/opt/stepforge`,
|
||||||
|
- the `stepforge` launcher at `/usr/bin/stepforge`,
|
||||||
|
- a desktop entry, icons, and `.sfgz`/`.sfglt` file associations.
|
||||||
|
|
||||||
|
Launch it from your application menu or run `stepforge`.
|
||||||
|
|
||||||
|
### Sandbox
|
||||||
|
|
||||||
|
The launcher runs **sandboxed**. On most modern kernels the Chromium
|
||||||
|
user-namespace sandbox works out of the box; the package's `postinst` also
|
||||||
|
makes the setuid `chrome-sandbox` helper usable as a fallback. StepForge will
|
||||||
|
**not** silently launch unsandboxed — see the launcher's message if the
|
||||||
|
sandbox is unavailable.
|
||||||
|
|
||||||
|
## Install from the portable tarball
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar -xzf stepforge_<version>_linux-x64.tar.gz
|
||||||
|
# Install the runtime libraries first (see below), then run:
|
||||||
|
./usr/bin/stepforge # or move opt/stepforge to /opt and use the launcher
|
||||||
|
```
|
||||||
|
|
||||||
|
The tarball includes the `/usr/bin/stepforge` launcher (unlike older builds).
|
||||||
|
Install the runtime libraries with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/linux/apt/install-runtime-deps.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Capture capabilities on apt systems
|
||||||
|
|
||||||
|
- **X11**: full per-click capture with an accurate marker (needs `xinput`).
|
||||||
|
- **Wayland**: screen capture via the XDG Desktop Portal + PipeWire; the
|
||||||
|
portal asks permission once per recording. Per-click capture with
|
||||||
|
coordinates is not exposed by Wayland, so recording uses a global hotkey or
|
||||||
|
interval trigger. StepForge reports the active trigger honestly.
|
||||||
|
|
||||||
|
Run StepForge and open Settings → Diagnostics to see the detected session
|
||||||
|
type, portal/PipeWire status, and the active capture profile.
|
||||||
|
|
||||||
|
## Build the .deb yourself
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/linux/apt/install-build-deps.sh # dpkg-dev, fakeroot, xvfb, …
|
||||||
|
nvm install && nvm use # pinned Node 22 (see .nvmrc)
|
||||||
|
npm ci
|
||||||
|
npm run package:linux:deb # -> build/artifacts/*.deb + tarball + sha256
|
||||||
|
```
|
||||||
|
|
||||||
|
The builder stages **only** runtime files: the app code, a fixed Electron
|
||||||
|
runtime, and production npm dependencies. It never copies the development
|
||||||
|
`node_modules`, docs, prompts, or examples, and it fails if `node_modules` is
|
||||||
|
missing rather than producing an unusable artifact.
|
||||||
@@ -14,7 +14,10 @@
|
|||||||
"start": "node scripts/start-electron.js",
|
"start": "node scripts/start-electron.js",
|
||||||
"test": "node scripts/run-unit-tests.js",
|
"test": "node scripts/run-unit-tests.js",
|
||||||
"sample": "node scripts/make-sample-guide.js",
|
"sample": "node scripts/make-sample-guide.js",
|
||||||
|
"icons": "node scripts/make-icons.js",
|
||||||
"package:windows": "node scripts/package-windows.js",
|
"package:windows": "node scripts/package-windows.js",
|
||||||
|
"package:linux:deb": "bash packaging/linux/debian/package.sh",
|
||||||
|
"package:linux:rpm": "bash packaging/linux/fedora/package.sh",
|
||||||
"build": "bash scripts/build-release.sh",
|
"build": "bash scripts/build-release.sh",
|
||||||
"verify": "bash scripts/verify.sh",
|
"verify": "bash scripts/verify.sh",
|
||||||
"bootstrap": "bash scripts/bootstrap-offline.sh"
|
"bootstrap": "bash scripts/bootstrap-offline.sh"
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 234 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 352 B |
|
After Width: | Height: | Size: 482 B |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 611 B |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
StepForge application icon — original artwork.
|
||||||
|
A rising staircase of three blocks (the "steps" of a step-by-step guide)
|
||||||
|
over a rounded square, in the app's blue. No third-party assets.
|
||||||
|
-->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#2563eb"/>
|
||||||
|
<stop offset="1" stop-color="#1e3a8a"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="256" height="256" rx="56" fill="url(#bg)"/>
|
||||||
|
<!-- Three ascending steps -->
|
||||||
|
<g fill="#ffffff">
|
||||||
|
<rect x="52" y="150" width="52" height="54" rx="8"/>
|
||||||
|
<rect x="102" y="116" width="52" height="88" rx="8"/>
|
||||||
|
<rect x="152" y="82" width="52" height="122" rx="8"/>
|
||||||
|
</g>
|
||||||
|
<!-- Capture spark on the top step -->
|
||||||
|
<circle cx="178" cy="60" r="16" fill="#facc15"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 925 B |
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# StepForge launcher installed at /usr/bin/stepforge.
|
||||||
|
#
|
||||||
|
# Runs the packaged Electron runtime against the installed app at
|
||||||
|
# /opt/stepforge. It NEVER installs or repairs anything at runtime and it does
|
||||||
|
# NOT silently disable the Chromium sandbox: an unsandboxed launch requires the
|
||||||
|
# explicit STEPFORGE_ALLOW_NO_SANDBOX=1 opt-in (development/CI only).
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
APP_DIR=/opt/stepforge
|
||||||
|
ELECTRON="$APP_DIR/node_modules/electron/dist/electron"
|
||||||
|
SANDBOX_HELPER="$APP_DIR/node_modules/electron/dist/chrome-sandbox"
|
||||||
|
|
||||||
|
if [ ! -x "$ELECTRON" ]; then
|
||||||
|
echo "stepforge: Electron runtime missing at $ELECTRON (reinstall the package)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$APP_DIR" || exit 1
|
||||||
|
|
||||||
|
# Linux screen capture: enable the PipeWire path for Wayland portals; harmless
|
||||||
|
# on X11 where Ozone auto-selects.
|
||||||
|
COMMON_ARGS="--enable-features=WebRTCPipeWireCapturer --ozone-platform-hint=auto"
|
||||||
|
|
||||||
|
sandbox_ok() {
|
||||||
|
[ -e "$SANDBOX_HELPER" ] || return 1
|
||||||
|
helper_uid="$(stat -c '%u' "$SANDBOX_HELPER" 2>/dev/null || echo '')"
|
||||||
|
helper_mode="$(stat -c '%a' "$SANDBOX_HELPER" 2>/dev/null || echo '')"
|
||||||
|
[ "$helper_uid" = "0" ] || return 1
|
||||||
|
[ -n "$helper_mode" ] || return 1
|
||||||
|
# setuid bit set?
|
||||||
|
[ $(( $((8#$helper_mode)) & 04000 )) -ne 0 ] || return 1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
userns_ok() {
|
||||||
|
# Namespaced sandbox works without the setuid helper on kernels that allow
|
||||||
|
# unprivileged user namespaces.
|
||||||
|
if [ -r /proc/sys/kernel/unprivileged_userns_clone ]; then
|
||||||
|
[ "$(cat /proc/sys/kernel/unprivileged_userns_clone)" = "1" ] && return 0 || return 1
|
||||||
|
fi
|
||||||
|
if [ -r /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then
|
||||||
|
[ "$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns)" = "0" ] && return 0 || return 1
|
||||||
|
fi
|
||||||
|
[ -e /proc/self/ns/user ] && return 0 || return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if sandbox_ok || userns_ok; then
|
||||||
|
exec "$ELECTRON" $COMMON_ARGS "$APP_DIR" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${STEPFORGE_ALLOW_NO_SANDBOX:-}" = "1" ] || [ "${ELECTRON_DISABLE_SANDBOX:-}" = "1" ]; then
|
||||||
|
echo "stepforge: launching WITHOUT the Chromium sandbox (explicit opt-in)." >&2
|
||||||
|
exec "$ELECTRON" --no-sandbox $COMMON_ARGS "$APP_DIR" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat >&2 <<'MSG'
|
||||||
|
stepforge: the Chromium sandbox is not available and StepForge will not launch
|
||||||
|
unsandboxed by default.
|
||||||
|
|
||||||
|
Fix one of the following:
|
||||||
|
* Make the setuid sandbox helper usable:
|
||||||
|
sudo chown root:root /opt/stepforge/node_modules/electron/dist/chrome-sandbox
|
||||||
|
sudo chmod 4755 /opt/stepforge/node_modules/electron/dist/chrome-sandbox
|
||||||
|
* Enable unprivileged user namespaces (kernel/sysctl dependent):
|
||||||
|
sudo sysctl -w kernel.unprivileged_userns_clone=1
|
||||||
|
|
||||||
|
For development/CI only you may set STEPFORGE_ALLOW_NO_SANDBOX=1 to override.
|
||||||
|
MSG
|
||||||
|
exit 1
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
|
||||||
|
<mime-type type="application/x-stepforge-guide">
|
||||||
|
<comment>StepForge guide archive</comment>
|
||||||
|
<glob pattern="*.sfgz"/>
|
||||||
|
<icon name="stepforge"/>
|
||||||
|
</mime-type>
|
||||||
|
<mime-type type="application/x-stepforge-template">
|
||||||
|
<comment>StepForge export template</comment>
|
||||||
|
<glob pattern="*.sfglt"/>
|
||||||
|
<icon name="stepforge"/>
|
||||||
|
</mime-type>
|
||||||
|
</mime-info>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=StepForge
|
||||||
|
GenericName=Step-by-step guide capture
|
||||||
|
Comment=Capture, annotate, and export step-by-step guides
|
||||||
|
Exec=stepforge %U
|
||||||
|
Icon=stepforge
|
||||||
|
Terminal=false
|
||||||
|
Categories=Office;Graphics;Utility;
|
||||||
|
Keywords=documentation;screenshot;guide;capture;steps;
|
||||||
|
StartupNotify=true
|
||||||
|
StartupWMClass=StepForge
|
||||||
|
MimeType=application/x-stepforge-guide;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
Package: stepforge
|
||||||
|
Version: @VERSION@
|
||||||
|
Section: utils
|
||||||
|
Priority: optional
|
||||||
|
Architecture: @ARCH@
|
||||||
|
Depends: libnss3, libnspr4, libatk1.0-0, libatk-bridge2.0-0, libcups2, libgbm1, libasound2, libgtk-3-0, libxkbcommon0, libatspi2.0-0
|
||||||
|
Recommends: xinput, x11-utils, xdg-desktop-portal, pipewire
|
||||||
|
Maintainer: @MAINTAINER@
|
||||||
|
Homepage: https://github.com/Twest2/StepForge
|
||||||
|
Description: Local-first step-by-step guide capture and export tool
|
||||||
|
StepForge captures step-by-step workflows as screenshots, lets you annotate
|
||||||
|
and describe each step, and exports to Markdown, PDF, DOCX, PPTX, HTML, and
|
||||||
|
more. Local-first: no telemetry, with an optional user-configured local AI
|
||||||
|
integration.
|
||||||
|
.
|
||||||
|
This package bundles a fixed Electron runtime and only production
|
||||||
|
dependencies; it does not install anything at runtime.
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build a production StepForge .deb (and a matching portable tarball) from a
|
||||||
|
# pruned, runtime-only tree.
|
||||||
|
#
|
||||||
|
# Unlike the old scripts/package-linux.sh this does NOT copy the development
|
||||||
|
# node_modules, docs, prompts, examples, or stale audit files; it stages only
|
||||||
|
# the app code plus a runtime dependency set (the fixed Electron runtime and
|
||||||
|
# production npm deps), a real desktop entry, icons, MIME registration, and a
|
||||||
|
# license. Architecture is detected, not hardcoded.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
VERSION="$(node -p "require('./package.json').version")"
|
||||||
|
MAINTAINER="${STEPFORGE_MAINTAINER:-StepForge <[email protected]>}"
|
||||||
|
OUT_DIR="${STEPFORGE_PACKAGE_DIR:-$ROOT_DIR/build/artifacts}"
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
|
||||||
|
# Map dpkg architecture to a Node-style label for the tarball name.
|
||||||
|
DEB_ARCH="$(dpkg --print-architecture 2>/dev/null || echo amd64)"
|
||||||
|
case "$DEB_ARCH" in
|
||||||
|
amd64) NODE_ARCH="x64" ;;
|
||||||
|
arm64) NODE_ARCH="arm64" ;;
|
||||||
|
*) NODE_ARCH="$DEB_ARCH" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# A packaged app must contain a fixed runtime; never install at build time from
|
||||||
|
# within the package step, and never ship without node_modules.
|
||||||
|
if [ ! -d "$ROOT_DIR/node_modules/electron/dist" ]; then
|
||||||
|
echo "error: node_modules/electron is missing. Run 'npm ci' before packaging." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
WORK_DIR="$(mktemp -d "${OUT_DIR%/}/.deb.XXXXXX")"
|
||||||
|
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||||
|
APP_DIR="$WORK_DIR/opt/stepforge"
|
||||||
|
mkdir -p "$APP_DIR" "$WORK_DIR/usr/bin" "$WORK_DIR/DEBIAN"
|
||||||
|
mkdir -p "$WORK_DIR/usr/share/applications"
|
||||||
|
mkdir -p "$WORK_DIR/usr/share/mime/packages"
|
||||||
|
mkdir -p "$WORK_DIR/usr/share/doc/stepforge"
|
||||||
|
|
||||||
|
# --- application code (runtime only) ----------------------------------------
|
||||||
|
for item in app core exporters package.json package-lock.json; do
|
||||||
|
cp -a "$ROOT_DIR/$item" "$APP_DIR/$item"
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- runtime node_modules ----------------------------------------------------
|
||||||
|
# The fixed Electron runtime (needed at runtime even though it is a dev dep):
|
||||||
|
mkdir -p "$APP_DIR/node_modules"
|
||||||
|
cp -a "$ROOT_DIR/node_modules/electron" "$APP_DIR/node_modules/electron"
|
||||||
|
# Production npm dependencies (tesseract.js + language data + transitive):
|
||||||
|
while IFS= read -r dep; do
|
||||||
|
[ -n "$dep" ] || continue
|
||||||
|
rel="${dep#"$ROOT_DIR"/}"
|
||||||
|
[ "$rel" != "$dep" ] || continue # only paths under the repo
|
||||||
|
[ -d "$dep" ] || continue
|
||||||
|
mkdir -p "$APP_DIR/$(dirname "$rel")"
|
||||||
|
cp -a "$dep" "$APP_DIR/$rel"
|
||||||
|
done < <(npm ls --omit=dev --all --parseable 2>/dev/null | tail -n +2)
|
||||||
|
|
||||||
|
# Guard: the development-only packaging toolchain must not have leaked in.
|
||||||
|
if [ -d "$APP_DIR/node_modules/electron-builder" ] || [ -d "$APP_DIR/node_modules/app-builder-lib" ]; then
|
||||||
|
echo "error: build-only dependency leaked into the package payload." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- launcher ----------------------------------------------------------------
|
||||||
|
install -m 0755 "$ROOT_DIR/packaging/linux/common/launcher.sh" "$WORK_DIR/usr/bin/stepforge"
|
||||||
|
|
||||||
|
# --- desktop entry, icons, MIME ---------------------------------------------
|
||||||
|
install -m 0644 "$ROOT_DIR/packaging/linux/common/stepforge.desktop" "$WORK_DIR/usr/share/applications/stepforge.desktop"
|
||||||
|
install -m 0644 "$ROOT_DIR/packaging/linux/common/stepforge-mime.xml" "$WORK_DIR/usr/share/mime/packages/stepforge.xml"
|
||||||
|
for size in 16 32 48 64 128 256 512; do
|
||||||
|
icon="$ROOT_DIR/packaging/assets/icons/stepforge-${size}.png"
|
||||||
|
[ -f "$icon" ] || continue
|
||||||
|
dest="$WORK_DIR/usr/share/icons/hicolor/${size}x${size}/apps"
|
||||||
|
mkdir -p "$dest"
|
||||||
|
install -m 0644 "$icon" "$dest/stepforge.png"
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- license + docs pointer --------------------------------------------------
|
||||||
|
if [ -f "$ROOT_DIR/LICENSE" ]; then
|
||||||
|
install -m 0644 "$ROOT_DIR/LICENSE" "$WORK_DIR/usr/share/doc/stepforge/copyright"
|
||||||
|
elif [ -f "$ROOT_DIR/docs/LICENSE" ]; then
|
||||||
|
install -m 0644 "$ROOT_DIR/docs/LICENSE" "$WORK_DIR/usr/share/doc/stepforge/copyright"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- DEBIAN control + maintainer scripts ------------------------------------
|
||||||
|
sed -e "s/@VERSION@/$VERSION/" -e "s/@ARCH@/$DEB_ARCH/" -e "s#@MAINTAINER@#$MAINTAINER#" \
|
||||||
|
"$ROOT_DIR/packaging/linux/debian/control.in" > "$WORK_DIR/DEBIAN/control"
|
||||||
|
|
||||||
|
cat > "$WORK_DIR/DEBIAN/postinst" <<'POSTINST'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
# Make the Chromium setuid sandbox helper usable so the app launches sandboxed.
|
||||||
|
HELPER=/opt/stepforge/node_modules/electron/dist/chrome-sandbox
|
||||||
|
if [ -e "$HELPER" ]; then
|
||||||
|
chown root:root "$HELPER" || true
|
||||||
|
chmod 4755 "$HELPER" || true
|
||||||
|
fi
|
||||||
|
# Refresh desktop/MIME/icon caches (best effort).
|
||||||
|
if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database -q /usr/share/applications || true; fi
|
||||||
|
if command -v update-mime-database >/dev/null 2>&1; then update-mime-database /usr/share/mime || true; fi
|
||||||
|
if command -v gtk-update-icon-cache >/dev/null 2>&1; then gtk-update-icon-cache -q /usr/share/icons/hicolor || true; fi
|
||||||
|
exit 0
|
||||||
|
POSTINST
|
||||||
|
|
||||||
|
cat > "$WORK_DIR/DEBIAN/prerm" <<'PRERM'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
exit 0
|
||||||
|
PRERM
|
||||||
|
|
||||||
|
cat > "$WORK_DIR/DEBIAN/postrm" <<'POSTRM'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
if [ "$1" = "remove" ] || [ "$1" = "purge" ]; then
|
||||||
|
if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database -q /usr/share/applications || true; fi
|
||||||
|
if command -v update-mime-database >/dev/null 2>&1; then update-mime-database /usr/share/mime || true; fi
|
||||||
|
if command -v gtk-update-icon-cache >/dev/null 2>&1; then gtk-update-icon-cache -q /usr/share/icons/hicolor || true; fi
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
POSTRM
|
||||||
|
chmod 0755 "$WORK_DIR/DEBIAN/postinst" "$WORK_DIR/DEBIAN/prerm" "$WORK_DIR/DEBIAN/postrm"
|
||||||
|
|
||||||
|
# --- build the .deb ----------------------------------------------------------
|
||||||
|
DEB_FILE="$OUT_DIR/stepforge_${VERSION}_${DEB_ARCH}.deb"
|
||||||
|
if command -v fakeroot >/dev/null 2>&1; then
|
||||||
|
fakeroot dpkg-deb --build "$WORK_DIR" "$DEB_FILE" >/dev/null
|
||||||
|
else
|
||||||
|
dpkg-deb --build "$WORK_DIR" "$DEB_FILE" >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- portable tarball (INCLUDES the launcher, unlike the old script) ---------
|
||||||
|
TAR_FILE="$OUT_DIR/stepforge_${VERSION}_linux-${NODE_ARCH}.tar.gz"
|
||||||
|
tar -C "$WORK_DIR" -czf "$TAR_FILE" opt usr/bin/stepforge usr/share/applications usr/share/mime usr/share/icons
|
||||||
|
|
||||||
|
# --- checksums ---------------------------------------------------------------
|
||||||
|
( cd "$OUT_DIR" && sha256sum "$(basename "$DEB_FILE")" "$(basename "$TAR_FILE")" > "stepforge_${VERSION}_${DEB_ARCH}.sha256" )
|
||||||
|
|
||||||
|
echo "$DEB_FILE"
|
||||||
|
echo "$TAR_FILE"
|
||||||
@@ -14,7 +14,14 @@ mkdir -p "$BUILD_ROOT"
|
|||||||
|
|
||||||
bash "$ROOT_DIR/scripts/bootstrap-offline.sh"
|
bash "$ROOT_DIR/scripts/bootstrap-offline.sh"
|
||||||
node "$ROOT_DIR/scripts/make-sample-guide.js" --root "$EXAMPLES_ROOT"
|
node "$ROOT_DIR/scripts/make-sample-guide.js" --root "$EXAMPLES_ROOT"
|
||||||
STEPFORGE_PACKAGE_DIR="$ARTIFACT_DIR" bash "$ROOT_DIR/scripts/package-linux.sh" >/dev/null
|
# Production Linux package: a pruned runtime tree with real desktop
|
||||||
|
# integration. Requires node_modules (fails otherwise); never installs at
|
||||||
|
# build time. Skipped only when the Electron runtime is genuinely absent.
|
||||||
|
if [ -d "$ROOT_DIR/node_modules/electron/dist" ]; then
|
||||||
|
STEPFORGE_PACKAGE_DIR="$ARTIFACT_DIR" bash "$ROOT_DIR/packaging/linux/debian/package.sh" >/dev/null
|
||||||
|
else
|
||||||
|
echo "[build-release] skipping Linux .deb: node_modules/electron missing (run npm ci)" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
BUILD_ROOT="$BUILD_ROOT" \
|
BUILD_ROOT="$BUILD_ROOT" \
|
||||||
ARTIFACT_DIR="$ARTIFACT_DIR" \
|
ARTIFACT_DIR="$ARTIFACT_DIR" \
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install the BUILD toolchain for producing StepForge packages on apt-based
|
||||||
|
# systems. These are for DEVELOPERS/packagers only and are never shipped inside
|
||||||
|
# the end-user package.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if ! command -v apt-get >/dev/null 2>&1; then
|
||||||
|
echo "This script is for apt-based systems (Debian/Ubuntu)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SUDO=""
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
|
||||||
|
|
||||||
|
PACKAGES=(
|
||||||
|
dpkg-dev fakeroot # build the .deb
|
||||||
|
desktop-file-utils # validate the .desktop entry
|
||||||
|
ca-certificates # npm ci over https
|
||||||
|
xvfb # headless smoke test under Xvfb
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "Installing StepForge build dependencies via apt..."
|
||||||
|
$SUDO apt-get update
|
||||||
|
$SUDO apt-get install -y --no-install-recommends "${PACKAGES[@]}"
|
||||||
|
|
||||||
|
cat <<'MSG'
|
||||||
|
Done. Also install the pinned Node toolchain (see .nvmrc — Node 22.12+):
|
||||||
|
nvm install && nvm use # or another Node 22 LTS install method
|
||||||
|
Then, from the repo root:
|
||||||
|
npm ci
|
||||||
|
npm run package:linux:deb
|
||||||
|
MSG
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install the RUNTIME libraries StepForge needs on apt-based systems
|
||||||
|
# (Debian/Ubuntu). These are the shared libraries the packaged Electron runtime
|
||||||
|
# links against, plus the X11/portal integration used for capture. This is for
|
||||||
|
# END USERS installing from the tarball; the .deb declares the same set as
|
||||||
|
# Depends so apt pulls them automatically.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if ! command -v apt-get >/dev/null 2>&1; then
|
||||||
|
echo "This script is for apt-based systems (Debian/Ubuntu). Use the dnf script on Fedora." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SUDO=""
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
|
||||||
|
|
||||||
|
PACKAGES=(
|
||||||
|
# Chromium/Electron shared libraries
|
||||||
|
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2
|
||||||
|
libgtk-3-0 libgbm1 libasound2 libxkbcommon0 libatspi2.0-0
|
||||||
|
libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libxshmfence1
|
||||||
|
# X11 per-click capture (marker-accurate) — X11 sessions only
|
||||||
|
xinput x11-utils
|
||||||
|
# Wayland screen-share via the XDG portal + PipeWire
|
||||||
|
xdg-desktop-portal pipewire
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "Installing StepForge runtime dependencies via apt..."
|
||||||
|
$SUDO apt-get update
|
||||||
|
$SUDO apt-get install -y --no-install-recommends "${PACKAGES[@]}"
|
||||||
|
echo "Done. StepForge runtime dependencies are installed."
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Generate the StepForge PNG icon set from original geometry using the repo's
|
||||||
|
// own rasterizer + PNG writer (no external image tooling or third-party art).
|
||||||
|
// Mirrors packaging/assets/stepforge.svg. Output: packaging/assets/icons/.
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { createImage, fillRect, fillOval } = require('../core/raster');
|
||||||
|
const { encodePng } = require('../core/png');
|
||||||
|
|
||||||
|
const OUT_DIR = path.join(__dirname, '..', 'packaging', 'assets', 'icons');
|
||||||
|
const SIZES = [16, 32, 48, 64, 128, 256, 512];
|
||||||
|
|
||||||
|
const BG_TOP = [37, 99, 235, 255];
|
||||||
|
const BG_BOTTOM = [30, 58, 138, 255];
|
||||||
|
const WHITE = [255, 255, 255, 255];
|
||||||
|
const SPARK = [250, 204, 21, 255];
|
||||||
|
|
||||||
|
function lerp(a, b, t) {
|
||||||
|
return [
|
||||||
|
Math.round(a[0] + (b[0] - a[0]) * t),
|
||||||
|
Math.round(a[1] + (b[1] - a[1]) * t),
|
||||||
|
Math.round(a[2] + (b[2] - a[2]) * t),
|
||||||
|
255,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIcon(size) {
|
||||||
|
const img = createImage(size, size, [0, 0, 0, 0]);
|
||||||
|
const s = size / 256; // scale from the 256px reference design
|
||||||
|
|
||||||
|
// Rounded-square background approximated by a vertical gradient fill.
|
||||||
|
for (let y = 0; y < size; y += 1) {
|
||||||
|
fillRect(img, 0, y, size, 1, lerp(BG_TOP, BG_BOTTOM, y / size));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three ascending steps (x, y, w, h in reference px).
|
||||||
|
const steps = [
|
||||||
|
[52, 150, 52, 54],
|
||||||
|
[102, 116, 52, 88],
|
||||||
|
[152, 82, 52, 122],
|
||||||
|
];
|
||||||
|
for (const [x, y, w, h] of steps) {
|
||||||
|
fillRect(img, Math.round(x * s), Math.round(y * s), Math.round(w * s), Math.round(h * s), WHITE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture spark on the top step.
|
||||||
|
const r = Math.max(2, Math.round(16 * s));
|
||||||
|
fillOval(img, Math.round(178 * s - r), Math.round(60 * s - r), r * 2, r * 2, SPARK);
|
||||||
|
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||||
|
for (const size of SIZES) {
|
||||||
|
const png = encodePng(renderIcon(size));
|
||||||
|
fs.writeFileSync(path.join(OUT_DIR, `stepforge-${size}.png`), png);
|
||||||
|
}
|
||||||
|
// A conventional default name for the desktop entry / hicolor 256px slot.
|
||||||
|
fs.copyFileSync(path.join(OUT_DIR, 'stepforge-256.png'), path.join(OUT_DIR, 'stepforge.png'));
|
||||||
|
console.log(`wrote ${SIZES.length + 1} icons to ${OUT_DIR}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) main();
|
||||||
|
|
||||||
|
module.exports = { renderIcon, SIZES };
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
VERSION="$(node -p "const pkg=require('${ROOT_DIR}/package.json'); pkg.buildVersion || pkg.version" 2>/dev/null || echo 0.0.0)"
|
|
||||||
OUT_DIR="${STEPFORGE_PACKAGE_DIR:-$ROOT_DIR/build/artifacts}"
|
|
||||||
mkdir -p "$OUT_DIR"
|
|
||||||
WORK_DIR="$(mktemp -d "${OUT_DIR%/}/.pkg.XXXXXX")"
|
|
||||||
APP_DIR="$WORK_DIR/opt/stepforge"
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
rm -rf "$WORK_DIR"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
mkdir -p "$APP_DIR" "$WORK_DIR/usr/bin" "$WORK_DIR/DEBIAN"
|
|
||||||
|
|
||||||
copy_item() {
|
|
||||||
local src="$1"
|
|
||||||
local dest="$2"
|
|
||||||
if [[ -e "$ROOT_DIR/$src" ]]; then
|
|
||||||
mkdir -p "$(dirname "$dest")"
|
|
||||||
cp -a "$ROOT_DIR/$src" "$dest"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Application payload: only the files needed to run the app.
|
|
||||||
copy_item app "$APP_DIR/app"
|
|
||||||
copy_item core "$APP_DIR/core"
|
|
||||||
copy_item exporters "$APP_DIR/exporters"
|
|
||||||
copy_item scripts "$APP_DIR/scripts"
|
|
||||||
copy_item README.md "$APP_DIR/README.md"
|
|
||||||
copy_item LICENSE "$APP_DIR/LICENSE"
|
|
||||||
copy_item docs "$APP_DIR/docs"
|
|
||||||
copy_item ai_prompts "$APP_DIR/ai_prompts"
|
|
||||||
copy_item package.json "$APP_DIR/package.json"
|
|
||||||
copy_item package-lock.json "$APP_DIR/package-lock.json"
|
|
||||||
copy_item examples "$APP_DIR/examples"
|
|
||||||
copy_item build/agent_audit.md "$APP_DIR/build/agent_audit.md"
|
|
||||||
|
|
||||||
if [[ -d "$ROOT_DIR/node_modules" ]]; then
|
|
||||||
cp -a "$ROOT_DIR/node_modules" "$APP_DIR/node_modules"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cat > "$WORK_DIR/usr/bin/stepforge" <<'EOF'
|
|
||||||
#!/usr/bin/env sh
|
|
||||||
APP_DIR=/opt/stepforge
|
|
||||||
ELECTRON="$APP_DIR/node_modules/.bin/electron"
|
|
||||||
SANDBOX_HELPER="$APP_DIR/node_modules/electron/dist/chrome-sandbox"
|
|
||||||
cd "$APP_DIR" || exit 1
|
|
||||||
if command -v stat >/dev/null 2>&1 && [ -e "$SANDBOX_HELPER" ]; then
|
|
||||||
helper_uid="$(stat -c '%u' "$SANDBOX_HELPER" 2>/dev/null || echo '')"
|
|
||||||
helper_mode="$(stat -c '%a' "$SANDBOX_HELPER" 2>/dev/null || echo '')"
|
|
||||||
if [ "$helper_uid" = "0" ] && [ -n "$helper_mode" ]; then
|
|
||||||
helper_mode_num=$((8#$helper_mode))
|
|
||||||
if [ $((helper_mode_num & 04000)) -ne 0 ]; then
|
|
||||||
exec "$ELECTRON" "$APP_DIR" "$@"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
printf '%s\n' '[stepforge] Electron sandbox helper is not configured for this install; starting with --no-sandbox' >&2
|
|
||||||
exec "$ELECTRON" --no-sandbox "$APP_DIR" "$@"
|
|
||||||
EOF
|
|
||||||
chmod 0755 "$WORK_DIR/usr/bin/stepforge"
|
|
||||||
|
|
||||||
cat > "$WORK_DIR/DEBIAN/control" <<EOF
|
|
||||||
Package: stepforge
|
|
||||||
Version: $VERSION
|
|
||||||
Section: utils
|
|
||||||
Priority: optional
|
|
||||||
Architecture: amd64
|
|
||||||
Depends: xinput
|
|
||||||
Maintainer: StepForge <[email protected]>
|
|
||||||
Description: Offline desktop guide capture and export tool
|
|
||||||
A fully offline desktop app for step-by-step documentation, built for local
|
|
||||||
capture, annotation, and export workflows.
|
|
||||||
EOF
|
|
||||||
|
|
||||||
DEB_FILE="$OUT_DIR/stepforge_${VERSION}_amd64.deb"
|
|
||||||
TAR_FILE="$OUT_DIR/stepforge_${VERSION}_linux-x64.tar.gz"
|
|
||||||
|
|
||||||
if command -v dpkg-deb >/dev/null 2>&1; then
|
|
||||||
dpkg-deb --build "$WORK_DIR" "$DEB_FILE" >/dev/null
|
|
||||||
else
|
|
||||||
echo "dpkg-deb is not installed; skipping .deb build" >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
tar -C "$WORK_DIR/opt" -czf "$TAR_FILE" stepforge
|
|
||||||
|
|
||||||
printf '%s\n' "$DEB_FILE"
|
|
||||||
printf '%s\n' "$TAR_FILE"
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Integration test: build the production .deb and assert it is a real,
|
||||||
|
# runtime-only package — the right files present, and the dev tree / build
|
||||||
|
# tooling / app docs absent. A package is NOT accepted merely because
|
||||||
|
# dpkg-deb produced a file.
|
||||||
|
#
|
||||||
|
# Honest skip policy: skip ONLY when the prerequisites are genuinely absent
|
||||||
|
# (not apt-based, dpkg-deb missing, or node_modules not installed). Once we
|
||||||
|
# build, any structural failure fails the test.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
if ! command -v dpkg-deb >/dev/null 2>&1; then
|
||||||
|
echo "package-deb SKIPPED: dpkg-deb not installed (not an apt-based build host)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ ! -d "$ROOT_DIR/node_modules/electron/dist" ]; then
|
||||||
|
echo "package-deb SKIPPED: node_modules/electron missing (run npm ci first)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
OUT_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$OUT_DIR"' EXIT
|
||||||
|
|
||||||
|
DEB="$(STEPFORGE_PACKAGE_DIR="$OUT_DIR" bash packaging/linux/debian/package.sh | head -1)"
|
||||||
|
if [ ! -f "$DEB" ]; then
|
||||||
|
echo "package-deb FAILED: builder did not produce a .deb" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
fail() { echo "package-deb FAILED: $1" >&2; exit 1; }
|
||||||
|
|
||||||
|
listing="$(dpkg-deb -c "$DEB")"
|
||||||
|
control="$(dpkg-deb -f "$DEB")"
|
||||||
|
|
||||||
|
# Required install items.
|
||||||
|
for needle in \
|
||||||
|
'./usr/bin/stepforge' \
|
||||||
|
'./usr/share/applications/stepforge.desktop' \
|
||||||
|
'./usr/share/mime/packages/stepforge.xml' \
|
||||||
|
'./usr/share/icons/hicolor/256x256/apps/stepforge.png' \
|
||||||
|
'./opt/stepforge/node_modules/electron/dist/electron' \
|
||||||
|
'./opt/stepforge/app/main.js' \
|
||||||
|
'./usr/share/doc/stepforge/copyright'; do
|
||||||
|
echo "$listing" | grep -qF "$needle" || fail "missing packaged file: $needle"
|
||||||
|
done
|
||||||
|
|
||||||
|
# The development node_modules / build tooling must NOT be present.
|
||||||
|
for banned in \
|
||||||
|
'node_modules/electron-builder' \
|
||||||
|
'node_modules/app-builder-lib' \
|
||||||
|
'node_modules/dmg-builder'; do
|
||||||
|
echo "$listing" | grep -qF "$banned" && fail "build-only dependency leaked: $banned" || true
|
||||||
|
done
|
||||||
|
|
||||||
|
# The app's own docs/prompts/examples must not be shipped.
|
||||||
|
for banned in \
|
||||||
|
'./opt/stepforge/docs/' \
|
||||||
|
'./opt/stepforge/ai_prompts/' \
|
||||||
|
'./opt/stepforge/examples/'; do
|
||||||
|
echo "$listing" | grep -qF "$banned" && fail "app extra shipped: $banned" || true
|
||||||
|
done
|
||||||
|
|
||||||
|
# Control metadata sanity.
|
||||||
|
echo "$control" | grep -q '^Package: stepforge' || fail "control missing Package"
|
||||||
|
echo "$control" | grep -q '^Depends:.*libnss3' || fail "control missing runtime Depends"
|
||||||
|
echo "$control" | grep -Eq '^Architecture: (amd64|arm64)' || fail "control has no concrete Architecture"
|
||||||
|
|
||||||
|
# Sandbox is set up, not disabled: postinst makes chrome-sandbox setuid.
|
||||||
|
dpkg-deb --info "$DEB" | grep -q 'postinst' || fail "no postinst maintainer script"
|
||||||
|
|
||||||
|
# The launcher must refuse an unsandboxed launch by default.
|
||||||
|
grep -q 'STEPFORGE_ALLOW_NO_SANDBOX' packaging/linux/common/launcher.sh \
|
||||||
|
|| fail "launcher does not gate --no-sandbox behind an explicit opt-in"
|
||||||
|
|
||||||
|
echo "package-deb OK ($(basename "$DEB"), $(du -h "$DEB" | cut -f1))"
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..', '..');
|
||||||
|
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
||||||
|
const exists = (rel) => fs.existsSync(path.join(ROOT, rel));
|
||||||
|
|
||||||
|
// These are structural checks that run in the normal (cross-platform) unit
|
||||||
|
// suite so the Linux packaging config can't rot silently; the actual .deb
|
||||||
|
// build/inspection lives in tests/integration/linux/package-deb.test.sh.
|
||||||
|
|
||||||
|
test('Linux packaging files exist in their expected separate locations', () => {
|
||||||
|
for (const f of [
|
||||||
|
'packaging/linux/debian/package.sh',
|
||||||
|
'packaging/linux/debian/control.in',
|
||||||
|
'packaging/linux/common/stepforge.desktop',
|
||||||
|
'packaging/linux/common/stepforge-mime.xml',
|
||||||
|
'packaging/linux/common/launcher.sh',
|
||||||
|
'scripts/linux/apt/install-runtime-deps.sh',
|
||||||
|
'scripts/linux/apt/install-build-deps.sh',
|
||||||
|
'docs/linux/apt.md',
|
||||||
|
'tests/integration/linux/package-deb.test.sh',
|
||||||
|
]) {
|
||||||
|
assert.ok(exists(f), `expected ${f} to exist`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the old non-production package-linux.sh is gone', () => {
|
||||||
|
assert.equal(exists('scripts/package-linux.sh'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the desktop entry is valid and app-branded', () => {
|
||||||
|
const desktop = read('packaging/linux/common/stepforge.desktop');
|
||||||
|
assert.match(desktop, /^\[Desktop Entry\]/);
|
||||||
|
assert.match(desktop, /^Type=Application$/m);
|
||||||
|
assert.match(desktop, /^Exec=stepforge %U$/m);
|
||||||
|
assert.match(desktop, /^Icon=stepforge$/m);
|
||||||
|
assert.match(desktop, /^Categories=.+;$/m);
|
||||||
|
assert.match(desktop, /MimeType=application\/x-stepforge-guide/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the control template declares runtime deps, no hardcoded arch, real maintainer slot', () => {
|
||||||
|
const control = read('packaging/linux/debian/control.in');
|
||||||
|
assert.match(control, /^Architecture: @ARCH@$/m, 'arch must be templated, not hardcoded');
|
||||||
|
assert.match(control, /^Depends:.*libnss3/m);
|
||||||
|
assert.match(control, /@VERSION@/);
|
||||||
|
assert.match(control, /@MAINTAINER@/);
|
||||||
|
// The false "fully offline" wording must not reappear here.
|
||||||
|
assert.doesNotMatch(control, /fully offline/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the launcher refuses an unsandboxed launch unless explicitly opted in', () => {
|
||||||
|
const launcher = read('packaging/linux/common/launcher.sh');
|
||||||
|
assert.match(launcher, /STEPFORGE_ALLOW_NO_SANDBOX/);
|
||||||
|
// It must not unconditionally exec with --no-sandbox.
|
||||||
|
assert.doesNotMatch(launcher, /^exec .*--no-sandbox/m);
|
||||||
|
// It never installs anything at runtime.
|
||||||
|
assert.doesNotMatch(launcher, /npm (install|ci|rebuild)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the package builder requires node_modules and guards against dev-dep leaks', () => {
|
||||||
|
const script = read('packaging/linux/debian/package.sh');
|
||||||
|
assert.match(script, /node_modules\/electron\/dist/);
|
||||||
|
assert.match(script, /npm ls --omit=dev/);
|
||||||
|
assert.match(script, /electron-builder/); // the leak guard references it
|
||||||
|
assert.match(script, /dpkg --print-architecture/); // arch detected, not hardcoded
|
||||||
|
assert.doesNotMatch(script, /cp -a "\$ROOT_DIR\/node_modules" /); // never copy the whole dev tree
|
||||||
|
});
|
||||||
|
|
||||||
|
test('apt setup scripts target apt and keep build vs runtime deps separate', () => {
|
||||||
|
const runtime = read('scripts/linux/apt/install-runtime-deps.sh');
|
||||||
|
const build = read('scripts/linux/apt/install-build-deps.sh');
|
||||||
|
assert.match(runtime, /apt-get/);
|
||||||
|
assert.match(runtime, /libnss3/);
|
||||||
|
assert.doesNotMatch(runtime, /dpkg-dev|fakeroot/, 'runtime script must not install build tools');
|
||||||
|
assert.match(build, /dpkg-dev/);
|
||||||
|
assert.match(build, /fakeroot/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an original icon set is generated (not a placeholder/third-party asset)', () => {
|
||||||
|
assert.ok(exists('packaging/assets/stepforge.svg'));
|
||||||
|
assert.ok(exists('scripts/make-icons.js'));
|
||||||
|
// The generated PNGs are committed for packaging.
|
||||||
|
for (const size of [16, 48, 256]) {
|
||||||
|
assert.ok(exists(`packaging/assets/icons/stepforge-${size}.png`), `icon ${size} missing`);
|
||||||
|
}
|
||||||
|
// Regenerate the 256px icon and confirm the generator is deterministic and
|
||||||
|
// produces a valid PNG (starts with the PNG signature).
|
||||||
|
const { renderIcon } = require('../../scripts/make-icons');
|
||||||
|
const { encodePng } = require('../../core/png');
|
||||||
|
const png = encodePng(renderIcon(256));
|
||||||
|
assert.deepEqual([...png.subarray(0, 4)], [0x89, 0x50, 0x4e, 0x47]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { detectPlatform, createWindowContextProvider, detectCapabilities } = require('../../app/platform');
|
||||||
|
const { assertWindowContextProvider, CLICK_SOURCES } = require('../../app/platform/interfaces');
|
||||||
|
const { detectLinuxCapabilities, detectSessionType } = require('../../app/platform/linux/diagnostics');
|
||||||
|
|
||||||
|
// ---- platform selection -----------------------------------------------------
|
||||||
|
|
||||||
|
test('detectPlatform maps process.platform to an adapter family', () => {
|
||||||
|
assert.equal(detectPlatform('win32'), 'windows');
|
||||||
|
assert.equal(detectPlatform('darwin'), 'darwin');
|
||||||
|
assert.equal(detectPlatform('linux'), 'linux');
|
||||||
|
assert.equal(detectPlatform('sunos'), 'unsupported');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the factory returns a valid WindowContextProvider for every OS', () => {
|
||||||
|
for (const platform of ['win32', 'darwin', 'linux', 'sunos']) {
|
||||||
|
const provider = createWindowContextProvider({ platform });
|
||||||
|
assert.doesNotThrow(() => assertWindowContextProvider(provider));
|
||||||
|
assert.equal(typeof provider.collect, 'function');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unsupported platform provider returns an empty context, never throws', async () => {
|
||||||
|
const provider = createWindowContextProvider({ platform: 'sunos' });
|
||||||
|
assert.deepEqual(await provider.collect(), { appName: '', windowTitle: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the shared code delegates window context to the injected provider', async () => {
|
||||||
|
// The text-intel service must consume the provider, not branch on platform.
|
||||||
|
const { TextIntelService } = require('../../app/text-intel');
|
||||||
|
const { makeTmpDir, rmrf } = require('./helpers');
|
||||||
|
const root = makeTmpDir('platform-ctx');
|
||||||
|
let sawPoint = null;
|
||||||
|
const service = new TextIntelService({
|
||||||
|
store: { settingsDir: root },
|
||||||
|
settings: { get: () => null },
|
||||||
|
dataDir: root,
|
||||||
|
windowContextProvider: {
|
||||||
|
async collect(osPoint) { sawPoint = osPoint; return { appName: 'TestApp', windowTitle: 'Test Window' }; },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const ctx = await service.collectForegroundWindowContext({ x: 5, y: 6 });
|
||||||
|
assert.deepEqual(ctx, { appName: 'TestApp', windowTitle: 'Test Window' });
|
||||||
|
assert.deepEqual(sawPoint, { x: 5, y: 6 });
|
||||||
|
rmrf(root);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Linux diagnostics ------------------------------------------------------
|
||||||
|
|
||||||
|
test('session type prefers XDG_SESSION_TYPE then display env', () => {
|
||||||
|
assert.equal(detectSessionType({ XDG_SESSION_TYPE: 'wayland' }), 'wayland');
|
||||||
|
assert.equal(detectSessionType({ XDG_SESSION_TYPE: 'x11' }), 'x11');
|
||||||
|
assert.equal(detectSessionType({ WAYLAND_DISPLAY: 'wayland-0' }), 'wayland');
|
||||||
|
assert.equal(detectSessionType({ DISPLAY: ':0' }), 'x11');
|
||||||
|
assert.equal(detectSessionType({}), 'unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('X11 with xinput reports marker-capable per-click capture', () => {
|
||||||
|
const caps = detectLinuxCapabilities({
|
||||||
|
env: { XDG_SESSION_TYPE: 'x11', DISPLAY: ':0', DBUS_SESSION_BUS_ADDRESS: 'unix:x' },
|
||||||
|
hasBinary: (n) => n === 'xinput' || n === 'xprop',
|
||||||
|
existsSync: () => false,
|
||||||
|
readdirSync: () => [],
|
||||||
|
});
|
||||||
|
assert.equal(caps.isWayland, false);
|
||||||
|
assert.equal(caps.clickCapture, 'x11-xinput');
|
||||||
|
assert.equal(caps.screenCapture, 'x11-direct');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Wayland without PipeWire reports an actionable message', () => {
|
||||||
|
const caps = detectLinuxCapabilities({
|
||||||
|
env: { XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0' },
|
||||||
|
hasBinary: () => false,
|
||||||
|
existsSync: () => false,
|
||||||
|
readdirSync: () => [],
|
||||||
|
});
|
||||||
|
assert.equal(caps.isWayland, true);
|
||||||
|
assert.equal(caps.screenCapture, 'wayland-portal');
|
||||||
|
assert.ok(caps.messages.some((m) => /PipeWire|portal/i.test(m)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no click source falls back to hotkey/interval with a message', () => {
|
||||||
|
const caps = detectLinuxCapabilities({
|
||||||
|
env: { XDG_SESSION_TYPE: 'x11', DISPLAY: ':0' },
|
||||||
|
hasBinary: () => false, // no xinput
|
||||||
|
existsSync: () => false,
|
||||||
|
readdirSync: () => [], // no readable input devices
|
||||||
|
});
|
||||||
|
assert.equal(caps.clickCapture, 'hotkey-or-interval-only');
|
||||||
|
assert.ok(caps.messages.some((m) => /hotkey|interval/i.test(m)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readable evdev devices enable an evdev click source', () => {
|
||||||
|
const caps = detectLinuxCapabilities({
|
||||||
|
env: { XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0' },
|
||||||
|
hasBinary: (n) => n === 'pipewire',
|
||||||
|
existsSync: () => true,
|
||||||
|
readdirSync: () => ['event0', 'event1', 'mouse0'],
|
||||||
|
});
|
||||||
|
// event0/event1 are readable (accessSync is real, but /dev/input/eventN
|
||||||
|
// likely won't exist in CI; the profile still resolves without throwing).
|
||||||
|
assert.ok(['evdev-wayland', 'hotkey-or-interval-only'].includes(caps.clickCapture));
|
||||||
|
assert.equal(caps.os, 'linux');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- capability facade ------------------------------------------------------
|
||||||
|
|
||||||
|
test('detectCapabilities returns a Linux profile with valid click source', () => {
|
||||||
|
const caps = detectCapabilities({ platform: 'linux', env: { XDG_SESSION_TYPE: 'x11', DISPLAY: ':0' } });
|
||||||
|
assert.equal(caps.os, 'linux');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectCapabilities reports windows-hook for Windows', () => {
|
||||||
|
const caps = detectCapabilities({ platform: 'win32', env: {} });
|
||||||
|
assert.equal(caps.os, 'windows');
|
||||||
|
assert.equal(caps.clickCapture, 'windows-hook');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every documented click source is a known token', () => {
|
||||||
|
for (const s of ['windows-hook', 'x11', 'evdev-x11', 'evdev-wayland', 'wayland-portal', 'hotkey', 'interval', 'unavailable']) {
|
||||||
|
assert.ok(CLICK_SOURCES.includes(s));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- refactor guard ---------------------------------------------------------
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
test('text-intel no longer branches on process.platform for window context', () => {
|
||||||
|
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'app', 'text-intel.js'), 'utf8');
|
||||||
|
assert.doesNotMatch(src, /collectWindowsWindowContext|collectMacWindowContext|collectLinuxWindowContext/);
|
||||||
|
assert.doesNotMatch(src, /process\.platform === 'win32'/);
|
||||||
|
assert.match(src, /this\.windowContext\.collect/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const zlib = require('node:zlib');
|
||||||
|
|
||||||
|
const { GuideStore } = require('../../core/store');
|
||||||
|
const { SearchIndex } = require('../../core/search');
|
||||||
|
const { unzipSync, zipSync, crc32 } = require('../../core/zip');
|
||||||
|
const { exportGuideArchive, importGuideArchive } = require('../../core/archive');
|
||||||
|
const { createSnapshot, restoreSnapshot, autoSnapshotIfDue } = require('../../core/snapshots');
|
||||||
|
const { acquireLock, releaseLock } = require('../../core/locks');
|
||||||
|
const { makeTmpDir, rmrf, TINY_PNG } = require('./helpers');
|
||||||
|
|
||||||
|
// ---- ZIP bomb / resource limits ---------------------------------------------
|
||||||
|
|
||||||
|
test('unzip rejects an archive that declares too many entries', () => {
|
||||||
|
const many = [];
|
||||||
|
for (let i = 0; i < 20; i += 1) many.push({ name: `f${i}.txt`, data: 'x' });
|
||||||
|
const buf = zipSync(many);
|
||||||
|
assert.throws(() => unzipSync(buf, { limits: { maxEntries: 5 } }), /too many entries/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unzip rejects an entry whose declared size exceeds the per-entry limit', () => {
|
||||||
|
const buf = zipSync([{ name: 'big.txt', data: Buffer.alloc(1000, 65) }]);
|
||||||
|
assert.throws(() => unzipSync(buf, { limits: { maxEntryUncompressed: 100 } }), /entry too large/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unzip caps inflation so a deflate bomb cannot exhaust memory', () => {
|
||||||
|
// A hand-built entry whose deflate stream expands far past the cap. The
|
||||||
|
// maxOutputLength guard must abort inflation rather than allocating it all.
|
||||||
|
const bomb = Buffer.alloc(10 * 1024 * 1024, 0); // 10 MiB of zeros -> tiny deflate
|
||||||
|
const raw = zlib.deflateRawSync(bomb, { level: 9 });
|
||||||
|
const name = 'bomb';
|
||||||
|
const nameBuf = Buffer.from(name);
|
||||||
|
const local = Buffer.alloc(30);
|
||||||
|
local.writeUInt32LE(0x04034b50, 0);
|
||||||
|
local.writeUInt16LE(20, 4);
|
||||||
|
local.writeUInt16LE(0x0800, 6);
|
||||||
|
local.writeUInt16LE(8, 8); // deflate
|
||||||
|
local.writeUInt32LE(crc32(bomb), 14);
|
||||||
|
local.writeUInt32LE(raw.length, 18);
|
||||||
|
local.writeUInt32LE(bomb.length, 22);
|
||||||
|
local.writeUInt16LE(nameBuf.length, 26);
|
||||||
|
const central = Buffer.alloc(46);
|
||||||
|
central.writeUInt32LE(0x02014b50, 0);
|
||||||
|
central.writeUInt16LE(20, 4);
|
||||||
|
central.writeUInt16LE(20, 6);
|
||||||
|
central.writeUInt16LE(0x0800, 8);
|
||||||
|
central.writeUInt16LE(8, 10);
|
||||||
|
central.writeUInt32LE(crc32(bomb), 16);
|
||||||
|
central.writeUInt32LE(raw.length, 20);
|
||||||
|
central.writeUInt32LE(bomb.length, 24);
|
||||||
|
central.writeUInt16LE(nameBuf.length, 28);
|
||||||
|
central.writeUInt32LE(0, 42);
|
||||||
|
const localBlock = Buffer.concat([local, nameBuf, raw]);
|
||||||
|
const centralBlock = Buffer.concat([central, nameBuf]);
|
||||||
|
const eocd = Buffer.alloc(22);
|
||||||
|
eocd.writeUInt32LE(0x06054b50, 0);
|
||||||
|
eocd.writeUInt16LE(1, 8);
|
||||||
|
eocd.writeUInt16LE(1, 10);
|
||||||
|
eocd.writeUInt32LE(centralBlock.length, 12);
|
||||||
|
eocd.writeUInt32LE(localBlock.length, 16);
|
||||||
|
const buf = Buffer.concat([localBlock, centralBlock, eocd]);
|
||||||
|
|
||||||
|
assert.throws(() => unzipSync(buf, { limits: { maxEntryUncompressed: 64 * 1024 } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- transactional archive import -------------------------------------------
|
||||||
|
|
||||||
|
test('a corrupt step aborts the import leaving no partial guide', (t) => {
|
||||||
|
const root = makeTmpDir('import-atomic');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
|
||||||
|
const guide = store.createGuide({ title: 'Src' });
|
||||||
|
store.addStep(guide.guideId, { title: 'S1' }, TINY_PNG, { width: 1, height: 1 });
|
||||||
|
const archiveFile = path.join(root, 'out.sfgz');
|
||||||
|
exportGuideArchive(store, guide.guideId, archiveFile);
|
||||||
|
|
||||||
|
// Corrupt the exported step so validation fails during import.
|
||||||
|
const { unzipSync: uz } = require('../../core/zip');
|
||||||
|
const entries = uz(fs.readFileSync(archiveFile));
|
||||||
|
const tampered = entries.map((e) => {
|
||||||
|
if (e.name.endsWith('step.json')) {
|
||||||
|
const obj = JSON.parse(e.data.toString('utf8'));
|
||||||
|
// Corrupt the image size to non-finite values — validateStep rejects
|
||||||
|
// an image step with an invalid size.
|
||||||
|
obj.image = { originalPath: 'original.png', workingPath: 'working.png', size: { width: 'x', height: null } };
|
||||||
|
return { name: e.name, data: Buffer.from(JSON.stringify(obj)) };
|
||||||
|
}
|
||||||
|
return { name: e.name, data: e.data };
|
||||||
|
});
|
||||||
|
fs.writeFileSync(archiveFile, zipSync(tampered));
|
||||||
|
|
||||||
|
const before = store.listGuides().length;
|
||||||
|
assert.throws(() => importGuideArchive(store, archiveFile, { mode: 'copy' }));
|
||||||
|
// No partial guide was left behind, and no staging dir remains.
|
||||||
|
assert.equal(store.listGuides().length, before);
|
||||||
|
const leftover = fs.readdirSync(store.guidesDir).filter((n) => n.includes('.importing'));
|
||||||
|
assert.deepEqual(leftover, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a valid archive imports cleanly', (t) => {
|
||||||
|
const root = makeTmpDir('import-ok');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const guide = store.createGuide({ title: 'Src' });
|
||||||
|
store.addStep(guide.guideId, { title: 'S1' }, TINY_PNG, { width: 1, height: 1 });
|
||||||
|
const archiveFile = path.join(root, 'out.sfgz');
|
||||||
|
exportGuideArchive(store, guide.guideId, archiveFile);
|
||||||
|
|
||||||
|
const imported = importGuideArchive(store, archiveFile, { mode: 'copy' });
|
||||||
|
assert.equal(imported.title, 'Src');
|
||||||
|
assert.equal(imported.stepsOrder.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- atomic snapshot restore ------------------------------------------------
|
||||||
|
|
||||||
|
test('restoring a corrupt snapshot never destroys the live guide', (t) => {
|
||||||
|
const root = makeTmpDir('snap-atomic');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const guide = store.createGuide({ title: 'Live' });
|
||||||
|
store.addStep(guide.guideId, { title: 'keep me' }, TINY_PNG, { width: 1, height: 1 });
|
||||||
|
const snap = createSnapshot(store, guide.guideId, { label: 'good' });
|
||||||
|
|
||||||
|
// Corrupt the snapshot zip so restore must abort.
|
||||||
|
const snapFile = path.join(store.guideDir(guide.guideId), 'history', 'snapshots', snap);
|
||||||
|
fs.writeFileSync(snapFile, Buffer.from('not a zip at all'));
|
||||||
|
|
||||||
|
assert.throws(() => restoreSnapshot(store, guide.guideId, snap), /restore aborted|invalid|zip/i);
|
||||||
|
// The live guide and its step are intact.
|
||||||
|
const after = store.getGuide(guide.guideId);
|
||||||
|
assert.equal(after.title, 'Live');
|
||||||
|
assert.equal(after.stepsOrder.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restoring a valid snapshot swaps content and keeps history', (t) => {
|
||||||
|
const root = makeTmpDir('snap-ok');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const guide = store.createGuide({ title: 'V1' });
|
||||||
|
const s1 = store.addStep(guide.guideId, { title: 'first' }, TINY_PNG, { width: 1, height: 1 });
|
||||||
|
const snap = createSnapshot(store, guide.guideId, { label: 'v1' });
|
||||||
|
|
||||||
|
// Change the guide, then restore.
|
||||||
|
store.saveGuide({ ...store.getGuide(guide.guideId), title: 'V2' });
|
||||||
|
store.deleteStep(guide.guideId, s1.stepId);
|
||||||
|
assert.equal(store.getGuide(guide.guideId).title, 'V2');
|
||||||
|
|
||||||
|
const restored = restoreSnapshot(store, guide.guideId, snap);
|
||||||
|
assert.equal(restored.title, 'V1');
|
||||||
|
assert.equal(restored.stepsOrder.length, 1);
|
||||||
|
// history/ survived the restore (pre-restore snapshot exists too).
|
||||||
|
assert.ok(fs.existsSync(path.join(store.guideDir(guide.guideId), 'history')));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- atomic locks -----------------------------------------------------------
|
||||||
|
|
||||||
|
test('another process holding a fresh lock is a conflict; release-by-token frees ours', (t) => {
|
||||||
|
const root = makeTmpDir('lock');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const { lockPathFor } = require('../../core/locks');
|
||||||
|
const target = path.join(root, 'shared.sfgz');
|
||||||
|
fs.writeFileSync(target, 'x');
|
||||||
|
|
||||||
|
// Simulate a different process already holding a fresh lock.
|
||||||
|
fs.writeFileSync(lockPathFor(target), JSON.stringify({
|
||||||
|
host: 'other-host', user: 'someone-else', pid: 999999,
|
||||||
|
token: 'their-token', acquiredAt: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const attempt = acquireLock(target);
|
||||||
|
assert.equal(attempt.acquired, false);
|
||||||
|
assert.ok(attempt.conflict);
|
||||||
|
// We must not be able to release their lock with a guessed/absent token.
|
||||||
|
assert.equal(releaseLock(target, { token: 'wrong' }), false);
|
||||||
|
|
||||||
|
// Force-steal (user confirmed), then release by our own acquisition token.
|
||||||
|
const stolen = acquireLock(target, { force: true });
|
||||||
|
assert.equal(stolen.acquired, true);
|
||||||
|
assert.equal(releaseLock(target, { lock: stolen.lock }), true);
|
||||||
|
assert.equal(acquireLock(target).acquired, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the same process can re-acquire its own lock', (t) => {
|
||||||
|
const root = makeTmpDir('lock-reacquire');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const target = path.join(root, 'shared.sfgz');
|
||||||
|
fs.writeFileSync(target, 'x');
|
||||||
|
assert.equal(acquireLock(target).acquired, true);
|
||||||
|
// Same process, second acquire: not a conflict.
|
||||||
|
assert.equal(acquireLock(target).acquired, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('force steal takes over a held lock', (t) => {
|
||||||
|
const root = makeTmpDir('lock-force');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const target = path.join(root, 'shared.sfgz');
|
||||||
|
fs.writeFileSync(target, 'x');
|
||||||
|
acquireLock(target);
|
||||||
|
const stolen = acquireLock(target, { force: true });
|
||||||
|
assert.equal(stolen.acquired, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- search reconcile -------------------------------------------------------
|
||||||
|
|
||||||
|
test('reconcile rebuilds a missing index from the store', (t) => {
|
||||||
|
const root = makeTmpDir('search-rebuild');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const guide = store.createGuide({ title: 'Password reset guide' });
|
||||||
|
store.addStep(guide.guideId, { title: 'Open admin portal' }, TINY_PNG, { width: 1, height: 1 });
|
||||||
|
|
||||||
|
// A brand-new index (nothing persisted) must recover by reconciling.
|
||||||
|
const index = new SearchIndex(store.indexDir);
|
||||||
|
const summary = index.reconcile(store);
|
||||||
|
assert.equal(summary.reindexed, 1);
|
||||||
|
assert.ok(index.search('password').length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reconcile drops entries for deleted guides and reindexes changed ones', (t) => {
|
||||||
|
const root = makeTmpDir('search-reconcile');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const g1 = store.createGuide({ title: 'alpha guide' });
|
||||||
|
const g2 = store.createGuide({ title: 'beta guide' });
|
||||||
|
const index = new SearchIndex(store.indexDir);
|
||||||
|
index.reconcile(store);
|
||||||
|
assert.ok(index.search('alpha').length > 0);
|
||||||
|
|
||||||
|
// Delete g1 out from under the index and change g2's title.
|
||||||
|
store.deleteGuide(g1.guideId);
|
||||||
|
store.saveGuide({ ...store.getGuide(g2.guideId), title: 'beta renamed gamma' });
|
||||||
|
|
||||||
|
const summary = index.reconcile(store);
|
||||||
|
assert.equal(index.search('alpha').length, 0, 'deleted guide is gone from search');
|
||||||
|
assert.ok(index.search('gamma').length > 0, 'changed guide is reindexed');
|
||||||
|
assert.equal(summary.removed, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a corrupt index file resets to a recoverable status', (t) => {
|
||||||
|
const root = makeTmpDir('search-corrupt');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
store.createGuide({ title: 'recoverable' });
|
||||||
|
fs.mkdirSync(store.indexDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(store.indexDir, 'search-index.json'), '{ corrupt json');
|
||||||
|
|
||||||
|
const index = new SearchIndex(store.indexDir);
|
||||||
|
const summary = index.reconcile(store);
|
||||||
|
assert.equal(summary.status, 'reset');
|
||||||
|
assert.ok(index.search('recoverable').length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- automatic backups ------------------------------------------------------
|
||||||
|
|
||||||
|
test('autoSnapshotIfDue takes a snapshot every N saves and prunes', (t) => {
|
||||||
|
const root = makeTmpDir('auto-backup');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const guide = store.createGuide({ title: 'G' });
|
||||||
|
const settings = {
|
||||||
|
get: (k) => ({ automatic: true, everyNSaves: 3, keepLast: 2 }[k.replace('backups.', '')] ?? ({ backups: { automatic: true, everyNSaves: 3, keepLast: 2 } }[k])),
|
||||||
|
};
|
||||||
|
// The helper reads settings.get('backups'):
|
||||||
|
const s = { get: (k) => (k === 'backups' ? { automatic: true, everyNSaves: 3, keepLast: 2 } : null) };
|
||||||
|
|
||||||
|
const dir = path.join(store.guideDir(guide.guideId), 'history', 'snapshots');
|
||||||
|
const count = () => (fs.existsSync(dir) ? fs.readdirSync(dir).filter((n) => n.endsWith('.zip')).length : 0);
|
||||||
|
|
||||||
|
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), null); // 1
|
||||||
|
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), null); // 2
|
||||||
|
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), true); // 3 -> snapshot
|
||||||
|
assert.equal(count(), 1);
|
||||||
|
autoSnapshotIfDue(store, guide.guideId, s); // 1
|
||||||
|
autoSnapshotIfDue(store, guide.guideId, s); // 2
|
||||||
|
autoSnapshotIfDue(store, guide.guideId, s); // 3 -> snapshot
|
||||||
|
assert.equal(count(), 2);
|
||||||
|
autoSnapshotIfDue(store, guide.guideId, s);
|
||||||
|
autoSnapshotIfDue(store, guide.guideId, s);
|
||||||
|
autoSnapshotIfDue(store, guide.guideId, s); // 3rd snapshot, pruned to keepLast=2
|
||||||
|
assert.equal(count(), 2, 'pruned to keepLast');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('autoSnapshotIfDue is a no-op when automatic backups are off', (t) => {
|
||||||
|
const root = makeTmpDir('auto-backup-off');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const guide = store.createGuide({ title: 'G' });
|
||||||
|
const s = { get: () => ({ automatic: false, everyNSaves: 1 }) };
|
||||||
|
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), null);
|
||||||
|
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), null);
|
||||||
|
const dir = path.join(store.guideDir(guide.guideId), 'history', 'snapshots');
|
||||||
|
assert.equal(fs.existsSync(dir) ? fs.readdirSync(dir).length : 0, 0);
|
||||||
|
});
|
||||||