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
47 changed files with 1680 additions and 146 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.
+114 -2
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;
@@ -900,8 +1006,14 @@ function setupIpc() {
platform: process.platform,
}));
// Platform capture-capability profile (session type, portal/PipeWire,
// xinput, click source, actionable messages) for the diagnostics UI.
h('platform:capabilities', () => require('./platform').detectCapabilities());
// 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 --------------------------------------------------------------
+16 -1
View File
@@ -63,4 +63,19 @@ function detectCapabilities({ platform = process.platform, env = process.env } =
};
}
module.exports = { detectPlatform, createWindowContextProvider, detectCapabilities };
/**
* 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 };
+58 -1
View File
@@ -96,4 +96,61 @@ function detectLinuxCapabilities({
};
}
module.exports = { detectLinuxCapabilities, detectSessionType };
/**
* 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 };
+4
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'),
+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,
};
});
+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]);
});
+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/);
});