Pin the Node toolchain, remove runtime npm repair, make CI and E2E truthful
Template tests / tests (pull_request) Failing after 1m26s

Phase 0 of the improvement plan (ai_prompts/prompt4.md): make the baseline
reproducible and stop the test runner from masking real failures.

- Pin Node >= 22.12 (engines + .nvmrc + engine-strict); every entry point
  fails fast with clear guidance instead of dying late with ERR_REQUIRE_ESM
  inside the packaging dependency graph.
- electron-launcher.js is diagnostics-only: all runtime npm install/rebuild/
  repair paths are removed. npm ci on the pinned toolchain is the only
  supported install path (README + GETTING_STARTED updated).
- Refuse to silently launch unsandboxed on Linux: --no-sandbox now requires
  an explicit STEPFORGE_ALLOW_NO_SANDBOX/ELECTRON_DISABLE_SANDBOX opt-in and
  is otherwise a hard error with actionable fixes; user-namespace sandboxing
  is detected and preferred.
- Click-capture E2E no longer converts startup crashes into "SKIPPED": the
  only allowed skip is the upfront absence of a display server. A missing
  shared library or crash now fails with the startup log. Same guard added
  to the startup smoke check.
- GitHub CI: run on pull_request, pin Node from .nvmrc, drop the macOS matrix
  entry (not a support target), and audit production and full dependency
  trees as separate signals. Gitea CI: pull_request trigger + pinned Node.
- Refresh package-lock on Node 22/npm 10 and remediate the form-data and
  undici advisories (npm audit: 0 vulnerabilities, prod and full tree).
- Stop tracking generated machine-specific build reports
  (build/build_report.md, build/artifacts_manifest.json).

Verified: 203 unit tests pass; repo-structure, startup-smoke, unit-workflows,
sample-artifacts, and build-release checks pass locally with a real Electron
launch. The click self-test now truthfully reports the pre-existing Linux
arm/debounce capture failures (also red on Gitea CI main run 177) instead of
hiding behind SKIPPED; that defect is scheduled for the capture-fix PR.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-07-03 13:09:51 -07:00
co-authored by Claude Fable 5
parent 534a28ece8
commit 0f966a5fd0
19 changed files with 704 additions and 595 deletions
+7 -1
View File
@@ -4,6 +4,7 @@ on:
push: push:
branches: branches:
- main - main
pull_request:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -14,6 +15,11 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: https://gitea.com/actions/checkout@v4 uses: https://gitea.com/actions/checkout@v4
- name: Use pinned Node toolchain
uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Install dependencies - name: Install dependencies
run: npm ci --cache ~/.npm --prefer-offline run: npm ci --cache ~/.npm --prefer-offline
@@ -21,4 +27,4 @@ jobs:
env: env:
ELECTRON_ENABLE_LOGGING: "1" ELECTRON_ENABLE_LOGGING: "1"
ELECTRON_DISABLE_SANDBOX: "1" ELECTRON_DISABLE_SANDBOX: "1"
run: xvfb-run -a bash tests/run_test.sh run: xvfb-run -a bash tests/run_test.sh
+25 -2
View File
@@ -3,6 +3,7 @@ name: CI
on: on:
push: push:
branches: [main] branches: [main]
pull_request:
# Cancel an in-progress run when newer commits are pushed to the same ref. # Cancel an in-progress run when newer commits are pushed to the same ref.
concurrency: concurrency:
@@ -15,14 +16,16 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
fail-fast: false fail-fast: false
# Supported desktop targets are Windows and Linux. macOS is not a
# support target; do not imply it by testing on it.
matrix: matrix:
os: [ubuntu-latest, windows-latest, macos-latest] os: [ubuntu-latest, windows-latest]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 20 node-version-file: .nvmrc
cache: npm cache: npm
# The capture unit tests require app/capture.js, which require()s the # The capture unit tests require app/capture.js, which require()s the
@@ -33,3 +36,23 @@ jobs:
- name: Run unit tests - name: Run unit tests
run: npm test run: npm test
audit:
name: Dependency audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
# Production dependencies must be clean: these ship inside packages.
- name: Audit production dependencies (blocking)
run: npm audit --omit=dev --package-lock-only --audit-level=high
# Full-tree audit including build/dev tooling is an explicit separate
# signal: it must be visible but does not block unrelated changes.
- name: Audit full dependency tree (informational)
run: npm audit --package-lock-only --audit-level=high
continue-on-error: true
+2
View File
@@ -6,3 +6,5 @@ releases/
.tmp/ .tmp/
tests/.tmp/ tests/.tmp/
examples/.tmp/ examples/.tmp/
build/build_report.md
build/artifacts_manifest.json
+3
View File
@@ -0,0 +1,3 @@
# Refuse installs on Node versions outside package.json "engines".
# The locked dependency graph (electron-builder toolchain) needs Node >= 22.12.
engine-strict=true
+1
View File
@@ -0,0 +1 @@
22.17.0
+6 -2
View File
@@ -66,13 +66,17 @@ For a Windows installation, see [docs/windows_installation](docs/windows_install
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** (⚠️ 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).
Requirements: Node.js 20+ and npm (Electron is the only dependency). Requirements: Node.js 22.12+ and npm (pinned in `.nvmrc`; installs are
refused on older Nodes because the packaging toolchain needs 22.12+).
```bash ```bash
npm install # one-time, fetches the Electron shell npm ci # one-time, installs the locked dependency tree
npm start # launch StepForge npm start # launch StepForge
``` ```
Dependencies are only ever installed by you, via `npm ci` — the app never
downloads or repairs packages at runtime.
First run creates the local data directory (`~/.local/share/stepforge` on First run creates the local data directory (`~/.local/share/stepforge` on
Linux (WIP), `%APPDATA%/stepforge` on Windows; override with Linux (WIP), `%APPDATA%/stepforge` on Windows; override with
`STEPFORGE_DATA_DIR`). `STEPFORGE_DATA_DIR`).
+329
View File
@@ -0,0 +1,329 @@
# 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.
## Objective
Turn StepForge into a reliable, secure, maintainable Windows and Linux desktop application while preserving user data and the core capture/edit/export workflow. Work in small phases, add tests before or with each fix, and keep each pull request focused. Do not claim a capability until its acceptance test passes on the relevant operating system.
Linux is a platform rewrite, not a collection of `process.platform === "linux"` branches in Windows-oriented files. All Linux-only runtime, setup, packaging, and tests must live in separate Linux-specific files. Apt- and dnf-based distributions must also have separate setup/package files.
## Audit baseline
- Repository: Electron app with a dependency-light Node core.
- Approximate size: 10,949 lines in `app/`, 4,134 in `core/`, 2,658 in `exporters/`, 1,119 in `scripts/`, and 5,119 in tests.
- Largest production files are already too broad: `app/renderer/editor.js` (2,336 lines), `app/capture.js` (2,055), `app/renderer/dialogs.js` (1,039), `app/renderer/app.js` (944), and `app/main.js` (905).
- `node --check` succeeds for all checked JavaScript files.
- The full `bash tests/run_test.sh` run fails in `test_startup_smoke.sh`: Electron cannot load `libnspr4.so` on this Linux host. The earlier click self-test reports “SKIPPED,” masking that startup failure as a missing capture environment.
- Direct unit run: 205 tests, 200 passed, 1 failed, 4 skipped. The failure is `tests/unit/package-windows.test.js`, caused by the packaging dependency graph being loaded under Node 18 (`ERR_REQUIRE_ESM`). The four skipped tests are external renderer/codec validations.
- The host has Node 18.19.1. Documentation says Node 20+, but the lockfile contains packages requiring at least Node 20.19 and some packaging packages requiring Node 22.12. There is no `engines` field or hard prerequisite check.
- Sample generation and the current build-release workflow tests pass. They do not prove that the produced Linux package launches or that it has all system libraries.
- `npm audit --package-lock-only` reports two high-severity issues in build/dev dependencies (`form-data` and `undici`). `npm audit --omit=dev --package-lock-only` reports no production issue. This distinction disappears in the current Linux package because it copies all of `node_modules`, including dev/build dependencies.
- The launcher silently ran `npm install --package-lock=false` when dependencies were missing. That changed the ignored `node_modules` tree to versions different from `package-lock.json`. A desktop launcher must not repair itself by accessing npm at runtime.
- The worktree was clean before this plan; diagnostics only created ignored `node_modules` content.
## Confirmed high-priority findings
### Security and privacy
1. **A remote page can inherit the privileged preload API.** The main window has no `will-navigate` guard and no `setWindowOpenHandler`. Stored descriptions allow `https:` links. If a link navigates the main window, the preload still runs and exposes `window.stepforge` to the new page. IPC handlers do not validate sender URL/origin, and `shell:openPath` accepts an arbitrary renderer-provided target. This is a release-blocking privilege-boundary defect.
2. **The default session grants every Electron permission.** `app/main.js` uses permission handlers that return `true` for every permission and every requester. The comment that this is safe because content is local is not a security control. Grant only display capture, only to the dedicated capture worker, and reject everything else.
3. **Windows recording behaves like a keylogger.** The PowerShell/C# hook embedded in `app/capture.js` installs a global keyboard hook, reconstructs printable characters, buffers up to 200 characters, persists them in `captureMetadata`, and can send them with screenshots to an Ollama host. This can capture passwords or other sensitive text. It is not adequately disclosed or consented to. Disable raw character capture by default; ideally remove it. If retained, make it an explicit, separately consented feature with sensitive-field suppression, short in-memory lifetime, redaction, no persistence by default, and tests.
4. **“Fully offline” and “zero HTTP requests” are factually false.** `app/text-intel.js` performs configurable HTTP requests to an Ollama host and accepts a host that can be remote. The launcher can contact npm. The docs also say Electron is the only dependency, while Tesseract and its language data are production dependencies. Choose and document an accurate contract such as “local-first, no telemetry, optional user-configured Ollama,” then enforce it. If remote hosts are prohibited, validate loopback/unix-socket targets instead of accepting arbitrary HTTP endpoints.
5. **AI requests have no timeout, cancellation, size limit, or concurrency policy.** A dead Ollama endpoint can leave UI actions pending indefinitely. Full screenshots are base64-expanded into requests. Add `AbortController` deadlines, cancellation when a guide/step closes, request-size limits, bounded concurrency, and explicit data-disclosure UI.
6. **Archive import lacks resource limits and transactional extraction.** ZIP entry paths and CRCs are checked, but entry count, compressed size, inflated size, compression ratio, manifest size, and total extracted bytes are not bounded. A ZIP bomb can exhaust memory because the archive and inflated entries are handled synchronously in memory. Import writes `guide.json` before all steps validate, leaving partial guides after an error.
7. **Imported export templates can inject active HTML.** `customCss`, accent values, and other template values are interpolated into generated HTML without a typed schema. A malicious `.sfglt` can close the style element and add script. Validate every formats options, restrict colors/numbers/enums, and either remove arbitrary CSS or safely encode it with a clearly documented trusted-template boundary.
8. **The HTML sanitizer is regex-based and link navigation is not separated from rendering.** Replace ad hoc URL checks with URL parsing and a strict scheme/host policy. Add hostile HTML fixtures and ensure renderer links are intercepted rather than navigating the privileged window.
### Broken or misleading behavior
1. **Region capture returns the wrong shape.** `CaptureService.regionCapture()` stores the result of `storeFrameAsStep()` in `step` and then returns `{ ok: true, step }`. The actual step is therefore at `result.step.step`. Region capture selection and region auto-documentation expect `result.step.stepId`, so they break. Return the `storeFrameAsStep()` result directly and add an IPC-to-renderer workflow test.
2. **Cancelled region capture leaks an IPC listener.** `pickRegion()` removes `region:picked` only when an event arrives. Closing/cancelling the overlay leaves the listener and captured window references behind. Cleanup must be idempotent on pick, close, load failure, and app shutdown. Validate/clamp the received rectangle before cropping.
3. **Autosave can report clean after a failed write.** `flushStep()` and `flushGuide()` clear dirty flags before awaiting IPC. A rejected save can lose the visible dirty state and is often invoked through a debounce that does not handle rejected promises. Use a serialized save queue with states (`dirty`, `saving`, `saved`, `error`), only clear the matching revision after success, retry safely, show persistent failure UI, and flush on navigation/close.
4. **Concurrent whole-object saves can overwrite newer edits.** Editor saves, capture auto-documentation, AI generation, and background step updates all read and write full step objects with no revision check. An AI response based on stale data can overwrite user edits; `step:updated` can reload the editor while local changes are pending. Add per-guide/per-step revision numbers and compare-and-swap saves or field-level patches. Resolve conflicts explicitly.
5. **Configured automatic backups do not exist.** `backups.automatic` and `backups.everyNSaves` are defined but never used. Implement a save-count/time policy with pruning and failure reporting, or remove the settings and the documentation claim. Snapshot restore must extract into a temporary directory, validate fully, and atomically swap; the current restore deletes live content before extraction succeeds.
6. **Strict click timing still falls back to a post-click shot.** The selection logic rejects post-click frames, but `sessionCapture()` then takes a fresh shot after the click and stores it. That contradicts the strict-mode product promise. In strict mode, either keep a sufficiently healthy pre-click buffer or skip the capture with a visible diagnostic; never label a post-click fallback as strict.
7. **Linux evdev state is reported incorrectly.** `startEvdevWatcher()` does not set `clickWatcher`, so `state().clickCapture` is false even when evdev is active. The UI can say “hotkey only” while clicks are being watched. Device stream errors are swallowed and do not trigger fallback. Represent trigger sources as explicit states (`windows-hook`, `x11`, `wayland-helper`, `hotkey`, `interval`, `unavailable`) rather than a boolean.
8. **Power blocker ownership is wrong.** The IPC `start` action starts a power-save blocker even though new sessions begin paused. Tray/second-instance pauses bypass the main-process closure that stops it. Move power management behind capture state transitions so there is exactly one owner and assert that paused/finished sessions release it.
9. **File URLs are assembled by string concatenation.** `file://${p}` breaks on spaces, `#`, `%`, Windows drive letters, and other characters. Use `pathToFileURL()` and remove renderer control of arbitrary filesystem paths.
10. **Search can silently remain empty.** If the index is missing, corrupt, or version-mismatched, the constructor starts empty and does not reconcile all existing guides. Rebuild incrementally at startup, store a source revision/fingerprint, and expose recovery status instead of silently swallowing failures.
11. **Corrupt user data is silently hidden.** `listGuides()` and `listSteps()` skip unreadable entries. Corrupt settings are silently replaced in memory. Quarantine corrupt files, preserve originals, surface a recovery UI/report, and never make a guide disappear without explanation.
12. **Several public settings/schema fields are dead or incomplete.** `language`, `capture.includeCursor`, `editor.autoTitleTemplate`, `library.sortBy`, automatic backup settings, `themeOverride`, `exportProfiles`, `extraImages`, and parts of `links` are unused or only partially used. Implement them end-to-end or remove/migrate them; do not keep misleading UI/data contracts.
13. **Linked-guide locking is racy and barely observable.** Lock acquisition is read-then-write rather than exclusive creation, locks exist only for the short save operation, and two writers can race. Define whether the lock covers the editing session or just a write transaction. Use atomic exclusive creation plus ownership token, heartbeat/stale recovery if session-scoped, and conflict detection based on archive hash/revision before overwrite.
### Export correctness and scalability
1. `renderAllImages()` retains every decoded/rendered RGBA image. A single 4K image is roughly 32 MiB before copies; a large guide can consume gigabytes and terminate the export worker. Render one step at a time, write/consume it, release buffers, and report progress/cancellation.
2. PNG and ZIP decoding are synchronous and memory-heavy. Dimension-only limits still permit enormous allocations (up to 32,768 squared). Set total pixel/byte budgets, validate exact inflated length rather than only “at least,” and move heavy work off the main process.
3. The PDF writer replaces unsupported Unicode with `?`; raster annotation text uses an ASCII 8x8 font; the editor uses system fonts. Therefore the documented WYSIWYG and international-text claims are false. Vendor a properly licensed Unicode font or adopt a vetted renderer, embed/subset fonts, and add multilingual fixtures.
4. Editor and export rendering differ for blur, typography, antialiasing, tooltip layout, and potentially focused-view geometry. Build shared geometry/style calculations and golden-image comparisons with explicit tolerances.
5. Text-block position behavior needs a complete format matrix. Current grouping supports six positions for text blocks, while code/table blocks always fall into `rest`. Existing tests do not prove every position in PDF, DOCX, PPTX, HTML, Markdown, Confluence, and Wiki.js. Fix the reported callout movement issue by defining one canonical ordered content stream and testing every exporter against it.
6. Image sizing is format-specific and inconsistent. Introduce a canonical image layout policy (`natural`, `fit-content-width`, explicit max width/height, preserve aspect ratio, no-upscale) and map physical units correctly for HTML/CSS pixels, PDF points, DOCX twips, and PPTX EMUs. Make it configurable in export profiles and test portrait, landscape, ultrawide, small, and 4K images.
7. Markdown output does not robustly escape table pipes/newlines or choose a safe code-fence length when code contains backticks. Add escaping/conformance tests. Validate Office packages by opening/rendering with LibreOffice in Linux CI, not just by checking ZIP/XML structure. Validate PDF with Ghostscript/Poppler and images/GIF with external tools in a dedicated integration job.
8. Export writes directly into the selected output directory and can leave partial/stale files. Export into a temporary sibling directory, validate, then atomically publish. Define overwrite behavior and clean obsolete sidecar images.
### Build, packaging, and release
1. The current Linux package script is not production packaging. It copies all `node_modules` (including dev tools and vulnerable build dependencies), docs, prompts, examples, and stale audit files; hardcodes `amd64`; declares only `xinput`; lacks desktop entry/icons/MIME integration; and copies a nonexistent root `LICENSE`. The portable tarball excludes the generated `/usr/bin/stepforge` launcher because it archives only `opt/stepforge`.
2. A clean run can build a package without `node_modules`, producing an unusable artifact while tests still pass. Package tests only inspect file existence, not launch/install behavior.
3. Linux startup currently falls back to `--no-sandbox` whenever `chrome-sandbox` is not setuid-root. Do not normalize an unsandboxed production launch. Use a packaging method/configuration that supports Chromium sandboxing (or user namespaces where supported), fail with actionable diagnostics, and reserve `--no-sandbox` for explicitly marked development/CI environments.
4. The launcher auto-repairs/reinstalls Electron with npm and ignores the lockfile. Remove all runtime installation. Development setup uses `npm ci`; packaged applications contain a fixed Electron runtime.
5. The Windows artifact finder returns the first `.exe` encountered and can select the unpacked app executable instead of the NSIS installer. Select the expected artifact by exact pattern/metadata and fail on zero or multiple matches.
6. There is no real app icon/assets directory even though architecture docs claim one, and the Windows test explicitly asserts assets are not packaged. Add licensed original assets and verify Windows/Linux metadata.
7. Versioning is inconsistent: package version, four-part build version, tags, changelog, and committed build reports disagree. Use SemVer for releases, a separate platform file/build version where needed, and generate all metadata from one source. Do not commit stale machine-specific build reports/manifests as if current.
8. The license is contradictory. `package.json` says `MPL-2.0`, contribution docs require MPL-2.0/DCO, while `docs/LICENSE` and README impose a noncommercial license. There is no root `LICENSE`. The owner must choose one license before the next release; then make the SPDX field, root license text, README, contribution policy, package contents, and generated About view agree. This is a legal release blocker and cannot be guessed by an implementation agent.
9. GitHub CI runs only `npm test`, only on pushes to `main`; docs claim full checks on pull requests. Release builds only Windows. Add `pull_request`, run the same authoritative commands everywhere, and add Linux package jobs. Do not allow the click E2E test to convert arbitrary startup failures into skips.
## Target architecture
Refactor incrementally toward these boundaries; do not perform a blind rewrite of the whole app.
```text
app/
main/ lifecycle, window policy, IPC composition
renderer/ views/components with no filesystem privilege
capture/ platform-neutral session state machine and frame pairing
platform/
windows/ Windows hooks, context, power behavior
linux/ Linux session detection, portal/X11 input, window policy
services/ export, AI/OCR, search coordination
core/
domain/ guide/step/block model and migrations
storage/ transactional repository, recovery, snapshots, locks
render/ canonical document layout and annotation geometry
exporters/ thin format adapters consuming canonical layout
packaging/
windows/
linux/
debian/
fedora/
scripts/
linux/apt/
linux/dnf/
tests/
unit/
integration/
e2e/
fixtures/
```
Use dependency injection for OS adapters. The platform-neutral capture coordinator should consume interfaces such as `ClickSource`, `ScreenFrameSource`, `WindowContextProvider`, `WindowVisibilityPolicy`, and `PowerPolicy`. It should never inspect `process.platform` itself. `app/platform/index.js` is the only factory that selects a platform implementation.
Introduce a schema-v2 migration with a single ordered `blocks[]` collection instead of three arrays plus synthesized order. Keep a tested v1 reader/migrator and never rewrite user data without a pre-migration snapshot. Add `revision` fields for optimistic concurrency.
## Phased implementation plan
### Phase 0 — freeze the contract and make the baseline reproducible
- Resolve the license decision and the offline/local-AI product wording with the owner.
- Choose one supported Node LTS that satisfies the entire locked dependency graph (the current graph requires at least Node 22.12 for packaging), add `engines`, `.nvmrc` or `.node-version`, and a hard version check in setup/CI.
- Make `npm ci` the only dependency installation path. Remove auto-install/repair from `scripts/electron-launcher.js` and keep clear diagnostics.
- Refresh and pin the lockfile on the chosen Node/npm version; remediate the two audited build dependency issues. Add production and full dependency audits as separate CI signals with an explicit policy.
- Split tests into deterministic unit, desktop smoke, platform capture E2E, export integration, and package install/launch suites. A missing display may skip only a capture scenario after the app has demonstrably started; a missing shared library or crash must fail.
- Add `pull_request` CI. Run syntax/lint/type checks, unit tests, and artifact checks on Linux and Windows. Keep macOS core tests only if macOS is an intended support target; otherwise stop implying app support.
- Record baseline performance fixtures: 100-step 1080p guide, 25-step 4K guide, large archive, and rapid-click session. Track peak RSS, export time, save latency, and dropped-click count.
### Phase 1 — close privilege boundaries and data-loss paths
- In the main window, reject all navigation away from the exact local app entry URL. Add `setWindowOpenHandler(() => ({ action: "deny" }))`. Route safe external links through a narrow `openExternal` handler after scheme validation and optional confirmation.
- Validate IPC sender/webContents for every handler. Add per-channel input schemas, length/size limits, enum checks, and ownership/path checks. Remove generic `shell:openPath`/`showItemInFolder` from the renderer; replace them with intent-specific commands for known export, preview, data, and linked-archive paths.
- Set `sandbox: true` explicitly for every renderer. Deny all permissions by default and grant display capture only to the capture worker and only for the apps local URL. Add security regression tests for remote navigation, popup attempts, permission requests, malicious stored HTML, and hostile template archives.
- Remove or default-disable global printable-key capture. Add a privacy disclosure for screenshot/OCR/window-title/AI data. Never persist raw typed text unless the user explicitly opts in.
- Add AI timeouts, cancellation, concurrency limits, loopback policy if required, payload limits, and error states. Ensure stale AI responses cannot overwrite edited revisions.
- Build the serialized revision-aware autosave queue. Keep dirty state on failure, show last successful save time, flush before navigation/quit, and block destructive close only when a flush genuinely fails.
- Make guide/step save operations transactional at the guide level where multiple files must change. Add recovery journals or temp-directory swaps for add/delete/reorder/import/restore. Quarantine and report corrupt data.
- Add archive/template limits and preflight validation. Extract/import into temp storage, validate manifest/schema/all referenced files, then publish atomically.
### Phase 2 — fix known workflows before larger refactors
- Fix region captures nested result and listener cleanup. Add tests covering capture service → IPC → renderer selection → optional AI.
- Implement automatic snapshots or remove the dead settings. Make restore atomic and verify rollback after injected failures.
- Rebuild/reconcile the search index at startup and test deletion/corruption/version upgrade.
- Replace file URL concatenation with `pathToFileURL()` and test Windows, spaces, Unicode, `#`, and `%` paths.
- Fix power blocker transitions, evdev trigger reporting, watcher-loss fallback, pending click drain on application shutdown, and strict-mode post-click behavior.
- Validate and clamp all persisted geometry and settings. Reject NaN/infinite/negative image sizes, cyclic parent relationships, invalid block IDs/orders, unsafe image paths, out-of-range focused views, and oversized strings/arrays.
- Inventory every schema/settings field and either implement, migrate, or delete it. Update UI and docs in the same PR.
### Phase 3 — Linux rewrite with separate files (apt and dnf)
This phase must not add more Linux conditionals to `app/capture.js`, `app/text-intel.js`, `app/main.js`, or the Windows hook. First introduce platform interfaces, preserve the tested Windows adapter, and then write Linux implementations in new files.
Create at minimum:
```text
app/platform/index.js
app/platform/windows/capture-adapter.js
app/platform/windows/click-hook.cs
app/platform/windows/window-context.ps1
app/platform/windows/power-policy.js
app/platform/linux/capture-adapter.js
app/platform/linux/session-detection.js
app/platform/linux/portal-frame-source.js
app/platform/linux/x11-click-source.js
app/platform/linux/wayland-click-source.js
app/platform/linux/window-context-x11.js
app/platform/linux/window-policy.js
app/platform/linux/diagnostics.js
scripts/linux/apt/install-build-deps.sh
scripts/linux/apt/install-runtime-deps.sh
scripts/linux/dnf/install-build-deps.sh
scripts/linux/dnf/install-runtime-deps.sh
packaging/linux/debian/package.sh
packaging/linux/debian/control.in
packaging/linux/fedora/package.sh
packaging/linux/fedora/stepforge.spec
packaging/linux/common/stepforge.desktop
packaging/linux/common/stepforge-mime.xml
packaging/linux/common/launcher.sh
docs/linux/apt.md
docs/linux/dnf.md
tests/integration/linux/x11-capture.test.js
tests/integration/linux/wayland-capture.test.js
tests/integration/linux/package-deb.test.sh
tests/integration/linux/package-rpm.test.sh
```
Requirements:
- Support both X11 and Wayland as different capability profiles. X11 may use a separately implemented `xinput` adapter with event-time coordinates. Wayland must use XDG Desktop Portal/PipeWire for screen selection and capture.
- Do not promise global per-click capture with coordinates on Wayland when the platform does not expose it. The safe baseline is portal screen capture plus user-triggered global hotkey or interval capture. Treat direct `/dev/input` access as an optional, explicitly consented privileged mode, not default setup.
- Remove documentation that casually tells every user to join the broad `input` group. If a privileged helper is retained, perform a threat review, use least-privilege device rules, never read keyboard devices, package it separately, and show the security tradeoff before enabling it.
- Detect portal, PipeWire, compositor/session, sandbox, required shared libraries, xinput availability, and permission state in Linux diagnostics. Return actionable UI messages instead of console-only failures.
- On Wayland, map the portal-selected monitor to actual frame metadata. Do not assume `displays[0]` represents the selected screen. Test single/multiple monitors, mixed DPI, negative origins on X11, portal cancellation, stream revocation, suspend/resume, and monitor hotplug.
- Keep Linux minimize/restore/tray behavior in `window-policy.js`; do not branch inside the capture coordinator.
- Apt and dnf runtime dependency lists must be maintained in their separate files and verified in clean Debian/Ubuntu and Fedora containers/VMs. Include Chromium/Electron shared libraries, portal/PipeWire integration, and X11 tools only where needed. Do not install build tools in end-user packages.
- Produce a real `.deb` and `.rpm` from a pruned packaged Electron application. Never copy the development `node_modules` tree. Include architecture mapping (`x64`, `arm64` if supported), icons, desktop entry, categories, MIME registration, license, uninstall behavior, and sandbox-compatible permissions.
- Add Linux artifacts to release CI with checksums/SBOM. Install each artifact in a clean VM/container where possible, launch a smoke screen under Xvfb for X11, and run a real Wayland compositor test job for portal behavior. A package is not accepted merely because `dpkg-deb` or `rpmbuild` produced a file.
Linux acceptance criteria:
- Fresh apt-based and dnf-based systems can follow separate documented setup paths and launch StepForge without `--no-sandbox` or manual npm commands.
- Fullscreen, region, clipboard/import, edit, save, reopen, and export workflows pass on both distro families.
- X11 click capture preserves event time and marker position across DPI/monitors.
- Wayland asks for screen sharing once per recording, handles cancel/revoke, never loops portal prompts, and accurately reports whether the active trigger is hotkey, interval, or an approved click source.
- `.deb` and `.rpm` contain only runtime files and pass install, upgrade, uninstall, dependency, license, desktop-entry, and launch tests.
### Phase 4 — canonical editor/document model
- Migrate to a single ordered block list with text/code/table discriminated types and explicit anchors (`before-title`, `after-title`, `before-description`, `after-description`, `before-image`, `after-image`). Decide whether code/table can use anchors; enforce the decision consistently.
- Refactor the editor into bounded modules: guide state/autosave, step tree, properties form, block editor, annotation controls, capture controls, export dialog, and command history. Avoid framework migration unless it has a measured benefit and an approved dependency cost.
- Replace deprecated `document.execCommand` with an explicit editor model or a small audited implementation. Preserve selection safely, sanitize paste, and implement real link editing instead of inserting `[Text](Link)` placeholders.
- Unify undo/redo around commands and revisions. Include block edits, step metadata, crop/reset, reorder, delete/restore, and AI changes. Do not keep full base64 image copies in renderer history without a bounded disk-backed strategy.
- Make annotation geometry bounded and reusable. Share style/geometry calculations between canvas and raster export. Add rotation/layering only after parity is tested.
- Fix callout placement with exporter matrix fixtures. Add export image sizing controls and saved per-format profiles.
- Add accessibility: semantic buttons, modal roles/names, focus trap and restoration, keyboard traversal, visible focus, screen-reader labels, reduced-motion support, high contrast, and a non-canvas representation of annotations. Run automated accessibility checks plus manual keyboard testing.
- Improve responsive behavior below the current 880px minimum and at 125200% UI scale. Preserve pane sizes and window bounds per platform.
### Phase 5 — storage, performance, and export hardening
- Add explicit schema migration functions and fixture coverage for every historical schema. Back up before migration and make migrations idempotent.
- Add storage integrity scanning: guide/order references, orphan steps/images, duplicate IDs, missing originals/workings, invalid parents, and recoverable temp files. Provide repair/dry-run output.
- Replace repeated synchronous whole-index writes with an incremental, crash-safe index and background reconciliation. Measure search on large libraries.
- Stream archives and exports where practical. At minimum enforce byte/pixel budgets and render/release one step at a time. Add export progress, cancellation, and worker termination cleanup.
- Introduce a canonical layout layer that computes content order and image constraints once. Keep exporters thin.
- Add Unicode-capable text rendering and licensed embedded fonts. Test CJK, RTL, emoji policy, combining marks, smart punctuation, and long unbroken tokens. If a format cannot support a case, fail or document it rather than substituting silently.
- Add reproducible/golden output tests. Normalize timestamps/IDs where required, render PDF/DOCX/PPTX to images in integration CI, and compare meaningful layout rather than only container structure.
- Add stress/fault tests: disk full, permission denied, interrupted atomic rename, corrupted JSON, ZIP bomb, huge PNG, export worker crash, Ollama timeout, capture worker death, rapid app quit, and concurrent saves.
### Phase 6 — packaging, release, and documentation completion
- Use one packaging system/config source for Windows and Linux where possible, with platform-specific files under `packaging/`. Prune production dependencies and generate an SBOM/license notice.
- Add original icons at required resolutions. Sign Windows artifacts before recommending users bypass SmartScreen; sign/package Linux repositories if repository distribution is introduced.
- Build release artifacts from a clean checkout with `npm ci`, fixed toolchain versions, no dirty files, and no network during the packaging stage. Generate checksums and provenance.
- Test upgrade compatibility using real prior-version user data and installed packages.
- Rewrite README, architecture, security, getting-started, Linux apt/dnf, privacy/AI, file format, and troubleshooting docs to match tested behavior. Remove stale “WIP”/“fixed” claims and stale machine-specific build reports.
- Correct spelling/grammar and links, compress oversized documentation screenshots, and keep generated sample outputs either reproducible and CI-verified or out of version control.
## File-specific work map
- `app/main.js`: split lifecycle/IPC/security policy; navigation guards; permission allowlist; sender/input validation; path intents; capture power ownership.
- `app/capture.js`: reduce to platform-neutral session coordinator, then move every OS branch to adapters; fix region result/listener, strict fallback, shutdown drain, explicit trigger state.
- `app/text-intel.js`: split OCR, platform window context, and Ollama client; remove embedded OS scripts; add privacy controls, timeout/cancel/limits.
- `app/stream-backend.js` and worker: authenticated worker-only IPC, selected-display metadata, cancellation, bounded frames/encodes, lifecycle tests.
- `app/renderer/editor.js`: save state machine, revision conflicts, module split, canonical blocks, reliable undo, modern rich text, accessibility.
- `app/renderer/dialogs.js`: typed settings forms, validation, modal focus/ARIA, safe template options, AI disclosure.
- `core/schema.js`: schema v2, strict validation, bounds, migrations, revisions, unified blocks.
- `core/store.js`: transactions, corruption quarantine, async/heavy-operation strategy, integrity scan, conflict-aware patches.
- `core/archive.js`, `core/zip.js`, `core/snapshots.js`, `core/locks.js`: resource limits, temp validation/atomic swap, exclusive locks/revisions, rollback tests.
- `core/search.js`: startup reconciliation, incremental persistence, visible recovery.
- `core/renderast.js`, `core/raster.js`, `core/pdf.js`: canonical layout, lazy image rendering, WYSIWYG parity, Unicode/font work, resource limits.
- `exporters/*`: typed option schemas, safe escaping, streaming/lazy images, consistent anchors/image sizing, external conformance tests.
- `scripts/electron-launcher.js`: diagnostics only; never install or weaken production sandbox.
- `scripts/package-windows.js`: exact installer selection, assets, signing hooks, clean artifact verification.
- `.github/workflows/*` and `.gitea/workflows/*`: PR triggers, authoritative test commands, Linux distro/package matrix, non-masking E2E behavior, release artifacts/provenance.
- `README.md`, `docs/*`, `package.json`, root `LICENSE`: reconcile support, dependencies, AI/network/privacy, version, license, and build instructions.
## Required test layers
1. **Pure unit tests:** schema/migrations, storage transactions, sanitizer/URLs, archive limits, frame selection, platform parsers, layout calculations, exporter escaping.
2. **IPC contract tests:** instantiate handlers with fake senders and prove invalid origins, paths, sizes, and payloads are rejected. Do not rely on regex extraction alone.
3. **Renderer tests:** save failures/retries, navigation with dirty state, capture-added and AI races, block placement, modals/focus, keyboard and accessibility.
4. **Desktop E2E:** launch packaged/unpackaged app, create/capture/import/edit/save/restart/export. Separate Windows, Linux X11, and Linux Wayland scenarios with explicit capability expectations.
5. **Artifact tests:** install/launch/uninstall `.exe`, `.deb`, and `.rpm`; inspect file lists and dependencies; verify sandbox, icons, desktop integration, version, license, and clean upgrades.
6. **External output tests:** open/render PDF, DOCX, PPTX, HTML, GIF, and images with independent tools; include visual fixtures and multilingual content.
7. **Security/fault tests:** hostile navigation, malicious HTML/template/archive, ZIP bomb budgets, arbitrary IPC paths, permission denial, disk failures, worker crashes, stale AI responses, and captured-secret prevention.
## Definition of done
- No release-blocking security or license contradiction remains.
- No production launch path downloads dependencies or uses `--no-sandbox` by default.
- User edits remain visibly dirty until durably saved; injected failures and concurrent AI/capture updates do not lose data.
- Automatic backups, restore, archive import, and linked saves are transactional and tested.
- Windows, apt-based Linux, and dnf-based Linux use separate platform/setup/package files and pass their documented capability matrices.
- Linux `.deb` and `.rpm` install and launch from clean systems with only runtime dependencies.
- Region capture, callout placement, image sizing, Unicode, large-guide exports, and click-session shutdown have regression tests.
- CI runs on pull requests, cannot hide startup crashes as skips, and tests the same commands documented for contributors.
- README, Security, Architecture, Privacy/AI, support matrix, package metadata, changelog, and license all describe the shipping application accurately.
## Recommended PR sequence
1. Reproducible toolchain/CI and test-runner truthfulness.
2. Navigation/IPC/permission security boundary.
3. Privacy and AI/network contract.
4. Revision-aware autosave and transactional storage.
5. Region capture, power/session state, and shutdown fixes.
6. Archive/snapshot/lock/search recovery hardening.
7. Platform interface extraction with Windows behavior preserved.
8. Linux apt/X11 implementation and `.deb` packaging.
9. Linux dnf/X11 implementation and `.rpm` packaging.
10. Linux Wayland portal implementation and honest fallback behavior.
11. Canonical blocks/callout placement and image sizing.
12. Lazy exports, Unicode rendering, and external conformance tests.
13. Editor modularization/accessibility/UX polish.
14. Signed, reproducible release pipeline and final documentation reconciliation.
Do not combine these into one PR. Each PR must include migration/rollback notes where user data or package layout changes, automated tests proportional to risk, and a short manual verification matrix for the affected operating systems.
-218
View File
@@ -1,218 +0,0 @@
{
"format": "stepforge-artifacts-manifest",
"version": 1,
"generatedAt": "2026-06-11T21:54:13.294Z",
"packageVersion": "0.1.0",
"files": [
{
"kind": "artifact",
"path": "artifacts/stepforge_0.1.0_amd64.deb",
"size": 103691640,
"sha256": "320faa345f5997905fdc831c045dbe243490a7b4328680d105934f2aec1b4ffb"
},
{
"kind": "artifact",
"path": "artifacts/stepforge_0.1.0_linux-x64.tar.gz",
"size": 139378628,
"sha256": "32971595d4df40b429cb41ba644e57b84351ec1239b4bbe8d5b82fe3b01a4cc9"
},
{
"kind": "sample",
"path": "../examples/sample-data/library/guides/guide-sample-reset-password/guide.json",
"size": 843,
"sha256": "0d7760246d96a85a4d79c15c1ab1a7e481bd79e1d8ff29d177ac6d35ffe6cde6"
},
{
"kind": "sample",
"path": "../examples/sample-data/library/guides/guide-sample-reset-password/steps/step-sample-01-open-users/original.png",
"size": 13643,
"sha256": "09e12f935511bb6fabb5637501aa7743516b96d990adfab62ccfa311a7b60606"
},
{
"kind": "sample",
"path": "../examples/sample-data/library/guides/guide-sample-reset-password/steps/step-sample-01-open-users/step.json",
"size": 1593,
"sha256": "36deb8a8b51952a668e871a03b1c7fba2aaf72b1722fd2d88c7e5d5cbbd8da91"
},
{
"kind": "sample",
"path": "../examples/sample-data/library/guides/guide-sample-reset-password/steps/step-sample-01-open-users/working.png",
"size": 13643,
"sha256": "09e12f935511bb6fabb5637501aa7743516b96d990adfab62ccfa311a7b60606"
},
{
"kind": "sample",
"path": "../examples/sample-data/library/guides/guide-sample-reset-password/steps/step-sample-02-enable-policy/original.png",
"size": 14031,
"sha256": "b5e93a0ee74e2bdbbdf0871e901726dfbdc8b45dd648c959743520f92b02e7a2"
},
{
"kind": "sample",
"path": "../examples/sample-data/library/guides/guide-sample-reset-password/steps/step-sample-02-enable-policy/step.json",
"size": 1873,
"sha256": "4b0b3b74f851d034a2769c69df2e9d0428373ae15009fb3e2100afb5c10f7ebb"
},
{
"kind": "sample",
"path": "../examples/sample-data/library/guides/guide-sample-reset-password/steps/step-sample-02-enable-policy/working.png",
"size": 14031,
"sha256": "b5e93a0ee74e2bdbbdf0871e901726dfbdc8b45dd648c959743520f92b02e7a2"
},
{
"kind": "sample",
"path": "../examples/sample-data/library/guides/guide-sample-reset-password/steps/step-sample-02a-permission-prompt/step.json",
"size": 763,
"sha256": "e8539addbe730e3a1baaa6898bf9779d2ce80564aecb3f196619d8c7ff67cbae"
},
{
"kind": "sample",
"path": "../examples/sample-data/library/guides/guide-sample-reset-password/steps/step-sample-03-review-confirmation/original.png",
"size": 13602,
"sha256": "c96eedffdc5fd2eb9b63942cc00f1c8a91d01a0c5c2316c1d12d750b9b49e3d0"
},
{
"kind": "sample",
"path": "../examples/sample-data/library/guides/guide-sample-reset-password/steps/step-sample-03-review-confirmation/step.json",
"size": 1968,
"sha256": "6f1cbb9d2ed32b89dec3d221822c6e973dbba42c11e93c2b35499179e8481532"
},
{
"kind": "sample",
"path": "../examples/sample-data/library/guides/guide-sample-reset-password/steps/step-sample-03-review-confirmation/working.png",
"size": 13602,
"sha256": "c96eedffdc5fd2eb9b63942cc00f1c8a91d01a0c5c2316c1d12d750b9b49e3d0"
},
{
"kind": "sample",
"path": "../examples/sample-data/library/guides/guide-sample-reset-password/steps/step-sample-04-legacy-note/step.json",
"size": 506,
"sha256": "c6f78405f86f4183612f5865820b2454471cbc47975e9c15e055e7bf742b1eba"
},
{
"kind": "sample",
"path": "../examples/sample-data/library/guides/guide-sample-reset-password/steps/step-sample-05-deprecated-flow/step.json",
"size": 536,
"sha256": "709887c0a5debddc851216920ee3f6f2766e986059b21ac399e2533611ab5aed"
},
{
"kind": "sample",
"path": "../examples/sample-exports/docx/reset-a-password-in-admin-portal.docx",
"size": 110463,
"sha256": "be9e2b550b732fc6cd4a171b72749b49fe67730ee9969f69c23175688b0bea14"
},
{
"kind": "sample",
"path": "../examples/sample-exports/gif/reset-a-password-in-admin-portal.gif",
"size": 32090,
"sha256": "70a789c6ce1aa6154c65d0ee88a286d888fdfd9fd5c3045a14242e13df5ca263"
},
{
"kind": "sample",
"path": "../examples/sample-exports/html-rich/reset-a-password-in-admin-portal-rich.html",
"size": 149884,
"sha256": "70695d55c37d69abb191e65675f726eaa7aea6686a15a2e7d387195441e3ca8c"
},
{
"kind": "sample",
"path": "../examples/sample-exports/html-simple/reset-a-password-in-admin-portal.html",
"size": 146646,
"sha256": "860c774b9b8bfd821b22deadc21f8021dc66bc9cacf43702eecbf55e944c6a9a"
},
{
"kind": "sample",
"path": "../examples/sample-exports/image-bundle/reset-a-password-in-admin-portal-bundle.json",
"size": 779,
"sha256": "0108a63331925ea1fdd03326dd37dce1af3642a02d167e583d0d67b50fce27fa"
},
{
"kind": "sample",
"path": "../examples/sample-exports/image-bundle/steps-reset-a-password-in-admin-portal/001-open-admin-portal-users.png",
"size": 31424,
"sha256": "892a3174e9876ebfab4c879d6d579cf8ad6eba2f299ad95feff33adaf205e042"
},
{
"kind": "sample",
"path": "../examples/sample-exports/image-bundle/steps-reset-a-password-in-admin-portal/002-enable-the-reset-policy.png",
"size": 38991,
"sha256": "f1c79fb2baa1ef41dd85b04be59cc796f3822858654612044a22e27f20d0696a"
},
{
"kind": "sample",
"path": "../examples/sample-exports/image-bundle/steps-reset-a-password-in-admin-portal/004-review-the-confirmation.png",
"size": 37274,
"sha256": "32c5e15f8a04d6af01b7d139cff732872d5ce7e3e519be6ece99cca4167856ed"
},
{
"kind": "sample",
"path": "../examples/sample-exports/json/reset-a-password-in-admin-portal.json",
"size": 6740,
"sha256": "4a27376e75c3bb33fa8e1c4117c3314a04ec4766d67a9b5c729180d116d384d1"
},
{
"kind": "sample",
"path": "../examples/sample-exports/json/steps-reset-a-password-in-admin-portal/001-open-admin-portal-users.png",
"size": 31424,
"sha256": "892a3174e9876ebfab4c879d6d579cf8ad6eba2f299ad95feff33adaf205e042"
},
{
"kind": "sample",
"path": "../examples/sample-exports/json/steps-reset-a-password-in-admin-portal/002-enable-the-reset-policy.png",
"size": 38991,
"sha256": "f1c79fb2baa1ef41dd85b04be59cc796f3822858654612044a22e27f20d0696a"
},
{
"kind": "sample",
"path": "../examples/sample-exports/json/steps-reset-a-password-in-admin-portal/004-review-the-confirmation.png",
"size": 37274,
"sha256": "32c5e15f8a04d6af01b7d139cff732872d5ce7e3e519be6ece99cca4167856ed"
},
{
"kind": "sample",
"path": "../examples/sample-exports/markdown/reset-a-password-in-admin-portal.md",
"size": 1186,
"sha256": "16bbd2eb8850d8a55914abe9ece4d2aee465820fcd6c5ffde8925f37dd2b115b"
},
{
"kind": "sample",
"path": "../examples/sample-exports/markdown/steps-reset-a-password-in-admin-portal/001-open-admin-portal-users.png",
"size": 31424,
"sha256": "892a3174e9876ebfab4c879d6d579cf8ad6eba2f299ad95feff33adaf205e042"
},
{
"kind": "sample",
"path": "../examples/sample-exports/markdown/steps-reset-a-password-in-admin-portal/002-enable-the-reset-policy.png",
"size": 38991,
"sha256": "f1c79fb2baa1ef41dd85b04be59cc796f3822858654612044a22e27f20d0696a"
},
{
"kind": "sample",
"path": "../examples/sample-exports/markdown/steps-reset-a-password-in-admin-portal/004-review-the-confirmation.png",
"size": 37274,
"sha256": "32c5e15f8a04d6af01b7d139cff732872d5ce7e3e519be6ece99cca4167856ed"
},
{
"kind": "sample",
"path": "../examples/sample-exports/pdf/reset-a-password-in-admin-portal.pdf",
"size": 103120,
"sha256": "58a952a2f95653a91d4e662e4bc2ddcb0177de33e51a8f9e454cdf158ba3a795"
},
{
"kind": "sample",
"path": "../examples/sample-exports/pptx/reset-a-password-in-admin-portal.pptx",
"size": 117643,
"sha256": "4ce1e82903b726c549e53235e3fd4c70cf647123ccd823052030e2e1b9449864"
},
{
"kind": "sample",
"path": "../examples/sample-guide.sfgz",
"size": 88427,
"sha256": "313b88f48e53e5ad7fb4e0a8189700ba0f6be642ec2311b1a2fe7ea4f3dd0481"
},
{
"kind": "sample",
"path": "../examples/sample-manifest.json",
"size": 1163,
"sha256": "cb2920e7500758074f1f54867db1ed187d3304c8badbceb06fd611057bd6fe5d"
}
]
}
-43
View File
@@ -1,43 +0,0 @@
# StepForge Build Report
Version: 0.1.0
Generated: 2026-06-11T21:54:13.292Z
Host: linux x64 (node v20.20.2)
## Outputs
- Portable tarball: artifacts/stepforge_0.1.0_linux-x64.tar.gz
- Debian package: artifacts/stepforge_0.1.0_amd64.deb
- Sample guide archive: ../examples/sample-guide.sfgz
- Sample exports (9 formats): see examples/sample-exports/
- Full artifact list with sha256 checksums: artifacts_manifest.json
## Packaging tool availability
| Tool | Status |
|---|---|
| dpkg-deb (Linux .deb) | available |
| rpmbuild (Linux .rpm) | **missing** |
| appimagetool (Linux AppImage) | **missing** |
| makensis (Windows installer .exe) | **missing** |
| wixl / WiX (Windows .msi) | **missing** |
Fallback policy: when a packaging tool is missing the build still produces
the runnable app (portable tarball with launcher) plus whatever package
formats the available tools allow. Windows artifacts are produced by
`npm run package:windows` (electron-builder, portable .exe); .msi/.rpm/
AppImage require the tools listed above and are skipped on this host.
## Offline guarantee
- The shipped app opens no sockets: no telemetry, update checks, license
checks, cloud sync, or remote AI. See docs/SECURITY.md.
- All exporters (PNG/GIF/PDF/DOCX/PPTX/ZIP) are implemented in-repo with
Node built-ins; Electron is the only third-party dependency
(dev-time fetch recorded in build/agent_audit.md).
## Verification
- `bash tests/run_test.sh` runs the workflow suites (node --test), a
startup smoke test of the Electron launcher, the sample-artifact
pipeline, and this release build.
+8 -2
View File
@@ -11,13 +11,19 @@ For the windows installation, please see [windows_installation](windows_installa
## 1. Install ## 1. Install
Install the pinned Node toolchain first — Node 22.12 or newer (see
`.nvmrc`; with nvm: `nvm install && nvm use`). Installs are refused on
older Nodes.
From the repository root: From the repository root:
```bash ```bash
npm install npm ci
``` ```
That installs Electron and the local packaging tools used by the scripts. That installs the locked dependency tree — Electron and the local packaging
tools used by the scripts. `npm ci` is the only supported installation path;
the app never installs or repairs dependencies at runtime.
## 2. Launch the app ## 2. Launch the app
+11 -8
View File
@@ -15,6 +15,9 @@
"devDependencies": { "devDependencies": {
"electron": "^41.7.1", "electron": "^41.7.1",
"electron-builder": "^26.15.2" "electron-builder": "^26.15.2"
},
"engines": {
"node": ">=22.12.0"
} }
}, },
"node_modules/@electron/asar": { "node_modules/@electron/asar": {
@@ -2137,17 +2140,17 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.5", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2", "hasown": "^2.0.4",
"mime-types": "^2.1.12" "mime-types": "^2.1.35"
}, },
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -3910,9 +3913,9 @@
} }
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "6.26.0", "version": "6.27.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
"integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
+3
View File
@@ -7,6 +7,9 @@
"author": "StepForge [email protected]", "author": "StepForge [email protected]",
"license": "MPL-2.0", "license": "MPL-2.0",
"private": true, "private": true,
"engines": {
"node": ">=22.12.0"
},
"scripts": { "scripts": {
"start": "node scripts/start-electron.js", "start": "node scripts/start-electron.js",
"test": "node scripts/run-unit-tests.js", "test": "node scripts/run-unit-tests.js",
+79
View File
@@ -0,0 +1,79 @@
'use strict';
// Hard prerequisite check for the supported Node toolchain.
//
// The locked dependency graph (notably the electron-builder packaging
// toolchain) requires Node >= 22.12. Older Nodes fail late with confusing
// errors (ERR_REQUIRE_ESM deep inside dependencies) instead of a clear
// message, so every entry point calls this first.
const fs = require('node:fs');
const path = require('node:path');
function parseVersion(version) {
const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(version).trim());
if (!match) return null;
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
function compareVersions(a, b) {
for (let i = 0; i < 3; i += 1) {
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
}
return 0;
}
function requiredNodeVersion(projectRoot = path.join(__dirname, '..')) {
const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
const range = pkg.engines && pkg.engines.node ? String(pkg.engines.node) : null;
if (!range) return null;
const match = /(\d+\.\d+\.\d+)/.exec(range);
return match ? match[1] : null;
}
function checkNodeVersion({
currentVersion = process.versions.node,
projectRoot = path.join(__dirname, '..'),
} = {}) {
const required = requiredNodeVersion(projectRoot);
if (!required) return { ok: true, required: null, current: currentVersion };
const current = parseVersion(currentVersion);
const minimum = parseVersion(required);
if (!current || !minimum) return { ok: true, required, current: currentVersion };
return {
ok: compareVersions(current, minimum) >= 0,
required,
current: currentVersion,
};
}
function assertSupportedNode(options = {}) {
const result = checkNodeVersion(options);
if (result.ok) return result;
const message = [
`StepForge requires Node ${result.required} or newer; this is Node ${result.current}.`,
'',
'Install the pinned toolchain (see .nvmrc) and reinstall dependencies:',
'',
' nvm install && nvm use # or install Node 22 LTS another way',
' npm ci',
'',
'Older Nodes fail unpredictably inside the packaging dependency graph,',
'so this check stops early instead.',
].join('\n');
const error = new Error(message);
error.code = 'STEPFORGE_UNSUPPORTED_NODE';
throw error;
}
module.exports = {
assertSupportedNode,
checkNodeVersion,
compareVersions,
parseVersion,
requiredNodeVersion,
};
+93 -185
View File
@@ -1,6 +1,14 @@
'use strict'; 'use strict';
const { spawnSync } = require('node:child_process'); // Diagnostics-only Electron launcher helpers.
//
// This module never installs, rebuilds, or repairs dependencies at runtime.
// The only supported dependency installation path is `npm ci` on the pinned
// Node toolchain (see .nvmrc / package.json engines). A desktop launcher that
// mutates node_modules silently drifts away from package-lock.json and can
// download code at runtime; when the runtime is missing we fail with
// actionable diagnostics instead.
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
@@ -60,24 +68,83 @@ function sanitizeElectronEnv(baseEnv = process.env) {
return env; return env;
} }
// True only when the caller has explicitly marked this as a development or
// CI environment where launching without the Chromium sandbox is acceptable.
function noSandboxExplicitlyAllowed(env = process.env) {
return env.STEPFORGE_ALLOW_NO_SANDBOX === '1' || env.ELECTRON_DISABLE_SANDBOX === '1';
}
function sandboxHelperUsable(electronPath, statSync = fs.statSync) {
if (!electronPath) return false;
const helperPath = path.join(path.dirname(electronPath), 'chrome-sandbox');
try {
const stat = statSync(helperPath);
return stat.uid === 0 && Boolean(stat.mode & 0o4000);
} catch {
return false;
}
}
// Decide how to launch on Linux with respect to the Chromium sandbox.
// { args: [] } sandbox is available, launch normally
// { args: ['--no-sandbox'] } explicitly allowed dev/CI launch
// throws sandbox unavailable and not explicitly
// allowed: refuse to normalize an
// unsandboxed launch, explain how to fix it
function linuxSandboxLaunchArgs({ function linuxSandboxLaunchArgs({
electronPath, electronPath,
platform = process.platform, platform = process.platform,
statSync = fs.statSync, statSync = fs.statSync,
env = process.env,
userNamespaces = userNamespacesAvailable,
} = {}) { } = {}) {
if (platform !== 'linux') return []; if (platform !== 'linux') return [];
if (!electronPath) return ['--no-sandbox'];
const helperPath = path.join(path.dirname(electronPath), 'chrome-sandbox'); // Modern kernels with unprivileged user namespaces do not need the setuid
// helper; Chromium falls back to the namespace sandbox on its own. The
// setuid helper check below covers kernels where that is disabled.
if (sandboxHelperUsable(electronPath, statSync)) return [];
if (userNamespaces()) return [];
if (noSandboxExplicitlyAllowed(env)) return ['--no-sandbox'];
const helperPath = electronPath
? path.join(path.dirname(electronPath), 'chrome-sandbox')
: '<node_modules/electron/dist>/chrome-sandbox';
throw new Error(
[
'The Chromium sandbox is not available on this system, and StepForge',
'refuses to silently launch unsandboxed.',
'',
'Fix one of the following:',
` 1. Make the setuid sandbox helper usable:`,
` sudo chown root:root "${helperPath}"`,
` sudo chmod 4755 "${helperPath}"`,
' 2. Enable unprivileged user namespaces (kernel/sysctl dependent):',
' sudo sysctl -w kernel.unprivileged_userns_clone=1',
'',
'For development or CI only, you may explicitly opt in to an',
'unsandboxed launch with STEPFORGE_ALLOW_NO_SANDBOX=1.',
].join('\n')
);
}
function userNamespacesAvailable() {
try { try {
const stat = statSync(helperPath); // Debian/Ubuntu specific knob; absent elsewhere (treated as enabled).
const ownedByRoot = stat.uid === 0; const knob = '/proc/sys/kernel/unprivileged_userns_clone';
const hasSetuid = Boolean(stat.mode & 0o4000); if (fs.existsSync(knob)) {
if (ownedByRoot && hasSetuid) return []; return fs.readFileSync(knob, 'utf8').trim() === '1';
}
// Ubuntu 23.10+ AppArmor restriction on unprivileged user namespaces.
const apparmorKnob = '/proc/sys/kernel/apparmor_restrict_unprivileged_userns';
if (fs.existsSync(apparmorKnob)) {
return fs.readFileSync(apparmorKnob, 'utf8').trim() === '0';
}
return fs.existsSync('/proc/self/ns/user');
} catch { } catch {
// Missing or unreadable helper: fall back to the unsandboxed launcher. return false;
} }
return ['--no-sandbox'];
} }
function electronBinaryCandidates({ packageRoot, distDir, platform }) { function electronBinaryCandidates({ packageRoot, distDir, platform }) {
@@ -95,118 +162,21 @@ function electronBinaryCandidates({ packageRoot, distDir, platform }) {
return candidatePaths; return candidatePaths;
} }
function runNpmCommand({
packageRoot,
npmArgs,
errorLabel,
npmExecPath = process.env.npm_execpath || null,
npmNodeExecPath = process.env.npm_node_execpath || process.execPath,
}) {
if (!npmExecPath) {
return false;
}
const result = spawnSync(npmNodeExecPath, [npmExecPath, ...npmArgs], {
cwd: packageRoot,
env: sanitizeElectronEnv(),
stdio: 'inherit',
});
if (result.error) {
throw result.error;
}
if (result.signal) {
throw new Error(`${errorLabel} was interrupted by ${result.signal}`);
}
if (result.status !== 0) {
throw new Error(`${errorLabel} failed with exit code ${result.status ?? 1}`);
}
return true;
}
function runNpmRebuild({
packageRoot,
npmExecPath = process.env.npm_execpath || null,
npmNodeExecPath = process.env.npm_node_execpath || process.execPath,
}) {
return runNpmCommand({
packageRoot,
npmArgs: ['rebuild', 'electron', '--force', '--foreground-scripts'],
errorLabel: 'Electron rebuild',
npmExecPath,
npmNodeExecPath,
});
}
function runNpmInstall({
packageRoot,
npmExecPath = process.env.npm_execpath || null,
npmNodeExecPath = process.env.npm_node_execpath || process.execPath,
}) {
return runNpmCommand({
packageRoot,
npmArgs: [
'install',
'--include=dev',
'--ignore-scripts=false',
'--foreground-scripts',
'--no-audit',
'--no-fund',
'--package-lock=false',
],
errorLabel: 'Electron dependency install',
npmExecPath,
npmNodeExecPath,
});
}
function repairElectronInstall({
packageRoot,
}) {
const installScript = path.join(packageRoot, 'install.js');
if (!fs.existsSync(installScript)) {
return false;
}
const result = spawnSync(process.execPath, [installScript], {
cwd: packageRoot,
env: sanitizeElectronEnv(),
stdio: 'inherit',
});
if (result.error) {
throw result.error;
}
if (result.signal) {
throw new Error(`Electron repair was interrupted by ${result.signal}`);
}
if (result.status !== 0) {
throw new Error(`Electron repair failed with exit code ${result.status ?? 1}`);
}
return true;
}
function buildMissingElectronError({ packageRoot, distDir, candidatePaths }) { function buildMissingElectronError({ packageRoot, distDir, candidatePaths }) {
const tried = candidatePaths.map((candidate) => ` - ${candidate}`).join('\n'); const tried = (candidatePaths || []).map((candidate) => ` - ${candidate}`).join('\n');
return [ return [
'Electron could not be started because the desktop runtime is missing.', 'Electron could not be started because the desktop runtime is missing.',
'', '',
`Looked under: ${packageRoot}`, `Looked under: ${packageRoot || '(electron package not installed)'}`,
`Expected the binary in: ${distDir}`, `Expected the binary in: ${distDir || '(unknown)'}`,
'', '',
'Try reinstalling dependencies from the repo root:', 'StepForge never installs dependencies at runtime. Reinstall them from',
'the repo root on the pinned Node toolchain (see .nvmrc):',
'', '',
' npm install', ' npm ci',
' npm rebuild electron --force --foreground-scripts',
' make sure ELECTRON_SKIP_BINARY_DOWNLOAD is not set',
'', '',
'If that does not help, delete node_modules/electron and install again.', 'Make sure ELECTRON_SKIP_BINARY_DOWNLOAD is not set while installing.',
'If the problem persists, delete node_modules entirely and run npm ci again.',
'', '',
'Searched:', 'Searched:',
tried, tried,
@@ -219,99 +189,37 @@ function resolveElectronBinary({
platform = process.platform, platform = process.platform,
overrideDistPath = process.env.ELECTRON_OVERRIDE_DIST_PATH || null, overrideDistPath = process.env.ELECTRON_OVERRIDE_DIST_PATH || null,
} = {}) { } = {}) {
const repairErrors = []; if (!packageRoot) {
function resolveCurrentPackageRoot() {
if (packageRoot) return packageRoot;
const conventionalRoot = path.join(projectRoot, 'node_modules', 'electron'); const conventionalRoot = path.join(projectRoot, 'node_modules', 'electron');
if (fs.existsSync(path.join(conventionalRoot, 'package.json'))) { if (fs.existsSync(path.join(conventionalRoot, 'package.json'))) {
packageRoot = conventionalRoot; packageRoot = conventionalRoot;
return packageRoot;
} }
packageRoot = resolveElectronPackageRoot();
return packageRoot;
} }
function tryRepair(label, repairFn) { if (!packageRoot && !overrideDistPath) {
try {
if (!repairFn()) {
return null;
}
} catch (error) {
repairErrors.push(`${label}: ${error && error.message ? error.message : String(error)}`);
return null;
}
const currentPackageRoot = resolveCurrentPackageRoot();
if (!currentPackageRoot && !overrideDistPath) {
return null;
}
const distDir = overrideDistPath || path.join(currentPackageRoot, 'dist');
return electronBinaryCandidates({ packageRoot: currentPackageRoot, distDir, platform }).find((candidate) =>
fs.existsSync(candidate)
);
}
let currentPackageRoot = resolveCurrentPackageRoot();
if (!currentPackageRoot && !overrideDistPath) {
const installed = tryRepair('Electron dependency install', () =>
runNpmInstall({ packageRoot: projectRoot })
);
if (installed) {
return installed;
}
currentPackageRoot = resolveCurrentPackageRoot();
}
if (!currentPackageRoot && !overrideDistPath) {
throw new Error( throw new Error(
'Electron could not be started because node_modules/electron is not installed.\n\n' + 'Electron could not be started because node_modules/electron is not installed.\n\n' +
'Run `npm install` from the repo root, then try `npm start` again.' 'StepForge never installs dependencies at runtime. Run `npm ci` from the\n' +
'repo root on the pinned Node toolchain (see .nvmrc), then try again.'
); );
} }
const distDir = overrideDistPath || path.join(currentPackageRoot, 'dist'); const distDir = overrideDistPath || path.join(packageRoot, 'dist');
let candidatePaths = electronBinaryCandidates({ packageRoot: currentPackageRoot, distDir, platform }); const candidatePaths = electronBinaryCandidates({ packageRoot, distDir, platform });
let resolved = candidatePaths.find((candidate) => fs.existsSync(candidate)); const resolved = candidatePaths.find((candidate) => fs.existsSync(candidate));
if (resolved) { if (resolved) {
return resolved; return resolved;
} }
const repairAttempts = [ throw new Error(buildMissingElectronError({ packageRoot, distDir, candidatePaths }));
['Electron rebuild', () => runNpmRebuild({ packageRoot: currentPackageRoot })],
['Electron install repair', () => repairElectronInstall({ packageRoot: currentPackageRoot })],
['Electron dependency install', () => runNpmInstall({ packageRoot: projectRoot })],
];
for (const [label, repairFn] of repairAttempts) {
const repaired = tryRepair(label, repairFn);
if (repaired) {
return repaired;
}
}
throw new Error(
buildMissingElectronError({
packageRoot: currentPackageRoot,
distDir,
candidatePaths,
}) +
(repairErrors.length
? `\n\nAutomatic repair attempts failed:\n${repairErrors.map((error) => ` - ${error}`).join('\n')}`
: '')
);
} }
module.exports = { module.exports = {
buildMissingElectronError, buildMissingElectronError,
electronBinaryCandidates, electronBinaryCandidates,
readElectronPathHint, readElectronPathHint,
repairElectronInstall,
runNpmRebuild,
runNpmInstall,
sanitizeElectronEnv, sanitizeElectronEnv,
noSandboxExplicitlyAllowed,
linuxSandboxLaunchArgs, linuxSandboxLaunchArgs,
resolveElectronBinary, resolveElectronBinary,
resolveElectronPackageRoot, resolveElectronPackageRoot,
+9
View File
@@ -4,6 +4,15 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { spawnSync } = require('node:child_process'); const { spawnSync } = require('node:child_process');
const { assertSupportedNode } = require('./check-node-version');
try {
assertSupportedNode();
} catch (error) {
console.error(error.message);
process.exit(1);
}
function collectTestFiles(rootDir) { function collectTestFiles(rootDir) {
const files = []; const files = [];
if (!fs.existsSync(rootDir)) return files; if (!fs.existsSync(rootDir)) return files;
+8 -2
View File
@@ -3,6 +3,7 @@
const { spawn } = require('node:child_process'); const { spawn } = require('node:child_process');
const { assertSupportedNode } = require('./check-node-version');
const { const {
linuxSandboxLaunchArgs, linuxSandboxLaunchArgs,
resolveElectronBinary, resolveElectronBinary,
@@ -10,16 +11,21 @@ const {
} = require('./electron-launcher'); } = require('./electron-launcher');
let electronPath; let electronPath;
let sandboxArgs;
try { try {
assertSupportedNode();
electronPath = resolveElectronBinary(); electronPath = resolveElectronBinary();
sandboxArgs = linuxSandboxLaunchArgs({ electronPath });
} catch (error) { } catch (error) {
console.error(error && error.message ? error.message : error); console.error(error && error.message ? error.message : error);
process.exit(1); process.exit(1);
} }
const env = sanitizeElectronEnv(); const env = sanitizeElectronEnv();
const sandboxArgs = linuxSandboxLaunchArgs({ electronPath });
if (sandboxArgs.includes('--no-sandbox')) { if (sandboxArgs.includes('--no-sandbox')) {
console.warn('[stepforge] Electron sandbox helper is not configured for this install; starting with --no-sandbox'); console.warn(
'[stepforge] launching WITHOUT the Chromium sandbox (explicitly allowed via ' +
'STEPFORGE_ALLOW_NO_SANDBOX/ELECTRON_DISABLE_SANDBOX — development/CI only)'
);
} }
// On Linux, prefer the native Ozone path when available and enable PipeWire- // On Linux, prefer the native Ozone path when available and enable PipeWire-
+20 -6
View File
@@ -13,14 +13,22 @@
# arm: warmup click ignored, first armed click captured # arm: warmup click ignored, first armed click captured
# debounce: 4 of 4 (40ms burst collapses to 1, three 300ms clicks kept) # debounce: 4 of 4 (40ms burst collapses to 1, three 300ms clicks kept)
# #
# If the environment can't run a desktop capture at all (no display/stream), # Skip policy (kept honest on purpose): the ONLY allowed skip is the upfront
# the scenarios never print, so the check skips rather than failing CI. # absence of a display server, detected BEFORE launching. Once the app is
# launched, failing to reach the scenarios is a real failure — a startup
# crash (missing shared library, launcher bug) must never be reported as
# "no capture environment".
set -euo pipefail set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR" cd "$ROOT_DIR"
if [[ "$(uname -s)" == "Linux" && -z "${DISPLAY:-}" && -z "${WAYLAND_DISPLAY:-}" ]]; then
echo "click capture selftest SKIPPED: no display server (set DISPLAY or run under xvfb-run)"
exit 0
fi
TMP_ROOT="$(mktemp -d)" TMP_ROOT="$(mktemp -d)"
trap 'rm -rf "$TMP_ROOT"' EXIT trap 'rm -rf "$TMP_ROOT"' EXIT
@@ -30,11 +38,17 @@ STEPFORGE_DATA_DIR="$TMP_ROOT/data" STEPFORGE_CLICK_SELFTEST=1 \
timeout 120s npm start >"$LOG_FILE" 2>&1 timeout 120s npm start >"$LOG_FILE" 2>&1
set -e set -e
# The self-test always prints this first line once it begins; without it the # The self-test always prints this line once the app is up (the frame source
# app never reached the scenarios (couldn't launch / no capture environment). # is printed as a diagnostic even when no capture backend is available).
# Its absence means the app never started — that is a failure, not a skip.
if ! grep -q 'CLICK-SELFTEST source:' "$LOG_FILE"; then if ! grep -q 'CLICK-SELFTEST source:' "$LOG_FILE"; then
echo "click capture selftest SKIPPED (no capture environment on this host)" echo "click capture selftest FAILED: the app never reached the self-test scenarios" >&2
exit 0 if grep -Eq 'error while loading shared libraries' "$LOG_FILE"; then
echo "cause: Electron is missing system shared libraries on this host" >&2
fi
echo "----- startup output (last 40 lines) -----" >&2
tail -n 40 "$LOG_FILE" >&2
exit 1
fi fi
fail() { fail() {
+7
View File
@@ -7,6 +7,13 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR" cd "$ROOT_DIR"
# The only allowed skip is the upfront absence of a display server. Any
# failure after launch (missing shared library, crash) must fail the check.
if [[ "$(uname -s)" == "Linux" && -z "${DISPLAY:-}" && -z "${WAYLAND_DISPLAY:-}" ]]; then
echo "startup smoke SKIPPED: no display server (set DISPLAY or run under xvfb-run)"
exit 0
fi
TMP_ROOT="$(mktemp -d)" TMP_ROOT="$(mktemp -d)"
trap 'rm -rf "$TMP_ROOT"' EXIT trap 'rm -rf "$TMP_ROOT"' EXIT
+93 -126
View File
@@ -8,9 +8,10 @@ const path = require('node:path');
const { const {
buildMissingElectronError, buildMissingElectronError,
linuxSandboxLaunchArgs, linuxSandboxLaunchArgs,
repairElectronInstall, noSandboxExplicitlyAllowed,
resolveElectronBinary, resolveElectronBinary,
} = require('../../scripts/electron-launcher'); } = require('../../scripts/electron-launcher');
const { checkNodeVersion, assertSupportedNode } = require('../../scripts/check-node-version');
const { makeTmpDir, rmrf } = require('./helpers'); const { makeTmpDir, rmrf } = require('./helpers');
test('resolves the Electron binary from path.txt when present', (t) => { test('resolves the Electron binary from path.txt when present', (t) => {
@@ -40,146 +41,92 @@ test('falls back to the platform binary when path.txt is absent', (t) => {
); );
}); });
test('repairs a broken Electron install before resolving the binary', (t) => { test('never runs npm when the runtime is missing: fails with npm ci diagnostics', (t) => {
const root = makeTmpDir('electron-repair');
t.after(() => rmrf(root));
fs.mkdirSync(path.join(root, 'dist'), { recursive: true });
fs.writeFileSync(
path.join(root, 'install.js'),
[
"const fs = require('node:fs');",
"const path = require('node:path');",
"if (process.env.ELECTRON_SKIP_BINARY_DOWNLOAD) process.exit(2);",
"fs.mkdirSync(path.join(__dirname, 'dist'), { recursive: true });",
"fs.writeFileSync(path.join(__dirname, 'dist', 'electron.exe'), 'binary');",
"fs.writeFileSync(path.join(__dirname, 'path.txt'), 'electron.exe');",
].join('\n')
);
const originalSkip = process.env.ELECTRON_SKIP_BINARY_DOWNLOAD;
process.env.ELECTRON_SKIP_BINARY_DOWNLOAD = '1';
t.after(() => {
if (originalSkip === undefined) delete process.env.ELECTRON_SKIP_BINARY_DOWNLOAD;
else process.env.ELECTRON_SKIP_BINARY_DOWNLOAD = originalSkip;
});
assert.equal(
repairElectronInstall({ packageRoot: root }),
true
);
assert.equal(
resolveElectronBinary({ packageRoot: root, platform: 'win32' }),
path.join(root, 'dist', 'electron.exe')
);
});
test('rebuilds Electron through npm when the binary is missing', (t) => {
const root = makeTmpDir('electron-rebuild');
t.after(() => rmrf(root));
fs.mkdirSync(path.join(root, 'dist'), { recursive: true });
const fakeNpmCli = path.join(root, 'fake-npm-cli.js');
fs.writeFileSync(
fakeNpmCli,
[
"const fs = require('node:fs');",
"const path = require('node:path');",
"if (process.env.ELECTRON_SKIP_BINARY_DOWNLOAD) process.exit(2);",
"fs.mkdirSync(path.join(__dirname, 'dist'), { recursive: true });",
"fs.writeFileSync(path.join(__dirname, 'dist', 'electron.exe'), 'binary');",
"fs.writeFileSync(path.join(__dirname, 'path.txt'), 'electron.exe');",
].join('\n')
);
const originalNpmExecPath = process.env.npm_execpath;
const originalNpmNodeExecPath = process.env.npm_node_execpath;
const originalSkip = process.env.ELECTRON_SKIP_BINARY_DOWNLOAD;
process.env.npm_execpath = fakeNpmCli;
process.env.npm_node_execpath = process.execPath;
process.env.ELECTRON_SKIP_BINARY_DOWNLOAD = '1';
t.after(() => {
if (originalNpmExecPath === undefined) delete process.env.npm_execpath;
else process.env.npm_execpath = originalNpmExecPath;
if (originalNpmNodeExecPath === undefined) delete process.env.npm_node_execpath;
else process.env.npm_node_execpath = originalNpmNodeExecPath;
if (originalSkip === undefined) delete process.env.ELECTRON_SKIP_BINARY_DOWNLOAD;
else process.env.ELECTRON_SKIP_BINARY_DOWNLOAD = originalSkip;
});
assert.equal(
resolveElectronBinary({ packageRoot: root, platform: 'win32' }),
path.join(root, 'dist', 'electron.exe')
);
});
test('falls back to npm install when rebuild does not repair the runtime', (t) => {
const root = makeTmpDir('electron-install-fallback');
t.after(() => rmrf(root));
fs.mkdirSync(path.join(root, 'dist'), { recursive: true });
const fakeNpmCli = path.join(root, 'fake-npm-cli.js');
fs.writeFileSync(
fakeNpmCli,
[
"const fs = require('node:fs');",
"const path = require('node:path');",
"const command = process.argv[2];",
"if (command === 'rebuild') process.exit(1);",
"if (command === 'install') {",
" fs.mkdirSync(path.join(__dirname, 'dist'), { recursive: true });",
" fs.writeFileSync(path.join(__dirname, 'dist', 'electron.exe'), 'binary');",
" fs.writeFileSync(path.join(__dirname, 'path.txt'), 'electron.exe');",
" process.exit(0);",
"}",
"process.exit(1);",
].join('\n')
);
const originalNpmExecPath = process.env.npm_execpath;
const originalNpmNodeExecPath = process.env.npm_node_execpath;
process.env.npm_execpath = fakeNpmCli;
process.env.npm_node_execpath = process.execPath;
t.after(() => {
if (originalNpmExecPath === undefined) delete process.env.npm_execpath;
else process.env.npm_execpath = originalNpmExecPath;
if (originalNpmNodeExecPath === undefined) delete process.env.npm_node_execpath;
else process.env.npm_node_execpath = originalNpmNodeExecPath;
});
assert.equal(
resolveElectronBinary({ packageRoot: root, projectRoot: root, platform: 'win32' }),
path.join(root, 'dist', 'electron.exe')
);
});
test('reports a helpful error when the runtime is missing', (t) => {
const root = makeTmpDir('electron-missing'); const root = makeTmpDir('electron-missing');
t.after(() => rmrf(root)); t.after(() => rmrf(root));
fs.mkdirSync(path.join(root, 'dist'), { recursive: true }); fs.mkdirSync(path.join(root, 'dist'), { recursive: true });
assert.throws( // Point npm_execpath at a script that would create the binary if executed.
() => resolveElectronBinary({ packageRoot: root, platform: 'win32' }), // The launcher must NOT execute it: runtime self-repair is forbidden.
/npm install/ const trapNpmCli = path.join(root, 'trap-npm-cli.js');
fs.writeFileSync(
trapNpmCli,
[
"const fs = require('node:fs');",
"const path = require('node:path');",
"fs.writeFileSync(path.join(__dirname, 'npm-was-invoked'), '1');",
].join('\n')
); );
const originalNpmExecPath = process.env.npm_execpath;
process.env.npm_execpath = trapNpmCli;
t.after(() => {
if (originalNpmExecPath === undefined) delete process.env.npm_execpath;
else process.env.npm_execpath = originalNpmExecPath;
});
assert.throws(
() => resolveElectronBinary({ packageRoot: root, projectRoot: root, platform: 'win32' }),
/npm ci/
);
assert.equal(fs.existsSync(path.join(root, 'npm-was-invoked')), false);
});
test('missing electron package fails with npm ci diagnostics, not an install', (t) => {
const root = makeTmpDir('electron-no-package');
t.after(() => rmrf(root));
assert.throws(
() => resolveElectronBinary({ packageRoot: null, projectRoot: root, platform: 'win32' }),
/never installs dependencies at runtime[\s\S]*npm ci/
);
});
test('missing runtime error message explains recovery without runtime installs', (t) => {
const root = makeTmpDir('electron-missing-msg');
t.after(() => rmrf(root));
const message = buildMissingElectronError({ const message = buildMissingElectronError({
packageRoot: root, packageRoot: root,
distDir: path.join(root, 'dist'), distDir: path.join(root, 'dist'),
candidatePaths: [path.join(root, 'dist', 'electron.exe')], candidatePaths: [path.join(root, 'dist', 'electron.exe')],
}); });
assert.match(message, /Electron could not be started/); assert.match(message, /Electron could not be started/);
assert.match(message, /Expected the binary in:/); assert.match(message, /npm ci/);
assert.doesNotMatch(message, /npm install/);
}); });
test('uses --no-sandbox when the Linux sandbox helper is not root-owned and setuid', () => { test('refuses an unsandboxed Linux launch unless explicitly allowed', () => {
const args = linuxSandboxLaunchArgs({ assert.throws(
electronPath: '/tmp/stepforge/node_modules/electron/dist/electron', () =>
platform: 'linux', linuxSandboxLaunchArgs({
statSync: () => ({ uid: 1000, mode: 0o100755 }), electronPath: '/tmp/stepforge/node_modules/electron/dist/electron',
}); platform: 'linux',
assert.deepEqual(args, ['--no-sandbox']); statSync: () => ({ uid: 1000, mode: 0o100755 }),
env: {},
userNamespaces: () => false,
}),
/refuses to silently launch unsandboxed[\s\S]*STEPFORGE_ALLOW_NO_SANDBOX/
);
});
test('allows --no-sandbox only with an explicit dev/CI opt-in', () => {
for (const env of [
{ STEPFORGE_ALLOW_NO_SANDBOX: '1' },
{ ELECTRON_DISABLE_SANDBOX: '1' },
]) {
const args = linuxSandboxLaunchArgs({
electronPath: '/tmp/stepforge/node_modules/electron/dist/electron',
platform: 'linux',
statSync: () => ({ uid: 1000, mode: 0o100755 }),
env,
userNamespaces: () => false,
});
assert.deepEqual(args, ['--no-sandbox']);
}
assert.equal(noSandboxExplicitlyAllowed({ STEPFORGE_ALLOW_NO_SANDBOX: '1' }), true);
assert.equal(noSandboxExplicitlyAllowed({}), false);
}); });
test('keeps the sandbox enabled when the Linux helper is root-owned and setuid', () => { test('keeps the sandbox enabled when the Linux helper is root-owned and setuid', () => {
@@ -187,6 +134,26 @@ test('keeps the sandbox enabled when the Linux helper is root-owned and setuid',
electronPath: '/tmp/stepforge/node_modules/electron/dist/electron', electronPath: '/tmp/stepforge/node_modules/electron/dist/electron',
platform: 'linux', platform: 'linux',
statSync: () => ({ uid: 0, mode: 0o104755 }), statSync: () => ({ uid: 0, mode: 0o104755 }),
env: {},
}); });
assert.deepEqual(args, []); assert.deepEqual(args, []);
}); });
test('non-Linux platforms never receive sandbox launch flags', () => {
assert.deepEqual(linuxSandboxLaunchArgs({ platform: 'win32', env: {} }), []);
assert.deepEqual(linuxSandboxLaunchArgs({ platform: 'darwin', env: {} }), []);
});
test('node toolchain check compares against package.json engines', () => {
const ok = checkNodeVersion({ currentVersion: '99.0.0' });
assert.equal(ok.ok, true);
const tooOld = checkNodeVersion({ currentVersion: '18.19.1' });
assert.equal(tooOld.ok, false);
assert.match(tooOld.required, /^\d+\.\d+\.\d+$/);
assert.throws(
() => assertSupportedNode({ currentVersion: '18.19.1' }),
/requires Node .* or newer/
);
});