Author SHA1 Message Date
Tyler 2929d7d5e4 Fix zoom multi
Template tests / tests (push) Successful in 1m52s
2026-07-07 13:09:21 -05:00
Tyler 493f899a11 Fix zoom multi
Template tests / tests (push) Successful in 1m53s
2026-07-07 13:06:19 -05:00
Tyler 68e886c142 Fix zoom
Template tests / tests (push) Successful in 1m52s
2026-07-07 12:59:55 -05:00
Tyler 9e13ea9c40 Fix zoom-in accelerators
Template tests / tests (push) Successful in 1m53s
2026-07-07 12:45:49 -05:00
Tyler 058cdc20ab Restore
Template tests / tests (push) Successful in 1m54s
2026-07-07 12:34:42 -05:00
Tyler 7f68a72e2b Restore
Template tests / tests (push) Successful in 1m51s
2026-07-07 12:27:29 -05:00
Tyler 8e4f3ff951 Fix_selftest_window_guard
Template tests / tests (push) Successful in 2m16s
2026-07-07 12:18:05 -05:00
Tyler 67b5dc5965 Fix
Template tests / tests (push) Failing after 35s
2026-07-07 12:15:27 -05:00
Tyler 9555db9bf5 Fix
Template tests / tests (push) Failing after 4m1s
2026-07-07 12:04:08 -05:00
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
53 changed files with 2235 additions and 276 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.
+115
View File
@@ -22,6 +22,7 @@ const { readLock } = require('../core/locks');
const CaptureService = require('./capture');
const { TextIntelService } = require('./text-intel');
const { keepProcessesResponsive } = require('./win-power');
const { zoomShortcutFromInputEvent } = require('./shortcut-utils');
const security = require('./security');
const PACKAGE_JSON = require(path.join(__dirname, '..', 'package.json'));
@@ -65,6 +66,10 @@ let templates;
let capture;
let textIntel;
let mainWindow;
let lastZoomShortcut = null;
let canvasZoomActive = false;
const UI_ZOOM_LEVEL_MIN = -8;
const UI_ZOOM_LEVEL_MAX = 8;
function reindex(guideId) {
try {
@@ -87,6 +92,43 @@ function applyTheme() {
nativeTheme.themeSource = settings.get('appearance') || 'system';
}
function dispatchZoomShortcut(kind) {
sendToRenderer('editor:zoom-shortcut', kind);
}
// Ctrl+=/Ctrl+-/Ctrl+0 zoom the step editor's canvas while a guide is open
// there (dispatchZoomShortcut above); everywhere else — library, welcome,
// dialogs — the same keys scale the whole window's UI like a browser.
function applyUiZoom(kind) {
if (!mainWindow || mainWindow.isDestroyed()) return;
const wc = mainWindow.webContents;
if (kind === 'fit') {
wc.zoomLevel = 0;
return;
}
const delta = kind === 'in' ? 1 : kind === 'out' ? -1 : 0;
if (!delta) return;
wc.zoomLevel = Math.max(UI_ZOOM_LEVEL_MIN, Math.min(UI_ZOOM_LEVEL_MAX, wc.zoomLevel + delta));
}
// A single physical keypress reaches here twice — once via the global
// accelerator registration, once via before-input-event — plus multiple
// accelerator spellings can match the same key on some layouts. Collapse
// same-kind repeats within 50ms so one keypress is one zoom step.
function handleZoomShortcut(kind) {
if (!kind) return;
const now = Date.now();
if (lastZoomShortcut && lastZoomShortcut.kind === kind && (now - lastZoomShortcut.at) < 50) {
return;
}
lastZoomShortcut = { kind, at: now };
if (canvasZoomActive) {
dispatchZoomShortcut(kind);
} else {
applyUiZoom(kind);
}
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1280,
@@ -113,6 +155,16 @@ function createWindow() {
// away from it and every popup is denied, so no other document can run
// with this window's preload bridge.
security.installWindowSecurity(mainWindow, 'main');
mainWindow.webContents.on('before-input-event', (event, input) => {
// Electron reports both the key-down and key-up as separate
// before-input-event calls; only act on the down edge or every tap
// fires the shortcut twice regardless of the dedupe window below.
if (input.type !== 'keyDown') return;
const kind = zoomShortcutFromInputEvent(input);
if (!kind) return;
event.preventDefault();
handleZoomShortcut(kind);
});
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
mainWindow.once('ready-to-show', () => {
mainWindow.show();
@@ -248,6 +300,19 @@ function createWindow() {
}
};
const waitClickBackendReady = async () => {
for (let i = 0; i < 240; i++) {
const streamReady = Boolean(
capture.streamBackend
&& typeof capture.streamBackend.isActive === 'function'
&& capture.streamBackend.isActive(),
);
if (streamReady || capture.frameLoopRunning) return true;
await new Promise((res) => setTimeout(res, 50));
}
return false;
};
// Third scenario: the real "Start recording" path. armRecording
// warms the recorder while the window is visible and only arms the
// session once it hides; the first click *after* arming must get a
@@ -268,6 +333,12 @@ function createWindow() {
const warmupClicks = store.getGuide(armGuide.guideId).stepsOrder.length;
capture.onOsClick(Date.now(), toPhysical({ x: bounds.x + 100, y: bounds.y + 100 }), 'button-1');
await waitArmed();
if (!await waitClickBackendReady()) {
throw new Error('arm selftest backend never became ready');
}
await new Promise((res) => setTimeout(res, 1500));
if (mainWindow.isVisible()) mainWindow.hide();
await new Promise((res) => setTimeout(res, 200));
const armPoint = {
x: Math.round(bounds.x + bounds.width * 0.4),
y: Math.round(bounds.y + bounds.height * 0.4),
@@ -295,6 +366,12 @@ function createWindow() {
capture.togglePause(false);
await capture.startClickFrameBackend();
await waitArmed();
if (!await waitClickBackendReady()) {
throw new Error('debounce selftest backend never became ready');
}
await new Promise((res) => setTimeout(res, 1500));
if (mainWindow.isVisible()) mainWindow.hide();
await new Promise((res) => setTimeout(res, 200));
await new Promise((res) => setTimeout(res, 300));
const dbPoint = {
x: Math.round(bounds.x + bounds.width * 0.55),
@@ -365,6 +442,27 @@ function createWindow() {
function registerHotkeys() {
globalShortcut.unregisterAll();
const zoomBindings = [
['CommandOrControl+Plus', 'in'],
['CommandOrControl+Shift+=', 'in'],
['CommandOrControl+=', 'in'],
['CommandOrControl+numadd', 'in'],
['CommandOrControl+-', 'out'],
['CommandOrControl+Minus', 'out'],
['CommandOrControl+numsub', 'out'],
['CommandOrControl+0', 'fit'],
['CommandOrControl+num0', 'fit'],
];
for (const [accel, kind] of zoomBindings) {
try {
if (globalShortcut.register(accel, () => handleZoomShortcut(kind))) {
// Keep registering the other spellings so keyboards with different
// plus/minus translations still land on the same action.
}
} catch {
// Invalid accelerators must not break startup.
}
}
const accel = settings.get('capture.hotkeyCapture');
const pauseAccel = settings.get('capture.hotkeyPauseResume');
try {
@@ -399,6 +497,14 @@ function setupIpc() {
getMainWebContents: () => (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null),
});
const c = security.check;
// The renderer reports whether the step editor (with a guide open) is the
// visible screen, so Ctrl+=/Ctrl+-/Ctrl+0 can pick canvas zoom vs UI zoom.
ipcMain.on('editor:canvas-zoom-active', (event, active) => {
if (!trustedSender(event)) return;
canvasZoomActive = Boolean(active);
});
const IMAGE_BUDGET = 256 * 1024 * 1024; // channels that carry base64 PNGs
const h = (channel, fn, opts = {}) => {
const { maxChars = 2 * 1024 * 1024, validate = null } = opts;
@@ -899,6 +1005,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 --------------------------------------------------------------
+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 };
+5
View File
@@ -67,6 +67,10 @@ const api = {
onState: (fn) => ipcRenderer.on('capture:state', (e, payload) => fn(payload)),
onStepUpdated: (fn) => ipcRenderer.on('step:updated', (e, payload) => fn(payload)),
},
editor: {
onZoomShortcut: (fn) => ipcRenderer.on('editor:zoom-shortcut', (e, payload) => fn(payload)),
setCanvasZoomActive: (active) => ipcRenderer.send('editor:canvas-zoom-active', Boolean(active)),
},
archive: {
export: invoke('archive:export'),
open: invoke('archive:open'),
@@ -107,6 +111,7 @@ const api = {
},
app: {
info: invoke('app:info'),
platformCapabilities: invoke('platform:capabilities'),
},
};
+16 -1
View File
@@ -169,7 +169,14 @@ function makeHotkeyInput(value = '') {
return wrap;
}
async function promptText({ title, label = 'Value', value = '', placeholder = '', multiline = false } = {}) {
async function promptText({
title,
label = 'Value',
value = '',
placeholder = '',
multiline = false,
onInput = null,
} = {}) {
return new Promise((resolve) => {
const field = multiline
? el('textarea', { rows: 6, placeholder }, value)
@@ -186,12 +193,20 @@ 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();
resolve(field.value);
}
});
field.addEventListener('input', () => {
if (typeof onInput === 'function') onInput(field.value);
});
setTimeout(() => field.focus(), 0);
});
+87 -24
View File
@@ -4,6 +4,7 @@
const api = window.stepforge;
const dialogs = window.StepForgeDialogs || {};
const shortcuts = window.StepForgeShortcuts || {};
const clone = (value) => JSON.parse(JSON.stringify(value));
const BLOCK_KIND_ORDER = { text: 0, code: 1, table: 2 };
@@ -105,6 +106,24 @@ function isEditableTarget(target) {
);
}
function zoomShortcutFromEvent(e) {
if (shortcuts.zoomShortcutFromKeyboardEvent) {
return shortcuts.zoomShortcutFromKeyboardEvent(e);
}
if (!(e.ctrlKey || e.metaKey)) return null;
const { key, code, shiftKey } = e;
if (key === '0' || code === 'Digit0' || code === 'Numpad0') return 'fit';
if (
key === '+' || key === '=' || key === 'Add' || key === 'Plus' ||
code === 'Equal' || code === 'NumpadAdd' ||
(key === '=' && shiftKey) || (code === 'Equal' && shiftKey)
) return 'in';
if (key === '-' || key === '_' || key === 'Subtract' || key === 'Minus' || code === 'Minus' || code === 'NumpadSubtract') return 'out';
return null;
}
class GuideEditor {
constructor({ root, onMetaChange = () => {}, onToast = toast, onBack = () => {} } = {}) {
this.root = root;
@@ -141,6 +160,13 @@ class GuideEditor {
this.saveStepDebounced = debounce(() => this.flushStep(), 180);
this.saveGuideDebounced = debounce(() => this.flushGuide(), 180);
if (api.editor && typeof api.editor.onZoomShortcut === 'function') {
api.editor.onZoomShortcut((kind) => {
if (!this.active || !this.guide) return;
this.applyZoomShortcut(kind);
});
}
this.onDocumentKeyDown = this.onDocumentKeyDown.bind(this);
document.addEventListener('keydown', this.onDocumentKeyDown, true);
}
@@ -152,6 +178,9 @@ class GuideEditor {
setActive(active) {
this.active = Boolean(active);
if (api.editor && typeof api.editor.setCanvasZoomActive === 'function') {
api.editor.setCanvasZoomActive(this.active);
}
if (!this.active && this.guideId) {
// Leaving the editor: flush pending debounced saves so navigation can
// never drop the last edit (failures keep the dirty state and retry),
@@ -1182,7 +1211,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 +1246,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));
@@ -1348,6 +1384,22 @@ class GuideEditor {
if (mode === 1.5) this.dom.zoom150Btn.classList.add('active');
}
applyZoomShortcut(kind) {
if (kind === 'in') {
this.setZoom(Math.min(3, (Number(this.currentZoom) || 1) + 0.25));
return true;
}
if (kind === 'out') {
this.setZoom(Math.max(0.25, (Number(this.currentZoom) || 1) - 0.25));
return true;
}
if (kind === 'fit') {
this.setZoom('fit');
return true;
}
return false;
}
pushCanvasHistory(recordOrLabel = 'change') {
if (!this.currentStep) return;
const record = recordOrLabel && typeof recordOrLabel === 'object'
@@ -2168,19 +2220,39 @@ class GuideEditor {
async editAnnotationText(ann) {
const step = this.currentStep;
if (!step || !ann) return;
const originalText = ann.text ?? '';
const applyText = (nextText, { persist = true } = {}) => {
const selected = this.canvas.selected();
if (!selected) return;
selected.text = nextText;
step.annotations = clone(this.canvas.annotations || []);
this.pendingSave = true;
this.canvas.setAnnotations(step.annotations || []);
this.renderAnnotationPanel();
this.emitMeta();
if (persist) this.saveStepDebounced();
};
const value = await dialogs.promptText({
title: ann.type === 'tooltip' ? 'Edit tooltip' : 'Edit text',
label: 'Text',
value: ann.text || '',
value: originalText,
multiline: true,
onInput: applyText,
});
if (value == null) return;
ann.text = value;
step.annotations = clone(step.annotations || []);
this.pendingSave = true;
this.saveStepDebounced.cancel();
if (value == null) {
const current = this.canvas.selected();
if ((current?.text ?? '') !== originalText) {
applyText(originalText, { persist: false });
}
await this.flushStep(step);
return;
}
const current = this.canvas.selected();
if ((current?.text ?? '') !== value) {
applyText(value, { persist: false });
}
await this.flushStep(step);
this.renderAnnotationPanel();
this.emitMeta();
}
formatDescription(command, block = null) {
@@ -2227,6 +2299,12 @@ class GuideEditor {
onDocumentKeyDown(e) {
if (!this.active || !this.guide) return;
const zoomShortcut = zoomShortcutFromEvent(e);
if (zoomShortcut) {
e.preventDefault();
this.applyZoomShortcut(zoomShortcut);
return;
}
if ((e.ctrlKey || e.metaKey) && e.key === '/' && !e.shiftKey) {
e.preventDefault();
this.openQuickActions();
@@ -2270,21 +2348,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();
+1
View File
@@ -21,6 +21,7 @@
<div id="modal-root"></div>
<div id="toast-root"></div>
<script src="util.js"></script>
<script src="../shortcut-utils.js"></script>
<script src="canvas.js"></script>
<script src="dialogs.js"></script>
<script src="editor.js"></script>
+55
View File
@@ -0,0 +1,55 @@
'use strict';
(function attachShortcutUtils(root, factory) {
const api = factory();
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
if (root) {
root.StepForgeShortcuts = api;
}
})(typeof globalThis !== 'undefined' ? globalThis : this, () => {
function hasZoomModifier(source) {
return Boolean(source && (source.ctrlKey || source.metaKey || source.control || source.meta));
}
function zoomShortcutFromSource(source) {
if (!hasZoomModifier(source)) return null;
const key = String(source.key || '');
const code = String(source.code || '');
const shiftKey = Boolean(source.shiftKey || source.shift);
if (key === '0' || code === 'Digit0' || code === 'Numpad0') return 'fit';
if (
key === '+' || key === '=' || key === 'Add' || key === 'Plus' ||
code === 'Equal' || code === 'NumpadAdd' ||
(key === '=' && shiftKey) || (code === 'Equal' && shiftKey)
) {
return 'in';
}
if (
key === '-' || key === '_' || key === 'Subtract' || key === 'Minus' ||
code === 'Minus' || code === 'NumpadSubtract'
) {
return 'out';
}
return null;
}
function zoomShortcutFromKeyboardEvent(event) {
return zoomShortcutFromSource(event);
}
function zoomShortcutFromInputEvent(input) {
return zoomShortcutFromSource(input);
}
return {
zoomShortcutFromInputEvent,
zoomShortcutFromKeyboardEvent,
};
});
+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 });
+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/);
});
+26
View File
@@ -0,0 +1,26 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
zoomShortcutFromInputEvent,
zoomShortcutFromKeyboardEvent,
} = require('../../app/shortcut-utils');
test('zoom shortcut helper recognizes zoom in, out, and fit across event shapes', () => {
assert.equal(zoomShortcutFromKeyboardEvent({ ctrlKey: true, key: '=', code: 'Equal' }), 'in');
assert.equal(zoomShortcutFromKeyboardEvent({ ctrlKey: true, key: '+', code: 'NumpadAdd' }), 'in');
assert.equal(zoomShortcutFromKeyboardEvent({ ctrlKey: true, key: 'Plus', code: 'Equal' }), 'in');
assert.equal(zoomShortcutFromKeyboardEvent({ ctrlKey: true, key: '-', code: 'Minus' }), 'out');
assert.equal(zoomShortcutFromKeyboardEvent({ metaKey: true, key: '0', code: 'Digit0' }), 'fit');
assert.equal(zoomShortcutFromKeyboardEvent({ ctrlKey: true, key: '=', code: 'Equal', shiftKey: true }), 'in');
});
test('zoom shortcut helper recognizes Electron before-input-event payloads', () => {
assert.equal(zoomShortcutFromInputEvent({ control: true, key: '=', code: 'Equal' }), 'in');
assert.equal(zoomShortcutFromInputEvent({ control: true, key: '=', code: 'Equal', shift: true }), 'in');
assert.equal(zoomShortcutFromInputEvent({ control: true, key: 'Plus', code: 'Equal', shift: true }), 'in');
assert.equal(zoomShortcutFromInputEvent({ control: true, key: '-', code: 'Minus' }), 'out');
assert.equal(zoomShortcutFromInputEvent({ meta: true, key: '0', code: 'Digit0' }), 'fit');
});
+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/);
});