Author SHA1 Message Date
Tyler a7d398ad6a fix release workflow node version
Template tests / tests (push) Failing after 40s
2026-07-07 10:18:24 -05:00
Tyler a55a7a9170 fix text box editing and zoom shortcuts 2026-07-07 10:13:55 -05:00
Tyler 8ddfe1b3e1 update ai prompt
Template tests / tests (push) Failing after 32s
2026-07-04 14:06:54 -05:00
Tyler cc724f894b Merge pull request #14 from Twest2/StepForge pr/10-wayland-honesty
Template tests / tests (push) Failing after 33s
Make Wayland capture honest and replace the broad input group with least privilege (plan PR 10)
2026-07-04 11:53:46 -05:00
TylerandClaude Fable 5 4f57cfacba Enforce LF line endings for Unix artifacts via .gitattributes
Template tests / tests (pull_request) Failing after 32s
A shell script, .desktop, .rules, or .spec file checked out with CRLF (Windows
with core.autocrlf=true) breaks when run/parsed on Linux — a
`#!/usr/bin/env bash\r` shebang is a "bad interpreter" error, and the Linux
package would ship broken scripts. Pin these (and other text) to LF, and mark
binary assets (png/gz/traineddata) so they are never converted. Also harden
the packaging/wayland structural tests to strip CR defensively.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 23:44:45 -05:00
TylerandClaude Fable 5 8c39e8db4e Make the enable-script guard robust to CRLF line endings
Template tests / tests (pull_request) Failing after 3m22s
The wayland-honesty guard stripped comments per line to check usermod is never
run, but the comment-strip regex depended on `.`/`$` behavior that breaks on
Windows CRLF checkouts (`.` does not cross `\r`), so the comment's cautionary
usermod mention was read as a command and the test failed only on windows-
latest CI. Check command position (line start, optional sudo) in multiline
mode instead — line-ending agnostic.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 23:42:45 -05:00
TylerandClaude Fable 5 2c0b8b021e Make Wayland capture honest and replace the broad input group with least privilege
Template tests / tests (pull_request) Failing after 33s
Phase 3 of the improvement plan (PR 10 of the sequence): honest Wayland
behavior and a least-privilege alternative to the broad `input` group the
audit flagged as a keylogging surface.

Least-privilege input access:
- packaging/linux/common/60-stepforge-input.rules grants the ACTIVE session
  read access to MOUSE devices only (ID_INPUT_MOUSE, excluding
  ID_INPUT_KEYBOARD) via a systemd uaccess ACL — session-scoped, device-
  scoped, and never keyboards. This replaces `usermod -aG input`, which grants
  every input device (keyboards included) to the user permanently.
- scripts/linux/enable-click-capture.sh installs it opt-in: it prints the exact
  rule, requires confirmation, and documents the security tradeoff. It never
  runs the broad-group command.

Honest trigger reporting:
- New chooseCaptureTrigger() (app/platform/linux/diagnostics.js, re-exported
  from app/platform/index.js) maps a capability profile to the real trigger:
  per-click with a marker on X11+xinput; per-click WITHOUT coordinates or a
  marker on Wayland evdev (the platform exposes no pointer position); and an
  honest hotkey/interval fallback otherwise. It never promises per-click
  capture with coordinates on Wayland.
- platform:capabilities now includes the active trigger for the machine and
  the user's fallback setting, for the diagnostics UI.

Docs:
- GETTING_STARTED_WITH_LINUX.md no longer instructs every Wayland user to join
  the `input` group; it presents that as a warning, documents the least-
  privilege script, corrects the capability table (per-click is opt-in, mice
  only; hotkey/interval is the default Wayland trigger), and points at
  Settings → Diagnostics.

Tests: trigger decisions for X11/xinput, Wayland evdev (no coordinates/marker),
Wayland fallback (interval/hotkey with an honest note), the platform facade
wiring, Windows; the udev rule (mouse-only, excludes keyboards, uaccess); the
opt-in enable script (confirms, installs the rule, never runs usermod -aG
input as a command); and docs guards. 289 unit tests pass; startup smoke and
click self-test unchanged (stream, markers 3/3, burst 8/8).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 23:39:59 -05:00
Tyler 7827ef3ad2 Merge pull request #13 from Twest2/StepForge pr/09-linux-dnf-rpm
Template tests / tests (push) Failing after 34s
Add production .rpm packaging and dnf setup; share runtime staging (plan PR 9)
2026-07-03 23:35:32 -05:00
TylerandClaude Fable 5 575d893f90 Add production .rpm packaging and dnf setup; share runtime staging
Template tests / tests (pull_request) Failing after 33s
Phase 3 of the improvement plan (PR 9 of the sequence): the dnf/Fedora
packaging half, in separate Linux-specific files, mirroring the apt/.deb work.

- packaging/linux/common/stage-runtime.sh: shared runtime-only payload staging
  extracted from the .deb builder so the two package formats never drift. It
  copies app code + a fixed Electron runtime + production npm deps (npm ls
  --omit=dev), guards against dev-dep leaks, and fails without node_modules.
  The .deb builder now delegates to it.
- packaging/linux/fedora/stepforge.spec: runtime Requires (nss, nspr, gtk3,
  …), Recommends (xinput, portal, pipewire), %license, and a %post that makes
  chrome-sandbox setuid and refreshes desktop/MIME/icon caches. No compilation
  — it packages the prebuilt BuildRoot.
- packaging/linux/fedora/package.sh: stages the shared payload, substitutes
  version/maintainer into the spec, and runs rpmbuild against the prebuilt
  BuildRoot with detected arch. Requires rpmbuild + node_modules; emits an
  .rpm + sha256.
- scripts/linux/dnf/install-runtime-deps.sh and install-build-deps.sh:
  separate runtime vs build dependency sets for dnf (runtime installs no build
  tools).
- docs/linux/dnf.md: Fedora/RHEL install + build guide, distinct from apt.md.

Tests: packaging-linux.test.js gains Fedora/dnf structural checks (spec
Requires + MPL-2.0 + %license + sandbox setup, builder shares staging +
requires rpmbuild, dnf build/runtime dep separation, shared staging never
copies the whole dev tree). tests/integration/linux/package-rpm.test.sh builds
a real .rpm and asserts runtime-only contents (honest skip when rpmbuild is
absent, as on this apt host).

Verified: 13 packaging unit tests pass; the refactored .deb builder still
produces a valid runtime-only package (integration test green); the .rpm
integration test skips cleanly where rpmbuild is unavailable.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 23:33:11 -05:00
Tyler 92146c760d Merge pull request #12 from Twest2/StepForge pr/08-linux-apt-deb
Template tests / tests (push) Failing after 32s
Add production .deb packaging, apt setup, desktop integration, and icons (plan PR 8)
2026-07-03 23:32:54 -05:00
TylerandClaude Fable 5 fedf1d24c0 Add production .deb packaging, apt setup, desktop integration, and icons
Template tests / tests (pull_request) Failing after 31s
Phase 3 of the improvement plan (PR 8 of the sequence): the apt/X11 packaging
half of Linux support, in separate Linux-specific files. Replaces the old
scripts/package-linux.sh, which the audit flagged as "not production
packaging" (it copied the whole dev node_modules — including vulnerable build
deps — plus docs/prompts/examples/audit files, hardcoded amd64, declared only
xinput, lacked desktop/icon/MIME integration, and could build without
node_modules).

Production builder (packaging/linux/debian/package.sh):
- Stages ONLY runtime files: app code, a fixed Electron runtime, and the
  production npm deps (enumerated via npm ls --omit=dev). Never copies the
  development node_modules; guards against electron-builder/app-builder-lib
  leaking in. Fails if node_modules is absent instead of shipping an unusable
  artifact.
- Detects architecture (dpkg --print-architecture, x64/arm64) rather than
  hardcoding amd64. Generates DEBIAN/control from control.in with proper
  runtime Depends, real maintainer, and homepage.
- Installs a desktop entry, hicolor icons (16–512), .sfgz/.sfglt MIME
  registration, the launcher, and the license. postinst makes chrome-sandbox
  setuid and refreshes desktop/MIME/icon caches; postrm cleans them.
- Emits a .deb, a portable tarball that now INCLUDES /usr/bin/stepforge (the
  old tarball omitted it), and a sha256 sums file.

Launcher (packaging/linux/common/launcher.sh):
- Runs sandboxed; prefers the user-namespace sandbox, accepts a root-owned
  setuid helper, and otherwise refuses to launch with an actionable message.
  --no-sandbox requires an explicit STEPFORGE_ALLOW_NO_SANDBOX opt-in. Never
  installs anything at runtime.

Setup (separate build vs runtime, apt only):
- scripts/linux/apt/install-runtime-deps.sh (Chromium/Electron libs, X11
  tools, portal/PipeWire) and install-build-deps.sh (dpkg-dev, fakeroot,
  xvfb). Runtime script installs no build tools.

Assets: original StepForge icon — packaging/assets/stepforge.svg plus a
generator (scripts/make-icons.js) that renders the PNG set with the repo's own
rasterizer/PNG writer (no third-party art). npm run icons regenerates them.

Wiring: package.json gains package:linux:deb / package:linux:rpm / icons;
build-release.sh uses the production builder and requires node_modules; README
points at the apt/dnf guides.

Tests: tests/unit/packaging-linux.test.js (structural: files present in their
separate locations, old script gone, valid desktop entry, templated arch +
runtime Depends, launcher gates --no-sandbox, builder requires node_modules
and guards dev-dep leaks, apt build/runtime dep separation, original icon set
generates a valid PNG) runs in the normal suite;
tests/integration/linux/package-deb.test.sh builds a real .deb and asserts the
right files present and the dev tree / build tooling / app docs absent
(honest skip only when dpkg-deb/node_modules are genuinely missing).

Verified locally: 276 unit tests pass; the integration test builds and
validates stepforge_0.3.2_amd64.deb; build-release E2E passes with the new
production package.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 23:26:44 -05:00
Tyler 901940993c Merge pull request #11 from Twest2/StepForge pr/07-platform-interfaces
Template tests / tests (push) Failing after 3m20s
Introduce the platform adapter layer; extract window context behind it (plan PR 7)
2026-07-03 23:19:48 -05:00
TylerandClaude Fable 5 970d76a780 Introduce the platform adapter layer; extract window context behind it
Template tests / tests (pull_request) Failing after 33s
Phase 3 groundwork of the improvement plan (PR 7 of the sequence). The Linux
work is a platform rewrite, so this first establishes the interface boundary
and moves an OS-specific piece behind it with Windows behavior preserved — no
new process.platform branches in shared code.

- app/platform/index.js is the single factory that selects a platform
  implementation; shared code asks it for adapters and never inspects
  process.platform itself.
- app/platform/interfaces.js documents the adapter contracts
  (WindowContextProvider, ClickSource, PowerPolicy) and the explicit click-
  source vocabulary.
- Extracted the foreground-window/element detection into per-OS adapters,
  verbatim from text-intel.js:
    app/platform/windows/window-context.js  (PowerShell UIAutomation)
    app/platform/linux/window-context.js     (xprop)
    app/platform/darwin/window-context.js     (AppleScript)
  text-intel.js now delegates to the injected provider and its three
  platform-branching methods (and the now-dead child_process import) are gone.
- app/platform/linux/diagnostics.js detects session type, portal/PipeWire,
  xinput, readable input devices, and the resulting click/screen-capture
  profile, returning actionable messages for the UI. Exposed via a new
  platform:capabilities IPC + preload method.

This is behavior-preserving: the Windows/macOS/Linux window-context code is
the same, just relocated behind the factory, and the capture pipeline is
untouched.

Tests: platform selection for every OS, provider validity + null-object for
unsupported OS, the shared service delegating to an injected provider, Linux
capability detection (x11/xinput, Wayland-without-PipeWire messaging, no-click
fallback, evdev), the capability facade, and a guard that text-intel no longer
branches on process.platform. 268 unit tests pass; startup smoke passes and
the click self-test is unchanged (stream source, markers 3/3, burst 8/8).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 23:17:24 -05:00
Tyler 37079304c2 Merge pull request #10 from Twest2/StepForge pr/06-recovery-hardening
Template tests / tests (push) Failing after 32s
Harden archives, snapshots, locks, and search against corruption and races (plan PR 6)
2026-07-03 23:12:13 -05:00
TylerandClaude Fable 5 8aa9756b8a Harden archives, snapshots, locks, and search against corruption and races
Template tests / tests (pull_request) Failing after 33s
Phase 1/2 of the improvement plan (PR 6 of the sequence). Recovery and
resource-limit hardening for the storage-adjacent modules.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 23:10:21 -05:00
Tyler f31f1407a5 Merge pull request #9 from Twest2/StepForge pr/04-autosave-storage
Template tests / tests (push) Failing after 5m6s
Add optimistic revisions, keep dirty state on failed saves, quarantine corrupt data (plan PR 4)
2026-07-03 22:59:51 -05:00
56 changed files with 2603 additions and 310 deletions
+24
View File
@@ -0,0 +1,24 @@
# Normalize line endings. Unix artifacts MUST stay LF: a shell script or
# desktop/udev/spec file checked out with CRLF (e.g. on Windows with
# core.autocrlf=true) fails to run on Linux — `#!/usr/bin/env bash\r` is a
# "bad interpreter" error, and .desktop/.rules parsers choke on trailing \r.
* text=auto eol=lf
*.sh text eol=lf
*.desktop text eol=lf
*.rules text eol=lf
*.spec text eol=lf
*.xml text eol=lf
control.in text eol=lf
*.js text eol=lf
*.mjs text eol=lf
*.cjs text eol=lf
*.json text eol=lf
*.md text eol=lf
launcher.sh text eol=lf
# Binary assets must never be line-ending converted.
*.png binary
*.ico binary
*.gz binary
*.traineddata binary
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 20
node-version-file: .nvmrc
cache: npm
- name: Install dependencies
+6 -1
View File
@@ -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).
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
refused on older Nodes because the packaging toolchain needs 22.12+).
+33
View File
@@ -1,3 +1,36 @@
# After action report
## Done — 10 PRs implemented, merged, and CI-green on both forges
I worked through ai_prompts/prompt4.md and shipped PRs 110 of the plan's recommended sequence. Each was a focused branch, opened on both GitHub (Twest2/StepForge #5#14) and Gitea (Tyler/autodoc #21#30), verified green on GitHub CI (Ubuntu + Windows + dependency audit), merged with the same SHA to both forges, and the Gitea mirror closed with a merge note. Final state: main is byte-identical across local, GitHub, and Gitea (cc724f8), main CI is green, no open PRs, 289 unit tests pass (0 fail).
## PR What it fixed
1 Pinned Node ≥22.12 (.nvmrc/engines); removed all runtime npm self-repair; refuse silent --no-sandbox; CI on pull_request + prod/full audits; made the click E2E stop hiding startup crashes as "SKIPPED"; remediated the form-data/undici advisories
2 Closed the renderer privilege boundary: navigation/popup denial, sandboxed windows, per-channel IPC sender+argument validation, deny-by-default permissions (display capture only for the capture worker), intent-specific shell access replacing arbitrary shell:openPath
3 Truthful local-first AI/privacy contract: raw keystroke capture off by default, AbortController timeouts + cancellation + concurrency + image-size limits, loopback-only Ollama unless explicitly opted in, honest docs + new docs/PRIVACY.md
4 Optimistic revision fields + compare-and-swap (stale AI responses can't clobber edits), autosave keeps dirty state on failed saves, corrupt guides/steps quarantined instead of silently vanishing
5 Region-capture nested-result + listener-leak bugs, single-owner power blocker, explicit click-source reporting (evdev fix), strict-mode skips instead of storing post-click shots, pathToFileURL, shutdown drain
6 ZIP-bomb limits, transactional archive import, atomic snapshot restore, exclusive locks (O_EXCL + token), search index reconcile at startup, implemented the dead automatic-backup settings
7 Platform adapter layer — window-context detection extracted to app/platform/{windows,linux,darwin}/; shared code no longer branches on process.platform; Linux capability diagnostics
8 Production .deb (runtime-only staging, no dev tree, detected arch, desktop/MIME/icons, original generated icon set), apt setup scripts, real launcher; retired the old broken packager
9 Production .rpm + dnf setup, shared stage-runtime.sh so both formats never drift
10 Honest Wayland triggers (never promises per-click coordinates it can't deliver) + a least-privilege mouse-only udev rule replacing the broad input group keylogging surface; .gitattributes so Unix scripts don't break on CRLF
I verified real behavior where possible: built and inspected an actual 107 MB .deb, ran the click self-test (markers 3/3, burst 8/8), and confirmed the sandboxed app boots.
## One blocking item I did not act on — the license (needs your decision)
The plan calls this a legal release blocker that an implementation agent must not guess, and I agree. The repo currently contradicts itself:
package.json → MPL-2.0 and CONTRIBUTING requires MPL-2.0 + DCO sign-off (permits commercial use)
docs/LICENSE + README §License → Creative Commons Attribution-NonCommercial (forbids commercial use; also not a software license)
there is no root LICENSE file
MPL-2.0 and CC-BY-NC are mutually exclusive, and contributors have been signing off under a license the project doesn't actually ship. You need to pick one, then I can reconcile the SPDX field, root LICENSE, README, CONTRIBUTING, package payloads, and the About view in a single follow-up PR.
## What remains (plan PRs 1114 — larger, several need your input)
Canonical blocks/callout-placement schema-v2 migration; lazy exports + Unicode font vendoring (needs a licensed-font choice); editor modularization + accessibility; and the signed reproducible release pipeline (needs signing keys). Also still open: the click self-test's arm/debounce scenarios fail on Linux — a pre-existing capture defect I surfaced honestly in PR 1 but didn't chase down (it needs on-device Linux capture debugging).
Tell me your license choice and I'll do the reconciliation PR; tell me which of 1114 to prioritize and I'll take it next.
# StepForge comprehensive improvement plan
This document is an implementation handoff for another coding agent. It is based on a repository-wide audit of commit `534a28e` on 2026-07-03. It is a plan, not authorization to make all changes in one unreviewable patch.
+31 -1
View File
@@ -17,7 +17,7 @@ const { buildRenderAst } = require('../core/renderast');
const { runExport, EXPORTERS } = require('../exporters');
const { runExportInWorker } = require('./export-runner');
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 CaptureService = require('./capture');
const { TextIntelService } = require('./text-intel');
@@ -72,6 +72,9 @@ function reindex(guideId) {
} catch {
// index failures must never block saves
}
// Automatic backup policy runs on the same save choke point. It is
// self-contained and never throws, so it can't affect the save either.
autoSnapshotIfDue(store, guideId, settings);
}
function orderedSteps(guideId) {
@@ -734,6 +737,13 @@ function setupIpc() {
return guide;
}, { validate: (a) => c.id(a.guideId) && c.fileName(a.name) });
// recovery status: corrupt files quarantined this session and search index
// health, so the UI can surface them instead of data silently vanishing.
h('recovery:status', () => ({
quarantined: store.getRecoveryReport(),
searchStatus: searchIndex.status,
}));
// templates
const validFormat = (v) => c.oneOf(v, FORMATS);
h('templates:list', ({ format }) => templates.list(format),
@@ -889,6 +899,15 @@ function setupIpc() {
dataDir: store.root,
platform: process.platform,
}));
// Platform capture-capability profile (session type, portal/PipeWire,
// xinput, click source, actionable messages) for the diagnostics UI, plus
// the honest active trigger for this machine and settings.
h('platform:capabilities', () => {
const platform = require('./platform');
const caps = platform.detectCapabilities();
const activeTrigger = platform.chooseCaptureTrigger(caps, settings.get('capture.fallbackTrigger') || 'interval');
return { ...caps, activeTrigger };
});
}
// ---- lifecycle --------------------------------------------------------------
@@ -916,6 +935,17 @@ if (!gotLock) {
store = new GuideStore(dataDir);
settings = new Settings(store.settingsDir);
searchIndex = new SearchIndex(store.indexDir);
// Rebuild/reconcile the index against the library at startup so a missing,
// corrupt, or version-mismatched index recovers instead of silently
// returning nothing.
try {
const summary = searchIndex.reconcile(store);
if (summary.reindexed || summary.removed || summary.status !== 'ok') {
console.log(`[stepforge] search index reconciled: ${JSON.stringify(summary)}`);
}
} catch (err) {
console.error(`[stepforge] search reconcile failed: ${err && err.message}`);
}
templates = new TemplateManager(store.templatesDir);
textIntel = new TextIntelService({
store,
+43
View File
@@ -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 };
+81
View File
@@ -0,0 +1,81 @@
'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: [],
};
}
/**
* The honest capture-trigger decision for the current capabilities. On Linux
* this defers to the diagnostics helper (which never promises per-click
* capture with coordinates on Wayland); other platforms have a fixed answer.
*/
function chooseCaptureTrigger(capabilities, userTriggerPreference = 'interval') {
if (capabilities && capabilities.os === 'linux') {
return require('./linux/diagnostics').chooseCaptureTrigger(capabilities, userTriggerPreference);
}
if (capabilities && capabilities.os === 'windows') {
return { trigger: 'click', clickSource: 'windows-hook', coordinates: true, marker: true, note: '' };
}
return { trigger: 'click', clickSource: capabilities ? capabilities.os : 'unavailable', coordinates: true, marker: true, note: '' };
}
module.exports = { detectPlatform, createWindowContextProvider, detectCapabilities, chooseCaptureTrigger };
+62
View File
@@ -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 };
+156
View File
@@ -0,0 +1,156 @@
'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,
};
}
/**
* Decide the honest capture trigger for a Linux capability profile. StepForge
* must never *promise* per-click capture with coordinates on Wayland, because
* the platform does not expose pointer position to apps. Returns the trigger,
* whether clicks carry coordinates, whether a marker can be drawn, and a
* user-facing note. `userTriggerPreference` is the capture.fallbackTrigger
* setting ('interval' | 'hotkey') used only when no click source exists.
*/
function chooseCaptureTrigger(capabilities, userTriggerPreference = 'interval') {
const caps = capabilities || {};
const click = caps.clickCapture;
if (click === 'x11-xinput') {
return {
trigger: 'click',
clickSource: 'x11',
coordinates: true,
marker: true,
note: 'Per-click capture with an accurate marker (X11 + xinput).',
};
}
if (click === 'evdev-x11') {
return {
trigger: 'click',
clickSource: 'evdev-x11',
coordinates: true,
marker: true,
note: 'Per-click capture via kernel input devices (X11, no xinput).',
};
}
if (click === 'evdev-wayland') {
// Wayland exposes button presses (via evdev, if permitted) but NOT pointer
// position, so a step is captured per click but without a marker. This is
// only reached when the user opted into the least-privilege device rule.
return {
trigger: 'click',
clickSource: 'evdev-wayland',
coordinates: false,
marker: false,
note: 'Per-click capture on Wayland has no pointer position, so no marker is drawn.',
};
}
// No global click source: the safe baseline is the user's chosen fallback.
const trigger = userTriggerPreference === 'hotkey' ? 'hotkey' : 'interval';
return {
trigger,
clickSource: trigger,
coordinates: false,
marker: false,
note: caps.isWayland
? 'Wayland does not expose global clicks; recording uses your ' + trigger + ' trigger. '
+ 'Screen sharing is requested once per recording via the portal.'
: 'No global click source available; recording uses your ' + trigger + ' trigger.',
};
}
module.exports = { detectLinuxCapabilities, detectSessionType, chooseCaptureTrigger };
+51
View File
@@ -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 };
+88
View File
@@ -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 };
+4
View File
@@ -77,6 +77,9 @@ const api = {
create: invoke('snapshots:create'),
restore: invoke('snapshots:restore'),
},
recovery: {
status: invoke('recovery:status'),
},
templates: {
list: invoke('templates:list'),
load: invoke('templates:load'),
@@ -104,6 +107,7 @@ const api = {
},
app: {
info: invoke('app:info'),
platformCapabilities: invoke('platform:capabilities'),
},
};
+5
View File
@@ -186,6 +186,11 @@ async function promptText({ title, label = 'Value', value = '', placeholder = ''
});
field.addEventListener('keydown', (e) => {
if (multiline && e.key === 'Enter') {
// Let the textarea keep the Enter key for a new line.
e.stopPropagation();
return;
}
if (!multiline && e.key === 'Enter') {
e.preventDefault();
close();
+36 -18
View File
@@ -105,6 +105,19 @@ function isEditableTarget(target) {
);
}
const ZOOM_IN_KEYS = new Set(['=', '+', 'Add']);
const ZOOM_OUT_KEYS = new Set(['-', '_', 'Subtract']);
function zoomShortcutFromEvent(e) {
if (!(e.ctrlKey || e.metaKey)) return null;
const { key, code } = e;
if (key === '0' || code === 'Digit0' || code === 'Numpad0') return 'fit';
if (ZOOM_IN_KEYS.has(key) || code === 'Equal' || code === 'NumpadAdd') return 'in';
if (ZOOM_OUT_KEYS.has(key) || code === 'Minus' || code === 'NumpadSubtract') return 'out';
return null;
}
class GuideEditor {
constructor({ root, onMetaChange = () => {}, onToast = toast, onBack = () => {} } = {}) {
this.root = root;
@@ -1182,7 +1195,6 @@ class GuideEditor {
const typeSelect = makeSelect(selected.type, [
'rect', 'oval', 'line', 'arrow', 'text', 'tooltip', 'number', 'blur', 'highlight', 'magnify', 'cursor',
].map((type) => ({ value: type, label: ANNOTATION_TYPE_LABELS[type] || type })));
const textInput = el('input', { type: 'text', value: selected.text || '', placeholder: 'Annotation text' });
const valueInput = el('input', { type: 'number', value: Number.isFinite(selected.value) ? selected.value : '', placeholder: 'Value' });
const strokeInput = el('input', { type: 'color', value: style.stroke || '#E5484D' });
const fillInput = el('input', { type: 'color', value: style.fill && style.fill !== 'transparent' ? style.fill : '#ffffff' });
@@ -1218,9 +1230,17 @@ class GuideEditor {
const fields = new Set(ANNOTATION_FIELDS[selected.type] || []);
const strokeLabel = (selected.type === 'text' || selected.type === 'number') ? 'Color' : 'Stroke';
const typeLabel = ANNOTATION_TYPE_LABELS[selected.type] || selected.type;
const textInput = fields.has('text')
? el('textarea', {
rows: Math.max(3, Math.min(8, String(selected.text || '').split('\n').length)),
placeholder: 'Annotation text',
spellcheck: true,
})
: el('input', { type: 'text', value: selected.text || '', placeholder: 'Annotation text' });
if (fields.has('text')) textInput.value = selected.text || '';
const rows = [labeledRow('Type', typeSelect)];
if (fields.has('text')) rows.push(labeledRow('Text', textInput));
if (fields.has('text')) rows.push(labeledRow('Text', textInput, { stacked: true }));
if (fields.has('value')) rows.push(labeledRow('Value', valueInput));
if (fields.has('stroke')) rows.push(labeledRow(strokeLabel, strokeInput));
if (fields.has('fill')) rows.push(labeledRow('Fill', fillInput));
@@ -2178,9 +2198,10 @@ class GuideEditor {
ann.text = value;
step.annotations = clone(step.annotations || []);
this.pendingSave = true;
await this.flushStep(step);
this.canvas.setAnnotations(step.annotations || []);
this.renderAnnotationPanel();
this.emitMeta();
await this.flushStep(step);
}
formatDescription(command, block = null) {
@@ -2227,6 +2248,18 @@ class GuideEditor {
onDocumentKeyDown(e) {
if (!this.active || !this.guide) return;
const zoomShortcut = zoomShortcutFromEvent(e);
if (zoomShortcut) {
e.preventDefault();
if (zoomShortcut === 'in') {
this.setZoom(Math.min(3, (Number(this.currentZoom) || 1) + 0.25));
} else if (zoomShortcut === 'out') {
this.setZoom(Math.max(0.25, (Number(this.currentZoom) || 1) - 0.25));
} else {
this.setZoom('fit');
}
return;
}
if ((e.ctrlKey || e.metaKey) && e.key === '/' && !e.shiftKey) {
e.preventDefault();
this.openQuickActions();
@@ -2270,21 +2303,6 @@ class GuideEditor {
if (next) this.selectStep(next.stepId);
return;
}
if ((e.ctrlKey || e.metaKey) && (e.key === '=' || e.key === '+')) {
e.preventDefault();
this.setZoom(Math.min(3, (Number(this.currentZoom) || 1) + 0.25));
return;
}
if ((e.ctrlKey || e.metaKey) && e.key === '-') {
e.preventDefault();
this.setZoom(Math.max(0.25, (Number(this.currentZoom) || 1) - 0.25));
return;
}
if ((e.ctrlKey || e.metaKey) && e.key === '0') {
e.preventDefault();
this.setZoom('fit');
return;
}
// Copy / paste the selected annotation.
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c' && this.selectedAnnotationId) {
e.preventDefault();
+7 -134
View File
@@ -2,7 +2,6 @@
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync, execFile } = require('node:child_process');
const {
DEFAULT_CAPTURE_TITLES,
@@ -23,15 +22,6 @@ const OCR_CROP = {
height: 220,
};
function hasBinary(name) {
try {
execFileSync('which', [name], { stdio: 'pipe' });
return true;
} catch {
return false;
}
}
function clamp(v, min, max) {
return Math.min(max, Math.max(min, v));
}
@@ -63,6 +53,7 @@ class TextIntelService {
dataDir,
fetchImpl = global.fetch,
screenApi = null,
windowContextProvider = null,
}) {
this.store = store;
this.settings = settings;
@@ -70,6 +61,10 @@ class TextIntelService {
this.dataDir = dataDir;
this.fetch = fetchImpl;
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.workerPromise = null;
this.workerQueue = Promise.resolve();
@@ -271,135 +266,13 @@ class TextIntelService {
async collectForegroundWindowContext(osPoint = null) {
try {
if (process.platform === 'win32') return this.collectWindowsWindowContext(osPoint);
if (process.platform === 'darwin') return this.collectMacWindowContext();
if (process.platform === 'linux') return this.collectLinuxWindowContext();
return await this.windowContext.collect(osPoint);
} catch {
// 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 }) {
const ctx = await this.buildCaptureContext({ mode, frame, clickPos, clickMeta });
+32 -8
View File
@@ -124,18 +124,40 @@ function importGuideArchive(store, file, { mode = 'copy' } = {}) {
}
function finalizeImport(store, newGuide, idMap, stepJsons, stepFiles) {
// Transactional import: validate the guide and EVERY step first, then write
// the whole guide into a temporary staging directory, and only publish it
// with a single atomic rename. Previously guide.json was written before the
// steps validated, so a bad step left a partial guide in the library.
validateGuide(newGuide);
writeJsonSync(path.join(store.guideDir(newGuide.guideId), 'guide.json'), newGuide);
const normalizedSteps = [];
for (const [stepId, { raw }] of stepJsons) {
const step = normalizeStep({ ...raw, stepId });
step.parentStepId = raw.parentStepId ? idMap.get(raw.parentStepId) || null : null;
validateStep(step);
const dir = store.stepDir(newGuide.guideId, stepId);
writeJsonSync(path.join(dir, 'step.json'), step);
for (const { name, data } of stepFiles.get(stepId) || []) {
atomicWriteFileSync(path.join(dir, name), data);
validateStep(step); // throws before anything is written on a bad step
normalizedSteps.push([stepId, step]);
}
const finalDir = store.guideDir(newGuide.guideId);
if (fs.existsSync(finalDir)) throw new Error(`guide already exists: ${newGuide.guideId}`);
const stagingDir = `${finalDir}.importing-${Date.now()}`;
fs.rmSync(stagingDir, { recursive: true, force: true });
try {
fs.mkdirSync(stagingDir, { recursive: true });
writeJsonSync(path.join(stagingDir, 'guide.json'), newGuide);
for (const [stepId, step] of normalizedSteps) {
const dir = path.join(stagingDir, 'steps', stepId);
fs.mkdirSync(dir, { recursive: true });
writeJsonSync(path.join(dir, 'step.json'), step);
for (const { name, data } of stepFiles.get(stepId) || []) {
atomicWriteFileSync(path.join(dir, name), data);
}
}
// Publish atomically. If the final dir appeared meanwhile, fail cleanly.
if (fs.existsSync(finalDir)) throw new Error(`guide already exists: ${newGuide.guideId}`);
fs.renameSync(stagingDir, finalDir);
} catch (err) {
fs.rmSync(stagingDir, { recursive: true, force: true });
throw err;
}
return store.getGuide(newGuide.guideId);
}
@@ -160,7 +182,9 @@ function saveLinkedGuide(store, guideId, { force = false } = {}) {
store.saveGuide(guide, { touch: false });
return { saved: true, path: target };
} finally {
releaseLock(target);
// Release by our acquisition token so we never remove a lock a concurrent
// force-steal replaced with theirs.
releaseLock(target, { lock: result.lock });
}
}
+56 -10
View File
@@ -20,18 +20,39 @@ function lockPathFor(archivePath) {
return path.join(dir, `${stem}.lock-sfgz`);
}
function currentHolder() {
function currentProcess() {
return { host: os.hostname(), user: os.userInfo().username, pid: process.pid };
}
function currentHolder() {
return {
...currentProcess(),
// Random per-acquisition token so two processes that happen to share
// host+user+pid space (containers, pid reuse) still compare distinctly,
// and so a steal can be detected by the previous holder.
token: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
};
}
function readLock(archivePath) {
return readJsonIfExists(lockPathFor(archivePath), null);
}
function sameHolder(a, b) {
// Process identity (host+user+pid). Used to decide whether an existing lock is
// held by *this process* (safe to re-acquire) or someone else (a conflict).
function sameProcess(a, b) {
return a && b && a.host === b.host && a.user === b.user && a.pid === b.pid;
}
// Exact-acquisition identity via the per-acquisition token. Used by release so
// a caller only removes the lock it actually took (never one a force-steal
// replaced with its own).
function sameAcquisition(existing, owner) {
if (!existing || !owner) return false;
if (owner.token) return existing.token === owner.token;
return sameProcess(existing, owner);
}
function isStale(lock, now = Date.now()) {
const t = Date.parse(lock && lock.acquiredAt);
return !Number.isFinite(t) || now - t > STALE_AFTER_MS;
@@ -44,24 +65,49 @@ function isStale(lock, now = Date.now()) {
*/
function acquireLock(archivePath, { force = false } = {}) {
const file = lockPathFor(archivePath);
const existing = readLock(archivePath);
const me = currentHolder();
if (existing && !sameHolder(existing, me) && !isStale(existing) && !force) {
const lock = { ...me, acquiredAt: nowIso() };
const payload = JSON.stringify(lock, null, 2);
// Fast path: exclusive create. Only one writer wins the O_CREAT|O_EXCL race,
// so two processes can't both believe they hold the lock (the old
// read-then-write left exactly that window open).
try {
fs.writeFileSync(file, payload, { flag: 'wx' });
return { acquired: true, lock };
} catch (err) {
if (err.code !== 'EEXIST') throw err;
}
// A lock already exists. We may take it over only if this process already
// holds it, it is stale, or the caller is force-stealing (user confirmed).
const existing = readLock(archivePath);
if (existing && !sameProcess(existing, me) && !isStale(existing) && !force) {
return { acquired: false, conflict: existing };
}
const lock = { ...me, acquiredAt: nowIso() };
fs.writeFileSync(file, JSON.stringify(lock, null, 2));
// Overwrite to claim ownership (our token now identifies the lock).
fs.writeFileSync(file, payload);
return { acquired: true, lock };
}
/** Release only if we are the holder (or force). */
function releaseLock(archivePath, { force = false } = {}) {
/**
* Release only if we are the holder (or force). Pass the `lock` (or its
* `token`) returned by acquireLock so ownership is matched by token — the
* per-acquisition token means a fresh currentHolder() would not match.
*/
function releaseLock(archivePath, { force = false, lock = null, token = null } = {}) {
const file = lockPathFor(archivePath);
const existing = readLock(archivePath);
if (!existing) return true;
if (!force && !sameHolder(existing, currentHolder())) return false;
// With no explicit lock/token, fall back to process identity (the legacy
// "release my own lock" path) rather than a fresh token that can't match.
const owner = lock || (token ? { token } : currentProcess());
if (!force && !sameAcquisition(existing, owner)) return false;
fs.rmSync(file, { force: true });
return true;
}
module.exports = { lockPathFor, readLock, acquireLock, releaseLock, isStale, STALE_AFTER_MS };
module.exports = {
lockPathFor, readLock, acquireLock, releaseLock, isStale, STALE_AFTER_MS,
sameProcess, sameAcquisition,
};
+69 -5
View File
@@ -14,7 +14,7 @@ const { blockText } = require('./blocks');
* specific step in the editor.
*/
const INDEX_VERSION = 1;
const INDEX_VERSION = 2;
function tokenize(text) {
if (!text) return [];
@@ -27,20 +27,82 @@ function tokenize(text) {
class SearchIndex {
constructor(indexDir) {
this.file = path.join(indexDir, 'search-index.json');
// Per-guide source fingerprints so a startup reconcile can tell which
// guides changed while the app was closed, without re-reading every step.
this.fingerprints = {}; // guideId -> fingerprint string
// Recovery status surfaced to the UI: 'ok' | 'reset' (missing/corrupt/
// version mismatch) | 'reconciled' (rebuilt from the store at startup).
this.status = 'ok';
const fileExisted = require('node:fs').existsSync(this.file);
const stored = readJsonIfExists(this.file, null);
if (stored && stored.version === INDEX_VERSION) {
if (stored && stored.version === INDEX_VERSION && stored.docs && typeof stored.docs === 'object') {
this.docs = stored.docs;
this.fingerprints = stored.fingerprints || {};
} else {
// Missing, corrupt, or an older index version: start empty and mark it,
// so reconcile() rebuilds from the store instead of silently staying
// blank (which made search "work" but return nothing). A file that
// existed but could not be used is a 'reset' (recovery-worthy); a
// genuinely absent index on first run is just 'ok'.
this.docs = {}; // docKey -> { guideId, stepId, title, text, updatedAt }
this.status = fileExisted ? 'reset' : 'ok';
}
}
persist() {
writeJsonSync(this.file, { version: INDEX_VERSION, docs: this.docs });
writeJsonSync(this.file, {
version: INDEX_VERSION,
docs: this.docs,
fingerprints: this.fingerprints,
});
}
static fingerprint(guide) {
return `${guide.updatedAt || ''}:${Number.isInteger(guide.revision) ? guide.revision : 0}`;
}
/**
* Reconcile the index against the store at startup: reindex guides that are
* new or changed (by fingerprint), and drop index entries for guides that no
* longer exist. Returns a summary with a recovery status for the UI.
*/
reconcile(store) {
const guides = store.listGuides();
const liveIds = new Set(guides.map((g) => g.guideId));
let reindexed = 0;
let removed = 0;
// Drop docs/fingerprints for guides that are gone.
for (const key of Object.keys(this.fingerprints)) {
if (!liveIds.has(key)) {
this.removeGuide(key, { persist: false });
delete this.fingerprints[key];
removed += 1;
}
}
for (const guide of guides) {
const fp = SearchIndex.fingerprint(guide);
const indexed = this.fingerprints[guide.guideId];
const hasDoc = Boolean(this.docs[`g:${guide.guideId}`]);
if (indexed === fp && hasDoc) continue; // unchanged
try {
this.indexGuide(guide, store.listSteps(guide.guideId), { persist: false });
reindexed += 1;
} catch {
// A single unreadable guide must not abort the whole reconcile.
}
}
this.persist();
if (this.status === 'reset' || reindexed > 0 || removed > 0) {
this.status = this.status === 'reset' ? 'reset' : 'reconciled';
}
return { status: this.status, reindexed, removed, total: guides.length };
}
/** (Re)index one guide and all of its steps. */
indexGuide(guide, stepsMap) {
indexGuide(guide, stepsMap, { persist = true } = {}) {
this.removeGuide(guide.guideId, { persist: false });
const placeholderText = Object.entries(guide.placeholders || {})
@@ -69,13 +131,15 @@ class SearchIndex {
updatedAt: guide.updatedAt,
};
}
this.persist();
this.fingerprints[guide.guideId] = SearchIndex.fingerprint(guide);
if (persist) this.persist();
}
removeGuide(guideId, { persist = true } = {}) {
for (const key of Object.keys(this.docs)) {
if (this.docs[key].guideId === guideId) delete this.docs[key];
}
delete this.fingerprints[guideId];
if (persist) this.persist();
}
+97 -9
View File
@@ -3,7 +3,8 @@
const fs = require('node:fs');
const path = require('node:path');
const { zipDirSync, extractZipSync } = require('./zip');
const { atomicWriteFileSync } = require('./util');
const { atomicWriteFileSync, readJsonSync } = require('./util');
const { validateGuide } = require('./schema');
/**
* Snapshot backups: a zip of the guide directory (excluding history/) stored
@@ -16,7 +17,11 @@ function snapshotsDir(store, guideId) {
}
function snapshotName(label) {
const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace(/-\d{3}Z$/, 'Z');
// Keep milliseconds: stripping them made two snapshots taken within the same
// second collide on filename (the second silently overwrote the first, so
// rapid automatic backups produced only one file). ms keeps names unique and
// still chronologically sortable.
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
return label ? `${stamp}-${label.replace(/[^A-Za-z0-9_-]+/g, '_')}.zip` : `${stamp}.zip`;
}
@@ -50,20 +55,103 @@ function pruneSnapshots(store, guideId, keepLast) {
/**
* Restore a snapshot: replaces the guide's current content (guide.json and
* steps/) with the snapshot's, keeping the history/ directory intact.
*
* The extraction is staged and validated BEFORE any live content is touched:
* a corrupt or truncated snapshot can no longer destroy the current guide.
* The swap itself moves the old content aside, moves the new content in, then
* deletes the old — so a failure mid-swap leaves a recoverable state.
*/
function restoreSnapshot(store, guideId, name) {
const file = path.join(snapshotsDir(store, guideId), path.basename(name));
if (!fs.existsSync(file)) throw new Error(`snapshot not found: ${name}`);
const buf = fs.readFileSync(file);
const guideDir = store.guideDir(guideId);
// Safety: snapshot the pre-restore state too, so a restore is undoable.
createSnapshot(store, guideId, { label: 'pre-restore' });
for (const entry of fs.readdirSync(guideDir)) {
if (entry === 'history') continue;
fs.rmSync(path.join(guideDir, entry), { recursive: true, force: true });
// 1. Extract + validate into a temp staging dir. Nothing live is touched yet.
const staging = `${guideDir}.restoring-${Date.now()}`;
fs.rmSync(staging, { recursive: true, force: true });
try {
fs.mkdirSync(staging, { recursive: true });
extractZipSync(buf, staging);
const guideJson = path.join(staging, 'guide.json');
if (!fs.existsSync(guideJson)) throw new Error('snapshot is missing guide.json');
validateGuide(readJsonSync(guideJson)); // throws on a corrupt snapshot
} catch (err) {
fs.rmSync(staging, { recursive: true, force: true });
throw new Error(`snapshot restore aborted (snapshot invalid): ${err.message}`);
}
extractZipSync(buf, guideDir);
// 2. Snapshot the pre-restore state so the restore is itself undoable.
createSnapshot(store, guideId, { label: 'pre-restore' });
// 3. Swap in the validated content, preserving history/. Move live content
// aside first so we can roll back if a step fails.
const backup = `${guideDir}.prev-${Date.now()}`;
const liveEntries = fs.readdirSync(guideDir).filter((e) => e !== 'history');
fs.mkdirSync(backup, { recursive: true });
try {
for (const entry of liveEntries) {
fs.renameSync(path.join(guideDir, entry), path.join(backup, entry));
}
for (const entry of fs.readdirSync(staging)) {
if (entry === 'history') continue;
fs.renameSync(path.join(staging, entry), path.join(guideDir, entry));
}
} catch (err) {
// Roll back: restore whatever we moved aside.
for (const entry of fs.readdirSync(backup)) {
const dest = path.join(guideDir, entry);
fs.rmSync(dest, { recursive: true, force: true });
fs.renameSync(path.join(backup, entry), dest);
}
fs.rmSync(backup, { recursive: true, force: true });
fs.rmSync(staging, { recursive: true, force: true });
throw err;
}
fs.rmSync(backup, { recursive: true, force: true });
fs.rmSync(staging, { recursive: true, force: true });
return store.getGuide(guideId);
}
module.exports = { createSnapshot, listSnapshots, pruneSnapshots, restoreSnapshot, snapshotsDir };
/**
* Automatic backup policy. Every guide keeps a small save counter in its
* history dir; once `everyNSaves` saves accumulate (and backups.automatic is
* on) an automatic snapshot is taken and old ones pruned to backups.keepLast.
* Returns the snapshot name when one was taken, else null. Never throws — a
* backup failure must not break the save that triggered it.
*/
function autoSnapshotIfDue(store, guideId, settings) {
try {
const backups = (settings && settings.get && settings.get('backups')) || {};
if (backups.automatic === false) return null;
const everyN = Number.isInteger(backups.everyNSaves) && backups.everyNSaves > 0 ? backups.everyNSaves : 25;
const keepLast = Number.isInteger(backups.keepLast) && backups.keepLast > 0 ? backups.keepLast : 10;
const dir = path.join(store.guideDir(guideId), 'history');
fs.mkdirSync(dir, { recursive: true });
const counterFile = path.join(dir, 'autosave-counter.json');
let count = 0;
try {
count = JSON.parse(fs.readFileSync(counterFile, 'utf8')).count || 0;
} catch { count = 0; }
count += 1;
if (count >= everyN) {
createSnapshot(store, guideId, { label: 'auto', keepLast });
count = 0;
atomicWriteFileSync(counterFile, JSON.stringify({ count }));
return true;
}
atomicWriteFileSync(counterFile, JSON.stringify({ count }));
return null;
} catch (err) {
// Best effort: report, never break the caller's save.
console.error(`[stepforge] automatic backup failed for ${guideId}: ${err && err.message}`);
return null;
}
}
module.exports = {
createSnapshot, listSnapshots, pruneSnapshots, restoreSnapshot, snapshotsDir,
autoSnapshotIfDue,
};
+44 -8
View File
@@ -121,8 +121,22 @@ function zipSync(entries, { date = new Date(2026, 0, 1) } = {}) {
return Buffer.concat([...localParts, centralBuf, eocd]);
}
/** Parse a zip buffer into [{ name, data }] with CRC verification. */
function unzipSync(buffer) {
// Resource limits for untrusted archives (share files, snapshots). These cap
// memory and disk work so a ZIP bomb can't exhaust the machine. Callers that
// build archives themselves may relax them; imports use the defaults.
const DEFAULT_UNZIP_LIMITS = {
maxEntries: 50000,
maxTotalCompressed: 1024 * 1024 * 1024, // 1 GiB of stored bytes
maxTotalUncompressed: 4 * 1024 * 1024 * 1024, // 4 GiB inflated total
maxEntryUncompressed: 512 * 1024 * 1024, // 512 MiB per entry
};
/**
* Parse a zip buffer into [{ name, data }] with CRC verification and hard
* resource limits. `limits` overrides DEFAULT_UNZIP_LIMITS.
*/
function unzipSync(buffer, { limits = {} } = {}) {
const lim = { ...DEFAULT_UNZIP_LIMITS, ...limits };
if (!Buffer.isBuffer(buffer) || buffer.length < 22) throw new Error('zip: too small');
// Find end-of-central-directory record (scan backwards over the comment).
let eocd = -1;
@@ -132,11 +146,14 @@ function unzipSync(buffer) {
}
if (eocd < 0) throw new Error('zip: end record not found');
const count = buffer.readUInt16LE(eocd + 10);
if (count > lim.maxEntries) throw new Error(`zip: too many entries (${count} > ${lim.maxEntries})`);
let pos = buffer.readUInt32LE(eocd + 16);
const entries = [];
let totalCompressed = 0;
let totalUncompressed = 0;
for (let i = 0; i < count; i++) {
if (buffer.readUInt32LE(pos) !== 0x02014b50) throw new Error('zip: bad central header');
if (pos + 46 > buffer.length || buffer.readUInt32LE(pos) !== 0x02014b50) throw new Error('zip: bad central header');
const method = buffer.readUInt16LE(pos + 10);
const crc = buffer.readUInt32LE(pos + 16);
const compSize = buffer.readUInt32LE(pos + 20);
@@ -151,17 +168,33 @@ function unzipSync(buffer) {
assertSafeEntryName(name);
if (name.endsWith('/')) continue; // directory entry
// Budget checks BEFORE allocating/inflating: the declared sizes are
// attacker-controlled, so reject oversize claims up front.
if (uncompSize > lim.maxEntryUncompressed) {
throw new Error(`zip: entry too large (${uncompSize} > ${lim.maxEntryUncompressed}): ${name}`);
}
totalCompressed += compSize;
totalUncompressed += uncompSize;
if (totalCompressed > lim.maxTotalCompressed) throw new Error('zip: total compressed size exceeds limit');
if (totalUncompressed > lim.maxTotalUncompressed) throw new Error('zip: total inflated size exceeds limit');
if (buffer.readUInt32LE(localOffset) !== 0x04034b50) throw new Error('zip: bad local header');
const lNameLen = buffer.readUInt16LE(localOffset + 26);
const lExtraLen = buffer.readUInt16LE(localOffset + 28);
const dataStart = localOffset + 30 + lNameLen + lExtraLen;
if (dataStart + compSize > buffer.length) throw new Error(`zip: entry data out of range: ${name}`);
const raw = buffer.subarray(dataStart, dataStart + compSize);
let data;
if (method === 0) data = Buffer.from(raw);
else if (method === 8) data = zlib.inflateRawSync(raw);
else throw new Error(`zip: unsupported method ${method} for ${name}`);
else if (method === 8) {
// Cap inflation so a small deflate stream can't expand to gigabytes —
// even if the declared uncompSize lied, this is the real guard.
data = zlib.inflateRawSync(raw, { maxOutputLength: lim.maxEntryUncompressed });
} else throw new Error(`zip: unsupported method ${method} for ${name}`);
// Exact length match (not "at least"): the inflated bytes must equal the
// declared uncompressed size, and the CRC must verify.
if (data.length !== uncompSize) throw new Error(`zip: size mismatch for ${name}`);
if (crc32(data) !== crc) throw new Error(`zip: CRC mismatch for ${name}`);
entries.push({ name, data });
@@ -170,10 +203,10 @@ function unzipSync(buffer) {
}
/** Extract a zip buffer under destDir; every path is traversal-checked. */
function extractZipSync(buffer, destDir) {
function extractZipSync(buffer, destDir, { limits = {} } = {}) {
const resolvedDest = path.resolve(destDir);
const written = [];
for (const { name, data } of unzipSync(buffer)) {
for (const { name, data } of unzipSync(buffer, { limits })) {
const target = path.resolve(resolvedDest, name);
if (target !== resolvedDest && !target.startsWith(resolvedDest + path.sep)) {
throw new Error(`zip: entry escapes destination: ${name}`);
@@ -203,4 +236,7 @@ function zipDirSync(dir, { filter = () => true, prefix = '' } = {}) {
return zipSync(entries);
}
module.exports = { crc32, zipSync, unzipSync, extractZipSync, zipDirSync, assertSafeEntryName };
module.exports = {
crc32, zipSync, unzipSync, extractZipSync, zipDirSync, assertSafeEntryName,
DEFAULT_UNZIP_LIMITS,
};
+34 -22
View File
@@ -13,10 +13,11 @@ difference, how to get the best experience, and how to enable per-click capture.
| | **X11 / "Ubuntu on Xorg"** | **Wayland (default on Ubuntu)** |
|---|---|---|
| Screenshot per click | ✅ Yes | ✅ Yes (needs `input` group) |
| Screenshot per click | ✅ Yes | ⚙️ Optional (least-privilege mouse rule) |
| Red circle on the click | ✅ Yes | ❌ No (Wayland hides the cursor position) |
| "Share your screen" prompt | Never | Once per recording session |
| Setup needed | None | Add yourself to the `input` group |
| Default trigger | Per click | Global hotkey or timed interval |
| Setup needed | None | None (per-click is opt-in, mice only) |
**If you want the full Windows-like experience (click capture *with* the red
marker), use an Xorg session — see [Option A](#option-a-best-experience--use-xorg).**
@@ -67,27 +68,30 @@ stream stays open until you stop recording.
> If you never see steps appear, make sure you actually picked a screen and
> clicked **Share** in that dialog.
### Per-click capture (requires the `input` group)
### Per-click capture (optional, least-privilege)
By default on Wayland, StepForge cannot see your clicks, so it falls back to
**capturing a screenshot every few seconds** (timed capture).
By default on Wayland, StepForge cannot see your clicks, so it uses a **global
hotkey or a timed interval** to capture (see below). This is the recommended,
no-extra-permissions path.
To get a screenshot **on every click** instead, give your user read access to
the mouse devices by joining the `input` group:
If you want a screenshot **on every click**, you can grant StepForge read
access to your **mouse** devices. Do **not** use `sudo usermod -aG input`:
joining the `input` group grants your user access to *all* input devices —
**including keyboards** — permanently, on every session. That is a keylogging
surface StepForge does not need.
Instead, install the least-privilege udev rule, which grants your active
session read access to **mouse devices only** (never keyboards), scoped to
whoever is physically logged in:
```bash
sudo usermod -aG input "$USER"
bash scripts/linux/enable-click-capture.sh
```
Then **log out and log back in** (group membership only applies to new sessions).
Verify it took effect:
```bash
groups | tr ' ' '\n' | grep input # should print: input
```
Now StepForge reads mouse buttons directly from the kernel (`/dev/input`) and
captures a screenshot on each click.
It shows you the exact rule and asks for confirmation before installing. Under
the hood it uses a systemd `uaccess` ACL restricted to `ID_INPUT_MOUSE`
devices — see [packaging/linux/common/60-stepforge-input.rules](../packaging/linux/common/60-stepforge-input.rules).
Re-log in (or replug a USB mouse) for it to apply.
> **No red marker on Wayland.** Even with per-click capture working, Wayland
> does not tell apps *where* the pointer is, so StepForge cannot draw the circle
@@ -114,10 +118,16 @@ On launch StepForge chooses the best available click source:
1. **Windows** — low-level mouse hook (position + timing).
2. **X11**`xinput` (position + timing → full red marker).
3. **Linux evdev** (`/dev/input`) — button presses on X11 *and* Wayland, no
position on Wayland. Used when `xinput` can't see clicks (i.e. Wayland), if
you're in the `input` group.
4. **Timed capture** — the always-works fallback (a screenshot every N seconds)
when no click source is available.
position on Wayland. Used when `xinput` can't see clicks (i.e. Wayland) and
only if you opted into the least-privilege mouse rule
(`scripts/linux/enable-click-capture.sh`).
4. **Hotkey / timed capture** — the always-works fallback (the Capture hotkey,
or a screenshot every N seconds) when no click source is available. On
Wayland this is the default, and StepForge reports it honestly instead of
pretending clicks are captured.
Open **Settings → Diagnostics** to see the detected session type, portal/
PipeWire status, and the active capture trigger for your machine.
Screen frames come from a single long-lived capture stream per recording, so
clicks/timer ticks never re-open the screen-share dialog.
@@ -149,7 +159,9 @@ STEPFORGE_CAPTURE_LOG=1 npm start
- `[stepforge] screen-capture stream active …` — the stream is up.
- `[stepforge] per-click capture via evdev on N device(s) …` — clicks are wired up.
- `[stepforge] no readable mouse input devices …`you need the `input` group (see above).
- `[stepforge] no readable mouse input devices …`per-click capture is not
enabled; run `scripts/linux/enable-click-capture.sh` for the least-privilege
mouse rule, or just use the hotkey/interval trigger.
**Harmless console noise.** Lines like `vaInitialize failed`, `Frame latency is
negative`, and `StatusNotifierItem … already exported` come from Chromium/GNOME,
+67
View File
@@ -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.
+63
View File
@@ -0,0 +1,63 @@
# StepForge on dnf-based Linux (Fedora / RHEL)
This is the setup and packaging guide for **dnf-based** distributions. Debian,
Ubuntu, and other apt-based systems have a separate guide:
[apt.md](apt.md).
## Install from the .rpm
```bash
sudo dnf install ./stepforge-<version>-1.<arch>.rpm
```
dnf pulls the required runtime libraries automatically (they are declared as
`Requires`). 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 `%post` also makes
the setuid `chrome-sandbox` helper usable as a fallback. StepForge will **not**
silently launch unsandboxed.
## Install from the portable tarball
The portable tarball (same one shipped for apt systems) includes the
`/usr/bin/stepforge` launcher. Install the runtime libraries first:
```bash
bash scripts/linux/dnf/install-runtime-deps.sh
tar -xzf stepforge_<version>_linux-x64.tar.gz
./usr/bin/stepforge
```
## Capture capabilities on dnf systems
- **X11**: full per-click capture with an accurate marker (needs `xinput`).
- **Wayland** (Fedora's default): 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.
Open Settings → Diagnostics in the app to see the detected session type,
portal/PipeWire status, and the active capture profile.
## Build the .rpm yourself
```bash
bash scripts/linux/dnf/install-build-deps.sh # rpm-build, rpmdevtools, Xvfb, …
nvm install && nvm use # pinned Node 22 (see .nvmrc)
npm ci
npm run package:linux:rpm # -> build/artifacts/*.rpm + sha256
```
The builder stages **only** runtime files (shared with the `.deb` builder via
`packaging/linux/common/stage-runtime.sh`): the app code, a fixed Electron
runtime, and production npm dependencies. It never copies the development
`node_modules` and fails if `node_modules` is missing.
+3
View File
@@ -14,7 +14,10 @@
"start": "node scripts/start-electron.js",
"test": "node scripts/run-unit-tests.js",
"sample": "node scripts/make-sample-guide.js",
"icons": "node scripts/make-icons.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",
"verify": "bash scripts/verify.sh",
"bootstrap": "bash scripts/bootstrap-offline.sh"
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+23
View File
@@ -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,19 @@
# StepForge least-privilege input access (OPTIONAL, opt-in).
#
# Grants the user at the ACTIVE local session read/write access to MOUSE input
# devices only, via systemd-logind's `uaccess` ACL. This is the least-privilege
# alternative to joining the broad `input` group, which would grant access to
# ALL input devices — including keyboards — for the user permanently, on every
# session. StepForge never needs keystrokes, so this rule deliberately EXCLUDES
# keyboards.
#
# Scope of what this grants:
# * only devices udev classifies as a mouse (ID_INPUT_MOUSE=1),
# * only when they are NOT also a keyboard (ID_INPUT_KEYBOARD!=1),
# * only to whoever is logged in at the physical seat (uaccess is
# session-scoped, not a permanent group membership).
#
# StepForge uses this only for the optional Wayland per-click *trigger* (button
# presses, no coordinates). It is not required — the safe default is a global
# hotkey or interval capture.
SUBSYSTEM=="input", KERNEL=="event*", ENV{ID_INPUT_MOUSE}=="1", ENV{ID_INPUT_KEYBOARD}!="1", TAG+="uaccess"
+71
View File
@@ -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
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Shared staging for the Linux packages. Populates $STAGE_ROOT with the FHS
# layout common to the .deb and .rpm: a pruned runtime-only /opt/stepforge, the
# launcher, desktop entry, icons, MIME registration, and the license.
#
# Distro-specific metadata (Depends vs Requires) and the packaging step
# (dpkg-deb vs rpmbuild) stay in the per-distro builders. This file only
# assembles the payload so the two never drift.
#
# Usage: ROOT_DIR=<repo> STAGE_ROOT=<dir> bash stage-runtime.sh
set -euo pipefail
: "${ROOT_DIR:?ROOT_DIR must be set}"
: "${STAGE_ROOT:?STAGE_ROOT must be set}"
# A packaged app must contain a fixed runtime; never install at build time 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
APP_DIR="$STAGE_ROOT/opt/stepforge"
mkdir -p "$APP_DIR/node_modules" \
"$STAGE_ROOT/usr/bin" \
"$STAGE_ROOT/usr/share/applications" \
"$STAGE_ROOT/usr/share/mime/packages"
# --- 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):
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
[ -d "$dep" ] || continue
mkdir -p "$APP_DIR/$(dirname "$rel")"
cp -a "$dep" "$APP_DIR/$rel"
done < <(cd "$ROOT_DIR" && 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" "$STAGE_ROOT/usr/bin/stepforge"
# --- desktop entry, icons, MIME ---------------------------------------------
install -m 0644 "$ROOT_DIR/packaging/linux/common/stepforge.desktop" "$STAGE_ROOT/usr/share/applications/stepforge.desktop"
install -m 0644 "$ROOT_DIR/packaging/linux/common/stepforge-mime.xml" "$STAGE_ROOT/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="$STAGE_ROOT/usr/share/icons/hicolor/${size}x${size}/apps"
mkdir -p "$dest"
install -m 0644 "$icon" "$dest/stepforge.png"
done
+13
View File
@@ -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>
+13
View File
@@ -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;
+17
View File
@@ -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.
+97
View File
@@ -0,0 +1,97 @@
#!/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
WORK_DIR="$(mktemp -d "${OUT_DIR%/}/.deb.XXXXXX")"
trap 'rm -rf "$WORK_DIR"' EXIT
# Stage the shared runtime-only payload (fails if node_modules is missing).
ROOT_DIR="$ROOT_DIR" STAGE_ROOT="$WORK_DIR" bash "$ROOT_DIR/packaging/linux/common/stage-runtime.sh"
mkdir -p "$WORK_DIR/DEBIAN" "$WORK_DIR/usr/share/doc/stepforge"
# --- 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"
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Build a production StepForge .rpm from a pruned, runtime-only tree, mirroring
# the .deb builder. Stages the shared payload via common/stage-runtime.sh, then
# packages it with rpmbuild against a prebuilt BuildRoot (no compilation).
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"
if ! command -v rpmbuild >/dev/null 2>&1; then
echo "error: rpmbuild is not installed. Run scripts/linux/dnf/install-build-deps.sh" >&2
exit 1
fi
# RPM arch label from the host.
RPM_ARCH="$(rpm --eval '%{_arch}' 2>/dev/null || uname -m)"
BUILD_ROOT="$(mktemp -d "${OUT_DIR%/}/.rpm.XXXXXX")"
trap 'rm -rf "$BUILD_ROOT"' EXIT
STAGE="$BUILD_ROOT/buildroot"
mkdir -p "$STAGE"
# Shared runtime-only payload (fails if node_modules is missing).
ROOT_DIR="$ROOT_DIR" STAGE_ROOT="$STAGE" bash "$ROOT_DIR/packaging/linux/common/stage-runtime.sh"
# License into the RPM's conventional location.
mkdir -p "$STAGE/usr/share/licenses/stepforge"
if [ -f "$ROOT_DIR/LICENSE" ]; then
install -m 0644 "$ROOT_DIR/LICENSE" "$STAGE/usr/share/licenses/stepforge/LICENSE"
elif [ -f "$ROOT_DIR/docs/LICENSE" ]; then
install -m 0644 "$ROOT_DIR/docs/LICENSE" "$STAGE/usr/share/licenses/stepforge/LICENSE"
else
# rpmbuild %license requires the file to exist; write a pointer if absent.
echo "See project LICENSE." > "$STAGE/usr/share/licenses/stepforge/LICENSE"
fi
# Materialize the spec with version/maintainer substituted.
SPEC="$BUILD_ROOT/stepforge.spec"
sed -e "s/@VERSION@/$VERSION/" -e "s#@MAINTAINER@#$MAINTAINER#" \
"$ROOT_DIR/packaging/linux/fedora/stepforge.spec" > "$SPEC"
rpmbuild -bb \
--define "_topdir $BUILD_ROOT/rpmbuild" \
--define "_rpmdir $OUT_DIR" \
--define "_build_id_links none" \
--buildroot "$STAGE" \
--target "$RPM_ARCH" \
"$SPEC" >/dev/null
# rpmbuild writes to $OUT_DIR/<arch>/<name>.rpm — surface the final path.
RPM_FILE="$(find "$OUT_DIR" -name "stepforge-${VERSION}-1*.${RPM_ARCH}.rpm" -newer "$SPEC" | head -1)"
if [ -z "$RPM_FILE" ]; then
RPM_FILE="$(find "$OUT_DIR" -name "stepforge-${VERSION}-1*.rpm" | head -1)"
fi
[ -n "$RPM_FILE" ] || { echo "error: rpmbuild did not produce an .rpm" >&2; exit 1; }
# Checksum.
( cd "$(dirname "$RPM_FILE")" && sha256sum "$(basename "$RPM_FILE")" > "$(basename "$RPM_FILE").sha256" )
echo "$RPM_FILE"
+69
View File
@@ -0,0 +1,69 @@
# StepForge RPM spec. The payload is prebuilt into a staging BuildRoot by
# packaging/linux/fedora/package.sh (which stages a pruned runtime tree), so
# this spec only packages and declares metadata — it does not compile.
#
# Placeholders @VERSION@ / @MAINTAINER@ are substituted by package.sh.
%global debug_package %{nil}
%global __brp_check_rpaths %{nil}
%define _build_id_links none
Name: stepforge
Version: @VERSION@
Release: 1%{?dist}
Summary: Local-first step-by-step guide capture and export tool
License: MPL-2.0
URL: https://github.com/Twest2/StepForge
# Runtime shared libraries (Chromium/Electron) + capture integration.
Requires: nss
Requires: nspr
Requires: atk
Requires: at-spi2-atk
Requires: cups-libs
Requires: gtk3
Requires: mesa-libgbm
Requires: alsa-lib
Requires: libxkbcommon
Recommends: xinput
Recommends: xdg-desktop-portal
Recommends: pipewire
# The payload is architecture-specific (bundles the Electron binary).
%description
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.
%files
/opt/stepforge
/usr/bin/stepforge
/usr/share/applications/stepforge.desktop
/usr/share/mime/packages/stepforge.xml
/usr/share/icons/hicolor/*/apps/stepforge.png
%license /usr/share/licenses/stepforge/LICENSE
%post
# 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
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
%postun
if [ "$1" = 0 ]; 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
%changelog
* Fri Jul 03 2026 @MAINTAINER@ - @VERSION@-1
- Production runtime-only package (pruned tree, fixed Electron runtime).
+8 -1
View File
@@ -14,7 +14,14 @@ mkdir -p "$BUILD_ROOT"
bash "$ROOT_DIR/scripts/bootstrap-offline.sh"
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" \
ARTIFACT_DIR="$ARTIFACT_DIR" \
+32
View File
@@ -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
+31
View File
@@ -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."
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Install the BUILD toolchain for producing StepForge packages on dnf-based
# systems (Fedora/RHEL). For DEVELOPERS/packagers only; never shipped inside
# the end-user package.
set -euo pipefail
if ! command -v dnf >/dev/null 2>&1; then
echo "This script is for dnf-based systems (Fedora/RHEL)." >&2
exit 1
fi
SUDO=""
if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
PACKAGES=(
rpm-build rpmdevtools # build the .rpm
desktop-file-utils # validate the .desktop entry
ca-certificates # npm ci over https
xorg-x11-server-Xvfb # headless smoke test under Xvfb
)
echo "Installing StepForge build dependencies via dnf..."
$SUDO dnf install -y "${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:rpm
MSG
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Install the RUNTIME libraries StepForge needs on dnf-based systems (Fedora,
# RHEL/derivatives). These are the shared libraries the packaged Electron
# runtime links against, plus the X11/portal integration used for capture.
# For END USERS installing from the tarball; the .rpm declares the same set as
# Requires so dnf pulls them automatically.
set -euo pipefail
if ! command -v dnf >/dev/null 2>&1; then
echo "This script is for dnf-based systems (Fedora/RHEL). Use the apt script on Debian/Ubuntu." >&2
exit 1
fi
SUDO=""
if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
PACKAGES=(
# Chromium/Electron shared libraries
nss nspr atk at-spi2-atk at-spi2-core cups-libs libdrm
gtk3 mesa-libgbm alsa-lib libxkbcommon
libXcomposite libXdamage libXfixes libXrandr libxshmfence
# X11 per-click capture (marker-accurate) — X11 sessions only
xorg-x11-server-utils xinput
# Wayland screen-share via the XDG portal + PipeWire
xdg-desktop-portal pipewire
)
echo "Installing StepForge runtime dependencies via dnf..."
# Some package names differ across Fedora releases; install best-effort so one
# missing optional name doesn't abort the whole set.
$SUDO dnf install -y "${PACKAGES[@]}" || {
echo "Some packages were unavailable; retrying individually..." >&2
for pkg in "${PACKAGES[@]}"; do $SUDO dnf install -y "$pkg" || echo " (skipped: $pkg)" >&2; done
}
echo "Done. StepForge runtime dependencies are installed."
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# OPTIONAL: enable per-click capture on Wayland (or X11 without xinput) using a
# LEAST-PRIVILEGE udev rule instead of the broad `input` group.
#
# Security tradeoff (read before running):
# * This grants your ACTIVE local session read access to MOUSE devices only.
# * It deliberately EXCLUDES keyboards — StepForge never needs keystrokes.
# * Access is session-scoped (systemd `uaccess` ACL), not a permanent group.
# * It is NOT required: the safe default is a global hotkey or interval
# capture. Only enable this if you want a screenshot on every click.
#
# Compare to `sudo usermod -aG input "$USER"`, which grants access to ALL input
# devices (including keyboards) for your user on every session — a much larger
# surface. This script does not do that.
set -euo pipefail
RULE_SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/packaging/linux/common/60-stepforge-input.rules"
RULE_DEST="/etc/udev/rules.d/60-stepforge-input.rules"
if [ ! -f "$RULE_SRC" ]; then
echo "error: rule file not found at $RULE_SRC" >&2
exit 1
fi
echo "This installs a least-privilege udev rule granting your session read"
echo "access to MOUSE devices only (never keyboards):"
echo
sed 's/^/ /' "$RULE_SRC"
echo
printf 'Install it to %s? [y/N] ' "$RULE_DEST"
read -r reply
case "$reply" in
y|Y|yes|YES) ;;
*) echo "Aborted. No changes made."; exit 0 ;;
esac
SUDO=""
if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
$SUDO install -m 0644 "$RULE_SRC" "$RULE_DEST"
$SUDO udevadm control --reload-rules
$SUDO udevadm trigger --subsystem-match=input --action=change || true
cat <<'MSG'
Installed. You may need to unplug/replug a USB mouse or re-log in for the ACL
to apply to already-connected devices.
To remove it later:
sudo rm /etc/udev/rules.d/60-stepforge-input.rules
sudo udevadm control --reload-rules
MSG
+69
View File
@@ -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 };
-92
View File
@@ -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,49 @@
#!/usr/bin/env bash
# Integration test: build the production .rpm and assert it is a real,
# runtime-only package. Honest skip policy: skip ONLY when the prerequisites
# are genuinely absent (rpmbuild 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 rpmbuild >/dev/null 2>&1; then
echo "package-rpm SKIPPED: rpmbuild not installed (not a dnf-based build host)"
exit 0
fi
if [ ! -d "$ROOT_DIR/node_modules/electron/dist" ]; then
echo "package-rpm SKIPPED: node_modules/electron missing (run npm ci first)"
exit 0
fi
OUT_DIR="$(mktemp -d)"
trap 'rm -rf "$OUT_DIR"' EXIT
RPM="$(STEPFORGE_PACKAGE_DIR="$OUT_DIR" bash packaging/linux/fedora/package.sh | tail -1)"
[ -f "$RPM" ] || { echo "package-rpm FAILED: builder produced no .rpm" >&2; exit 1; }
fail() { echo "package-rpm FAILED: $1" >&2; exit 1; }
listing="$(rpm -qlp "$RPM" 2>/dev/null)"
for needle in \
'/usr/bin/stepforge' \
'/usr/share/applications/stepforge.desktop' \
'/usr/share/mime/packages/stepforge.xml' \
'/opt/stepforge/node_modules/electron/dist/electron' \
'/opt/stepforge/app/main.js'; do
echo "$listing" | grep -qF "$needle" || fail "missing packaged file: $needle"
done
echo "$listing" | grep -q '/usr/share/icons/hicolor/256x256/apps/stepforge.png' || fail "missing 256px icon"
# No dev tree / build tooling / app docs.
for banned in 'electron-builder' '/opt/stepforge/docs/' '/opt/stepforge/ai_prompts/' '/opt/stepforge/examples/'; do
echo "$listing" | grep -qF "$banned" && fail "unexpected payload: $banned" || true
done
# Metadata sanity.
rpm -qip "$RPM" 2>/dev/null | grep -q '^Name *: stepforge' || fail "rpm Name is not stepforge"
rpm -qp --requires "$RPM" 2>/dev/null | grep -q '^nss' || fail "rpm does not Require nss"
echo "package-rpm OK ($(basename "$RPM"))"
+145
View File
@@ -0,0 +1,145 @@
'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, '..', '..');
// Strip CR so /^...$/m assertions are robust to CRLF checkouts on Windows CI.
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8').replace(/\r\n/g, '\n');
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('Fedora/dnf packaging files exist in their own separate locations', () => {
for (const f of [
'packaging/linux/fedora/package.sh',
'packaging/linux/fedora/stepforge.spec',
'packaging/linux/common/stage-runtime.sh',
'scripts/linux/dnf/install-runtime-deps.sh',
'scripts/linux/dnf/install-build-deps.sh',
'docs/linux/dnf.md',
'tests/integration/linux/package-rpm.test.sh',
]) {
assert.ok(exists(f), `expected ${f} to exist`);
}
});
test('the RPM spec declares runtime Requires, license, and MPL-2.0', () => {
const spec = read('packaging/linux/fedora/stepforge.spec');
assert.match(spec, /^License:\s+MPL-2\.0$/m);
assert.match(spec, /^Requires:\s+nss$/m);
assert.match(spec, /%license/);
assert.match(spec, /chrome-sandbox/); // %post makes the sandbox helper setuid
assert.match(spec, /@VERSION@/);
assert.doesNotMatch(spec, /fully offline/i);
});
test('the rpm builder shares staging and requires rpmbuild + node_modules', () => {
const script = read('packaging/linux/fedora/package.sh');
assert.match(script, /stage-runtime\.sh/);
assert.match(script, /rpmbuild/);
assert.match(script, /rpm --eval '%\{_arch\}'|uname -m/); // arch detected
assert.doesNotMatch(script, /cp -a "\$ROOT_DIR\/node_modules" /);
});
test('dnf setup scripts target dnf and keep build vs runtime deps separate', () => {
const runtime = read('scripts/linux/dnf/install-runtime-deps.sh');
const build = read('scripts/linux/dnf/install-build-deps.sh');
assert.match(runtime, /dnf install/);
assert.match(runtime, /nss/);
assert.doesNotMatch(runtime, /rpm-build|rpmdevtools/, 'runtime script must not install build tools');
assert.match(build, /rpm-build/);
});
test('the shared staging never copies the whole dev node_modules', () => {
const staging = read('packaging/linux/common/stage-runtime.sh');
assert.match(staging, /npm ls --omit=dev/);
assert.match(staging, /electron-builder/); // leak guard
assert.doesNotMatch(staging, /cp -a "\$ROOT_DIR\/node_modules" "\$APP_DIR/);
assert.match(staging, /node_modules\/electron\/dist/); // requires the runtime
});
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 deb builder detects arch and delegates to shared staging', () => {
const script = read('packaging/linux/debian/package.sh');
assert.match(script, /stage-runtime\.sh/); // runtime-only staging is shared
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]);
});
+139
View File
@@ -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/);
});
+299
View File
@@ -0,0 +1,299 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const zlib = require('node:zlib');
const { GuideStore } = require('../../core/store');
const { SearchIndex } = require('../../core/search');
const { unzipSync, zipSync, crc32 } = require('../../core/zip');
const { exportGuideArchive, importGuideArchive } = require('../../core/archive');
const { createSnapshot, restoreSnapshot, autoSnapshotIfDue } = require('../../core/snapshots');
const { acquireLock, releaseLock } = require('../../core/locks');
const { makeTmpDir, rmrf, TINY_PNG } = require('./helpers');
// ---- ZIP bomb / resource limits ---------------------------------------------
test('unzip rejects an archive that declares too many entries', () => {
const many = [];
for (let i = 0; i < 20; i += 1) many.push({ name: `f${i}.txt`, data: 'x' });
const buf = zipSync(many);
assert.throws(() => unzipSync(buf, { limits: { maxEntries: 5 } }), /too many entries/);
});
test('unzip rejects an entry whose declared size exceeds the per-entry limit', () => {
const buf = zipSync([{ name: 'big.txt', data: Buffer.alloc(1000, 65) }]);
assert.throws(() => unzipSync(buf, { limits: { maxEntryUncompressed: 100 } }), /entry too large/);
});
test('unzip caps inflation so a deflate bomb cannot exhaust memory', () => {
// A hand-built entry whose deflate stream expands far past the cap. The
// maxOutputLength guard must abort inflation rather than allocating it all.
const bomb = Buffer.alloc(10 * 1024 * 1024, 0); // 10 MiB of zeros -> tiny deflate
const raw = zlib.deflateRawSync(bomb, { level: 9 });
const name = 'bomb';
const nameBuf = Buffer.from(name);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0x0800, 6);
local.writeUInt16LE(8, 8); // deflate
local.writeUInt32LE(crc32(bomb), 14);
local.writeUInt32LE(raw.length, 18);
local.writeUInt32LE(bomb.length, 22);
local.writeUInt16LE(nameBuf.length, 26);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(20, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(0x0800, 8);
central.writeUInt16LE(8, 10);
central.writeUInt32LE(crc32(bomb), 16);
central.writeUInt32LE(raw.length, 20);
central.writeUInt32LE(bomb.length, 24);
central.writeUInt16LE(nameBuf.length, 28);
central.writeUInt32LE(0, 42);
const localBlock = Buffer.concat([local, nameBuf, raw]);
const centralBlock = Buffer.concat([central, nameBuf]);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(1, 8);
eocd.writeUInt16LE(1, 10);
eocd.writeUInt32LE(centralBlock.length, 12);
eocd.writeUInt32LE(localBlock.length, 16);
const buf = Buffer.concat([localBlock, centralBlock, eocd]);
assert.throws(() => unzipSync(buf, { limits: { maxEntryUncompressed: 64 * 1024 } }));
});
// ---- transactional archive import -------------------------------------------
test('a corrupt step aborts the import leaving no partial guide', (t) => {
const root = makeTmpDir('import-atomic');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const guide = store.createGuide({ title: 'Src' });
store.addStep(guide.guideId, { title: 'S1' }, TINY_PNG, { width: 1, height: 1 });
const archiveFile = path.join(root, 'out.sfgz');
exportGuideArchive(store, guide.guideId, archiveFile);
// Corrupt the exported step so validation fails during import.
const { unzipSync: uz } = require('../../core/zip');
const entries = uz(fs.readFileSync(archiveFile));
const tampered = entries.map((e) => {
if (e.name.endsWith('step.json')) {
const obj = JSON.parse(e.data.toString('utf8'));
// Corrupt the image size to non-finite values — validateStep rejects
// an image step with an invalid size.
obj.image = { originalPath: 'original.png', workingPath: 'working.png', size: { width: 'x', height: null } };
return { name: e.name, data: Buffer.from(JSON.stringify(obj)) };
}
return { name: e.name, data: e.data };
});
fs.writeFileSync(archiveFile, zipSync(tampered));
const before = store.listGuides().length;
assert.throws(() => importGuideArchive(store, archiveFile, { mode: 'copy' }));
// No partial guide was left behind, and no staging dir remains.
assert.equal(store.listGuides().length, before);
const leftover = fs.readdirSync(store.guidesDir).filter((n) => n.includes('.importing'));
assert.deepEqual(leftover, []);
});
test('a valid archive imports cleanly', (t) => {
const root = makeTmpDir('import-ok');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const guide = store.createGuide({ title: 'Src' });
store.addStep(guide.guideId, { title: 'S1' }, TINY_PNG, { width: 1, height: 1 });
const archiveFile = path.join(root, 'out.sfgz');
exportGuideArchive(store, guide.guideId, archiveFile);
const imported = importGuideArchive(store, archiveFile, { mode: 'copy' });
assert.equal(imported.title, 'Src');
assert.equal(imported.stepsOrder.length, 1);
});
// ---- atomic snapshot restore ------------------------------------------------
test('restoring a corrupt snapshot never destroys the live guide', (t) => {
const root = makeTmpDir('snap-atomic');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const guide = store.createGuide({ title: 'Live' });
store.addStep(guide.guideId, { title: 'keep me' }, TINY_PNG, { width: 1, height: 1 });
const snap = createSnapshot(store, guide.guideId, { label: 'good' });
// Corrupt the snapshot zip so restore must abort.
const snapFile = path.join(store.guideDir(guide.guideId), 'history', 'snapshots', snap);
fs.writeFileSync(snapFile, Buffer.from('not a zip at all'));
assert.throws(() => restoreSnapshot(store, guide.guideId, snap), /restore aborted|invalid|zip/i);
// The live guide and its step are intact.
const after = store.getGuide(guide.guideId);
assert.equal(after.title, 'Live');
assert.equal(after.stepsOrder.length, 1);
});
test('restoring a valid snapshot swaps content and keeps history', (t) => {
const root = makeTmpDir('snap-ok');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const guide = store.createGuide({ title: 'V1' });
const s1 = store.addStep(guide.guideId, { title: 'first' }, TINY_PNG, { width: 1, height: 1 });
const snap = createSnapshot(store, guide.guideId, { label: 'v1' });
// Change the guide, then restore.
store.saveGuide({ ...store.getGuide(guide.guideId), title: 'V2' });
store.deleteStep(guide.guideId, s1.stepId);
assert.equal(store.getGuide(guide.guideId).title, 'V2');
const restored = restoreSnapshot(store, guide.guideId, snap);
assert.equal(restored.title, 'V1');
assert.equal(restored.stepsOrder.length, 1);
// history/ survived the restore (pre-restore snapshot exists too).
assert.ok(fs.existsSync(path.join(store.guideDir(guide.guideId), 'history')));
});
// ---- atomic locks -----------------------------------------------------------
test('another process holding a fresh lock is a conflict; release-by-token frees ours', (t) => {
const root = makeTmpDir('lock');
t.after(() => rmrf(root));
const { lockPathFor } = require('../../core/locks');
const target = path.join(root, 'shared.sfgz');
fs.writeFileSync(target, 'x');
// Simulate a different process already holding a fresh lock.
fs.writeFileSync(lockPathFor(target), JSON.stringify({
host: 'other-host', user: 'someone-else', pid: 999999,
token: 'their-token', acquiredAt: new Date().toISOString(),
}));
const attempt = acquireLock(target);
assert.equal(attempt.acquired, false);
assert.ok(attempt.conflict);
// We must not be able to release their lock with a guessed/absent token.
assert.equal(releaseLock(target, { token: 'wrong' }), false);
// Force-steal (user confirmed), then release by our own acquisition token.
const stolen = acquireLock(target, { force: true });
assert.equal(stolen.acquired, true);
assert.equal(releaseLock(target, { lock: stolen.lock }), true);
assert.equal(acquireLock(target).acquired, true);
});
test('the same process can re-acquire its own lock', (t) => {
const root = makeTmpDir('lock-reacquire');
t.after(() => rmrf(root));
const target = path.join(root, 'shared.sfgz');
fs.writeFileSync(target, 'x');
assert.equal(acquireLock(target).acquired, true);
// Same process, second acquire: not a conflict.
assert.equal(acquireLock(target).acquired, true);
});
test('force steal takes over a held lock', (t) => {
const root = makeTmpDir('lock-force');
t.after(() => rmrf(root));
const target = path.join(root, 'shared.sfgz');
fs.writeFileSync(target, 'x');
acquireLock(target);
const stolen = acquireLock(target, { force: true });
assert.equal(stolen.acquired, true);
});
// ---- search reconcile -------------------------------------------------------
test('reconcile rebuilds a missing index from the store', (t) => {
const root = makeTmpDir('search-rebuild');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const guide = store.createGuide({ title: 'Password reset guide' });
store.addStep(guide.guideId, { title: 'Open admin portal' }, TINY_PNG, { width: 1, height: 1 });
// A brand-new index (nothing persisted) must recover by reconciling.
const index = new SearchIndex(store.indexDir);
const summary = index.reconcile(store);
assert.equal(summary.reindexed, 1);
assert.ok(index.search('password').length > 0);
});
test('reconcile drops entries for deleted guides and reindexes changed ones', (t) => {
const root = makeTmpDir('search-reconcile');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const g1 = store.createGuide({ title: 'alpha guide' });
const g2 = store.createGuide({ title: 'beta guide' });
const index = new SearchIndex(store.indexDir);
index.reconcile(store);
assert.ok(index.search('alpha').length > 0);
// Delete g1 out from under the index and change g2's title.
store.deleteGuide(g1.guideId);
store.saveGuide({ ...store.getGuide(g2.guideId), title: 'beta renamed gamma' });
const summary = index.reconcile(store);
assert.equal(index.search('alpha').length, 0, 'deleted guide is gone from search');
assert.ok(index.search('gamma').length > 0, 'changed guide is reindexed');
assert.equal(summary.removed, 1);
});
test('a corrupt index file resets to a recoverable status', (t) => {
const root = makeTmpDir('search-corrupt');
t.after(() => rmrf(root));
const store = new GuideStore(root);
store.createGuide({ title: 'recoverable' });
fs.mkdirSync(store.indexDir, { recursive: true });
fs.writeFileSync(path.join(store.indexDir, 'search-index.json'), '{ corrupt json');
const index = new SearchIndex(store.indexDir);
const summary = index.reconcile(store);
assert.equal(summary.status, 'reset');
assert.ok(index.search('recoverable').length > 0);
});
// ---- automatic backups ------------------------------------------------------
test('autoSnapshotIfDue takes a snapshot every N saves and prunes', (t) => {
const root = makeTmpDir('auto-backup');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const guide = store.createGuide({ title: 'G' });
const settings = {
get: (k) => ({ automatic: true, everyNSaves: 3, keepLast: 2 }[k.replace('backups.', '')] ?? ({ backups: { automatic: true, everyNSaves: 3, keepLast: 2 } }[k])),
};
// The helper reads settings.get('backups'):
const s = { get: (k) => (k === 'backups' ? { automatic: true, everyNSaves: 3, keepLast: 2 } : null) };
const dir = path.join(store.guideDir(guide.guideId), 'history', 'snapshots');
const count = () => (fs.existsSync(dir) ? fs.readdirSync(dir).filter((n) => n.endsWith('.zip')).length : 0);
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), null); // 1
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), null); // 2
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), true); // 3 -> snapshot
assert.equal(count(), 1);
autoSnapshotIfDue(store, guide.guideId, s); // 1
autoSnapshotIfDue(store, guide.guideId, s); // 2
autoSnapshotIfDue(store, guide.guideId, s); // 3 -> snapshot
assert.equal(count(), 2);
autoSnapshotIfDue(store, guide.guideId, s);
autoSnapshotIfDue(store, guide.guideId, s);
autoSnapshotIfDue(store, guide.guideId, s); // 3rd snapshot, pruned to keepLast=2
assert.equal(count(), 2, 'pruned to keepLast');
});
test('autoSnapshotIfDue is a no-op when automatic backups are off', (t) => {
const root = makeTmpDir('auto-backup-off');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const guide = store.createGuide({ title: 'G' });
const s = { get: () => ({ automatic: false, everyNSaves: 1 }) };
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), null);
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), null);
const dir = path.join(store.guideDir(guide.guideId), 'history', 'snapshots');
assert.equal(fs.existsSync(dir) ? fs.readdirSync(dir).length : 0, 0);
});
+90
View File
@@ -0,0 +1,90 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chooseCaptureTrigger, detectLinuxCapabilities } = require('../../app/platform/linux/diagnostics');
const platform = require('../../app/platform');
const ROOT = path.resolve(__dirname, '..', '..');
// Strip CR so assertions are robust to CRLF checkouts on Windows CI.
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8').replace(/\r\n/g, '\n');
const exists = (rel) => fs.existsSync(path.join(ROOT, rel));
// ---- honest trigger decisions ----------------------------------------------
test('X11 + xinput promises per-click capture with a marker', () => {
const t = chooseCaptureTrigger({ os: 'linux', isWayland: false, clickCapture: 'x11-xinput' });
assert.equal(t.trigger, 'click');
assert.equal(t.coordinates, true);
assert.equal(t.marker, true);
});
test('Wayland evdev captures per click but never promises coordinates or a marker', () => {
const t = chooseCaptureTrigger({ os: 'linux', isWayland: true, clickCapture: 'evdev-wayland' });
assert.equal(t.trigger, 'click');
assert.equal(t.coordinates, false, 'Wayland exposes no pointer position');
assert.equal(t.marker, false);
});
test('Wayland without a click source falls back to the user trigger, honestly', () => {
const interval = chooseCaptureTrigger({ os: 'linux', isWayland: true, clickCapture: 'hotkey-or-interval-only' }, 'interval');
assert.equal(interval.trigger, 'interval');
assert.equal(interval.coordinates, false);
assert.match(interval.note, /Wayland does not expose global clicks/i);
const hotkey = chooseCaptureTrigger({ os: 'linux', isWayland: true, clickCapture: 'hotkey-or-interval-only' }, 'hotkey');
assert.equal(hotkey.trigger, 'hotkey');
});
test('the platform facade wires the Linux trigger decision from real capabilities', () => {
const caps = detectLinuxCapabilities({
env: { XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0' },
hasBinary: () => false,
existsSync: () => false,
readdirSync: () => [],
});
const t = platform.chooseCaptureTrigger(caps, 'interval');
assert.equal(t.trigger, 'interval');
assert.equal(t.marker, false);
});
test('Windows always reports per-click capture', () => {
const t = platform.chooseCaptureTrigger({ os: 'windows' });
assert.equal(t.trigger, 'click');
assert.equal(t.coordinates, true);
});
// ---- least-privilege input access -------------------------------------------
test('the udev rule grants mouse-only access and excludes keyboards', () => {
assert.ok(exists('packaging/linux/common/60-stepforge-input.rules'));
const rule = read('packaging/linux/common/60-stepforge-input.rules');
assert.match(rule, /ID_INPUT_MOUSE\}=="1"/);
assert.match(rule, /ID_INPUT_KEYBOARD\}!="1"/, 'must exclude keyboards');
assert.match(rule, /TAG\+="uaccess"/, 'session-scoped ACL, not a permanent group');
});
test('the enable script is opt-in and installs the least-privilege rule, not the input group', () => {
assert.ok(exists('scripts/linux/enable-click-capture.sh'));
const script = read('scripts/linux/enable-click-capture.sh');
assert.match(script, /read -r reply/, 'must confirm before installing');
assert.match(script, /60-stepforge-input\.rules/, 'installs the least-privilege udev rule');
// usermod may only appear in a comment (warning), never as an executed
// command. Check command position (line start, optional sudo) so this is
// robust to CRLF vs LF line endings across platforms.
assert.doesNotMatch(script, /^\s*(sudo\s+)?usermod\b/m, 'must not run the broad input-group command');
});
// ---- docs no longer push the broad input group ------------------------------
test('Linux docs recommend the least-privilege path and warn against the input group', () => {
const doc = read('docs/GETTING_STARTED_WITH_LINUX.md');
assert.match(doc, /enable-click-capture\.sh/);
assert.match(doc, /least-privilege/i);
// The broad group is now presented as a warning ("Do not use ..."), not a
// recommended step.
assert.match(doc, /Do \*\*not\*\* use `sudo usermod/);
});