Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2929d7d5e4 | ||
|
|
493f899a11 | ||
|
|
68e886c142 | ||
|
|
9e13ea9c40 | ||
|
|
058cdc20ab | ||
|
|
7f68a72e2b | ||
|
|
8e4f3ff951 | ||
|
|
67b5dc5965 | ||
|
|
9555db9bf5 | ||
|
|
a7d398ad6a | ||
|
|
a55a7a9170 | ||
|
|
8ddfe1b3e1 | ||
|
|
cc724f894b | ||
|
|
4f57cfacba | ||
|
|
8c39e8db4e | ||
|
|
2c0b8b021e | ||
|
|
7827ef3ad2 | ||
|
|
575d893f90 | ||
|
|
92146c760d | ||
|
|
fedf1d24c0 | ||
|
|
901940993c | ||
|
|
970d76a780 | ||
|
|
37079304c2 | ||
|
|
8aa9756b8a | ||
|
|
f31f1407a5 | ||
|
|
c1ccb5739b | ||
|
|
dd71cffac5 | ||
|
|
f62e3e19cb | ||
|
|
c916234ae8 | ||
|
|
ccbb9b03dc | ||
|
|
6ffef69705 | ||
|
|
50e445e7c3 | ||
|
|
6a3005f24c | ||
|
|
0f966a5fd0 | ||
|
|
534a28ece8 | ||
|
|
c2e05c900f | ||
|
|
7c006a7bb7 | ||
|
|
999f4a13b8 | ||
|
|
3356b935fc | ||
|
|
749f8d2d0d | ||
|
|
dd49e42290 | ||
|
|
266a92fedb | ||
|
|
0325b6efbc | ||
|
|
3c5c520799 | ||
|
|
962f929de2 | ||
|
|
a5a8498e71 | ||
|
|
32788fafdc | ||
|
|
412a4f4820 |
@@ -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
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -14,6 +15,11 @@ jobs:
|
||||
- name: Checkout repository
|
||||
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
|
||||
run: npm ci --cache ~/.npm --prefer-offline
|
||||
|
||||
@@ -21,4 +27,4 @@ jobs:
|
||||
env:
|
||||
ELECTRON_ENABLE_LOGGING: "1"
|
||||
ELECTRON_DISABLE_SANDBOX: "1"
|
||||
run: xvfb-run -a bash tests/run_test.sh
|
||||
run: xvfb-run -a bash tests/run_test.sh
|
||||
|
||||
@@ -3,6 +3,7 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
# Cancel an in-progress run when newer commits are pushed to the same ref.
|
||||
concurrency:
|
||||
@@ -15,14 +16,16 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
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:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
# The capture unit tests require app/capture.js, which require()s the
|
||||
@@ -33,3 +36,23 @@ jobs:
|
||||
|
||||
- name: Run unit tests
|
||||
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,13 +2,13 @@ name: Release
|
||||
|
||||
# Manual release: from the GitHub "Actions" tab pick "Release", click
|
||||
# "Run workflow", enter the version tag, and it builds the Windows installer
|
||||
# .exe and the Linux tarball + .deb, then publishes a GitHub Release with all
|
||||
# artifacts attached and auto-generated notes.
|
||||
# .exe, then publishes a GitHub Release with that artifact attached and
|
||||
# auto-generated notes.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Release tag, e.g. v0.1.1 (the tag is created at the current commit on this branch)'
|
||||
description: 'Release tag, e.g. v0.3.2.1 (the tag is created at the current commit on this branch)'
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: npm version "${VERSION#v}" --no-git-tag-version --allow-same-version
|
||||
run: node scripts/stamp-version.js "${VERSION#v}"
|
||||
|
||||
- name: Configure Windows code signing
|
||||
shell: bash
|
||||
@@ -68,41 +68,9 @@ jobs:
|
||||
path: releases/*.exe
|
||||
if-no-files-found: error
|
||||
|
||||
build-linux:
|
||||
name: Build Linux tarball + .deb
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Set version from input
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: npm version "${VERSION#v}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Package Linux artifacts (tarball + .deb)
|
||||
shell: bash
|
||||
env:
|
||||
STEPFORGE_PACKAGE_DIR: ${{ github.workspace }}/build/artifacts
|
||||
run: bash scripts/package-linux.sh
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-artifacts
|
||||
path: build/artifacts/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Publish GitHub Release
|
||||
needs: [build-windows, build-linux]
|
||||
needs: [build-windows]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -127,7 +95,6 @@ jobs:
|
||||
fi
|
||||
gh release create "$TAG" \
|
||||
dist/windows-installer/*.exe \
|
||||
dist/linux-artifacts/* \
|
||||
--title "StepForge $TAG" \
|
||||
--target "${{ github.sha }}" \
|
||||
--generate-notes \
|
||||
|
||||
@@ -6,3 +6,5 @@ releases/
|
||||
.tmp/
|
||||
tests/.tmp/
|
||||
examples/.tmp/
|
||||
build/build_report.md
|
||||
build/artifacts_manifest.json
|
||||
|
||||
@@ -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,15 +1,26 @@
|
||||
# StepForge
|
||||
|
||||
StepForge is a **fully offline**, open-source desktop app for Windows and
|
||||
Linux that captures step-by-step workflows as screenshots, lets you annotate
|
||||
and describe each step in a focused three-pane editor, and exports the result
|
||||
to Markdown, DOCX, PPTX, PDF, HTML (WIP), GIF (WIP), confluence (WIP), Wiki.js (WIP), and image bundles (WIP). The current reconmendations for exporting is Markdown and PDF.
|
||||
StepForge is a **local-first**, open-source desktop app for Windows, with
|
||||
Linux (WIP) builds. It captures step-by-step workflows as screenshots, lets
|
||||
you annotate and describe each step in a focused three-pane editor, and
|
||||
exports the result to Markdown, DOCX, PPTX, PDF, HTML (WIP), GIF (WIP),
|
||||
confluence (WIP), Wiki.js (WIP), and image bundles (WIP). The current
|
||||
reconmendations for exporting is Markdown and PDF.
|
||||
|
||||
It is an independent offline desktop guide-capture tool inspired by publicly
|
||||
documented workflow patterns of commercial documentation tools like Folge. It contains no
|
||||
third-party branding, assets, or code from those tools, and it never talks to
|
||||
the network: no telemetry, no update checks, no license checks, no cloud, no
|
||||
remote AI.
|
||||
It is an independent desktop guide-capture tool inspired by publicly
|
||||
documented workflow patterns of commercial documentation tools like Folge. It
|
||||
contains no third-party branding, assets, or code from those tools.
|
||||
|
||||
**Network and privacy contract.** StepForge has no telemetry, no update
|
||||
checks, no license checks, and no cloud. Guides never leave your machine on
|
||||
their own. The only outbound network feature is the **optional** AI
|
||||
integration: when *you* enable it and configure an [Ollama](https://ollama.com)
|
||||
endpoint, StepForge sends step screenshots and text to that endpoint to
|
||||
generate titles and descriptions. By default that endpoint must be **local
|
||||
(loopback)**; sending data to a remote host requires the explicit "Allow
|
||||
remote AI host" opt-in. See [docs/PRIVACY.md](docs/PRIVACY.md) for exactly
|
||||
what is collected and sent. Note that OCR (Tesseract) and its English language
|
||||
data are bundled production dependencies — Electron is not the only one.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -60,17 +71,29 @@ using only Node built-ins.
|
||||
|
||||
## Getting Started
|
||||
|
||||
For a windows installation, see [docs/windows_installation](docs/windows_installation.md) or for a developer/more in depth walkthrough, see [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md).
|
||||
For a Windows installation, see [docs/windows_installation](docs/windows_installation.md) or for a developer/more in depth walkthrough, see [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md).
|
||||
|
||||
Requirements: Node.js 20+ and npm (Electron is the only dependency).
|
||||
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+).
|
||||
|
||||
```bash
|
||||
npm install # one-time, fetches the Electron shell
|
||||
npm ci # one-time, installs the locked dependency tree
|
||||
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
|
||||
Linux, `%APPDATA%/stepforge` on Windows; override with `STEPFORGE_DATA_DIR`).
|
||||
Linux (WIP), `%APPDATA%/stepforge` on Windows; override with
|
||||
`STEPFORGE_DATA_DIR`).
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -92,7 +115,7 @@ documents, and validating the bytes of the output, not string matching.
|
||||
bash scripts/bootstrap-offline.sh # verify toolchain availability
|
||||
bash scripts/verify.sh # full test suite + smoke checks
|
||||
bash scripts/build-release.sh # assemble runnable app directory
|
||||
bash scripts/package-linux.sh # portable tar.gz + .deb (+ AppDir spec)
|
||||
bash scripts/package-linux.sh # local Linux packaging (WIP; not part of release)
|
||||
npm run package:windows # Windows installer .exe in releases/
|
||||
pwsh scripts/package-windows.ps1 # same Windows installer build via PowerShell
|
||||
```
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
# After action report
|
||||
|
||||
## Done — 10 PRs implemented, merged, and CI-green on both forges
|
||||
I worked through ai_prompts/prompt4.md and shipped PRs 1–10 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 11–14 — 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 11–14 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.
|
||||
|
||||
## 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 format’s 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 app’s 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 capture’s 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 125–200% 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.
|
||||
@@ -1,6 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
const { spawn, execFileSync } = require('node:child_process');
|
||||
const { desktopCapturer, screen, BrowserWindow, nativeImage, Tray, Menu } = require('electron');
|
||||
const raster = require('../core/raster');
|
||||
@@ -107,6 +108,88 @@ function hasBinary(name) {
|
||||
}
|
||||
}
|
||||
|
||||
// On Wayland, xinput only sees XWayland (X11-bridge) events — native Wayland
|
||||
// app clicks are delivered via the Wayland protocol and never reach xinput.
|
||||
// Treating it as "available" would leave the session with no click capture AND
|
||||
// no interval fallback, so zero steps get captured.
|
||||
function isWayland() {
|
||||
if (process.platform !== 'linux') return false;
|
||||
// XDG_SESSION_TYPE is the authoritative session hint when present. Some
|
||||
// desktops still export WAYLAND_DISPLAY even when the active session is X11,
|
||||
// so only fall back to it when XDG_SESSION_TYPE is unavailable.
|
||||
const sessionType = String(process.env.XDG_SESSION_TYPE || '').toLowerCase();
|
||||
if (sessionType) return sessionType === 'wayland';
|
||||
return Boolean(process.env.WAYLAND_DISPLAY);
|
||||
}
|
||||
|
||||
// ---- evdev (Linux kernel input) click reader --------------------------------
|
||||
// Reading /dev/input/event* directly sees mouse-button presses on BOTH X11 and
|
||||
// Wayland, because it taps the kernel input layer below the display server —
|
||||
// the one global-click source that survives Wayland's security model. It needs
|
||||
// read access to the device nodes (the user must be in the `input` group).
|
||||
//
|
||||
// Each event is a fixed-size `struct input_event`: a timeval, then u16 type,
|
||||
// u16 code, s32 value. The timeval is two `long`s, so the record is 24 bytes on
|
||||
// 64-bit and 16 on 32-bit; type/code/value always sit in the last 8 bytes.
|
||||
const EV_KEY = 0x01;
|
||||
const EVDEV_PRESS = 1;
|
||||
// BTN_LEFT/RIGHT/MIDDLE -> the same button-N naming the xinput path emits
|
||||
// (1=left, 2=middle, 3=right) so downstream debounce/marker logic is identical.
|
||||
const EVDEV_BUTTONS = { 272: 'button-1', 273: 'button-3', 274: 'button-2' };
|
||||
const EVDEV_RECORD_SIZE = (process.arch === 'x64' || process.arch === 'arm64'
|
||||
|| process.arch === 'ppc64' || process.arch === 's390x' || process.arch === 'loong64'
|
||||
|| process.arch === 'riscv64') ? 24 : 16;
|
||||
|
||||
/**
|
||||
* Decode a buffer of packed input_event records, returning the button presses
|
||||
* found and any trailing partial record (a device read can split mid-record).
|
||||
* Pure and size-parameterised so it is unit-testable without real devices.
|
||||
*/
|
||||
function decodeEvdevButtonPresses(buffer, recordSize = EVDEV_RECORD_SIZE) {
|
||||
const presses = [];
|
||||
let offset = 0;
|
||||
while (buffer.length - offset >= recordSize) {
|
||||
const type = buffer.readUInt16LE(offset + recordSize - 8);
|
||||
const code = buffer.readUInt16LE(offset + recordSize - 6);
|
||||
const value = buffer.readInt32LE(offset + recordSize - 4);
|
||||
offset += recordSize;
|
||||
if (type === EV_KEY && value === EVDEV_PRESS && EVDEV_BUTTONS[code]) {
|
||||
presses.push(EVDEV_BUTTONS[code]);
|
||||
}
|
||||
}
|
||||
return { presses, rest: buffer.subarray(offset) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The /dev/input/event* nodes for pointing devices that are readable by this
|
||||
* process. /proc/bus/input/devices lists every device with its Handlers line;
|
||||
* a pointing device exposes a `mouseN` handler, and the matching `eventN` is
|
||||
* the node to read. Unreadable nodes (no `input` group membership) are skipped.
|
||||
*/
|
||||
function readableEvdevMouseNodes() {
|
||||
const nodes = [];
|
||||
let table;
|
||||
try {
|
||||
table = fs.readFileSync('/proc/bus/input/devices', 'utf8');
|
||||
} catch {
|
||||
return nodes;
|
||||
}
|
||||
for (const block of table.split('\n\n')) {
|
||||
const handlers = /H:\s*Handlers=([^\n]*)/.exec(block);
|
||||
if (!handlers || !/\bmouse\d+\b/.test(handlers[1])) continue;
|
||||
const event = /\bevent(\d+)\b/.exec(handlers[1]);
|
||||
if (!event) continue;
|
||||
const node = `/dev/input/event${event[1]}`;
|
||||
try {
|
||||
fs.accessSync(node, fs.constants.R_OK);
|
||||
nodes.push(node);
|
||||
} catch {
|
||||
// Not readable — user is not in the `input` group for this node.
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
class CaptureService {
|
||||
constructor({
|
||||
store,
|
||||
@@ -115,18 +198,32 @@ class CaptureService {
|
||||
notify,
|
||||
screenApi = screen,
|
||||
textIntel = null,
|
||||
powerPolicy = null,
|
||||
}) {
|
||||
this.store = store;
|
||||
this.settings = settings;
|
||||
this.getWindow = getWindow;
|
||||
this.notify = notify;
|
||||
// Single owner of OS power/throttling state for the capture lifecycle.
|
||||
// setRecording(true) is called exactly while a session is actively
|
||||
// recording (session present and not paused); setRecording(false) whenever
|
||||
// it pauses or ends. No-op by default so tests and non-Electron hosts work.
|
||||
this.powerPolicy = powerPolicy || { setRecording() {} };
|
||||
this._recordingPower = false;
|
||||
// Injectable for tests; the click/coordinate paths must never reach for
|
||||
// the global `screen` directly so coordinate handling stays testable.
|
||||
this.screen = screenApi;
|
||||
this.textIntel = textIntel;
|
||||
// Cached display-server detection. A method (onWayland) reads this so tests
|
||||
// can flip platform behavior without touching process.env.
|
||||
this._wayland = isWayland();
|
||||
this.session = null; // { guideId, paused, count, intervalSec }
|
||||
this.intervalTimer = null;
|
||||
this.clickWatcher = null;
|
||||
// Explicit trigger source rather than a bare boolean, so the UI can tell
|
||||
// the truth about how clicks are being captured (or that they are not):
|
||||
// windows-hook | x11 | evdev-x11 | evdev-wayland | unavailable
|
||||
this.clickSource = 'unavailable';
|
||||
this.frameLoopTimer = null;
|
||||
this.frameLoopRunning = false;
|
||||
this.frameWaiters = [];
|
||||
@@ -154,6 +251,22 @@ class CaptureService {
|
||||
this.warmingUp = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile OS power state with the actual recording state. Called after
|
||||
* every session transition so there is exactly one owner: the blocker is
|
||||
* held iff a session exists and is not paused. Idempotent.
|
||||
*/
|
||||
syncPower() {
|
||||
const recording = Boolean(this.session && !this.session.paused);
|
||||
if (recording === this._recordingPower) return;
|
||||
this._recordingPower = recording;
|
||||
try {
|
||||
this.powerPolicy.setRecording(recording);
|
||||
} catch {
|
||||
// power management is best-effort; never break capture over it
|
||||
}
|
||||
}
|
||||
|
||||
state() {
|
||||
return this.session
|
||||
? {
|
||||
@@ -162,12 +275,21 @@ class CaptureService {
|
||||
guideId: this.session.guideId,
|
||||
count: this.session.count,
|
||||
intervalSec: this.session.intervalSec || 0,
|
||||
clickCapture: Boolean(this.clickWatcher),
|
||||
// clickCapture reflects any live global click source (xinput/evdev/
|
||||
// Windows hook), not just the spawned-process watcher — evdev has no
|
||||
// child process, so the old Boolean(this.clickWatcher) reported false
|
||||
// while clicks were in fact being captured.
|
||||
clickCapture: this.clickSource !== 'unavailable',
|
||||
clickSource: this.clickSource,
|
||||
clickCaptureAvailable: this.clickCaptureAvailable(),
|
||||
clickFrameSource: this.streamBackend ? 'stream' : (this.frameLoopRunning ? 'loop' : 'idle'),
|
||||
strictClickFrames: this.strictClickFrames(),
|
||||
}
|
||||
: { active: false, clickCaptureAvailable: this.clickCaptureAvailable() };
|
||||
: {
|
||||
active: false,
|
||||
clickSource: this.clickSource,
|
||||
clickCaptureAvailable: this.clickCaptureAvailable(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,21 +304,62 @@ class CaptureService {
|
||||
return this.settings.get('capture.strictClickFrames') !== false;
|
||||
}
|
||||
|
||||
fallbackCaptureTrigger() {
|
||||
const raw = String(this.settings.get('capture.fallbackTrigger') || 'interval').toLowerCase();
|
||||
return raw === 'hotkey' ? 'hotkey' : 'interval';
|
||||
}
|
||||
|
||||
fallbackIntervalSec() {
|
||||
const raw = Number(this.settings.get('capture.autoIntervalSec'));
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 5;
|
||||
}
|
||||
|
||||
clickCaptureAvailable() {
|
||||
if (this._clickAvail === undefined) {
|
||||
this._clickAvail = process.platform === 'win32' || (process.platform === 'linux' && hasBinary('xinput'));
|
||||
// Three click sources, in order of fidelity:
|
||||
// - Windows: the low-level mouse hook (position + timing);
|
||||
// - X11: xinput test-xi2 (position + timing) — but it can't see native
|
||||
// Wayland clicks, only XWayland ones, so it's gated to non-Wayland;
|
||||
// - Linux evdev (/dev/input): button presses on X11 AND Wayland, but no
|
||||
// cursor position on Wayland — used for per-click capture there (no
|
||||
// marker). Requires the user to be in the `input` group.
|
||||
this._clickAvail = process.platform === 'win32'
|
||||
|| (process.platform === 'linux' && !this.onWayland() && hasBinary('xinput'))
|
||||
|| (process.platform === 'linux' && readableEvdevMouseNodes().length > 0);
|
||||
}
|
||||
return this._clickAvail;
|
||||
}
|
||||
|
||||
/** Whether this is a Wayland session (cached; overridable in tests). */
|
||||
onWayland() {
|
||||
return this._wayland;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the in-process frame loop is a usable fallback recorder. It grabs
|
||||
* via desktopCapturer.getSources(), which on Wayland is broken (throws) and
|
||||
* pops the portal — so the loop is viable only off Wayland. On Wayland the
|
||||
* portal-backed stream backend is the sole capture path.
|
||||
*/
|
||||
canUseFrameLoop() {
|
||||
return !this.onWayland();
|
||||
}
|
||||
|
||||
startSession(guideId, { intervalSec = null } = {}) {
|
||||
this.finishSession();
|
||||
// Default trigger: clicks when the platform supports it, otherwise an
|
||||
// interval so a session always produces steps even if the global hotkey
|
||||
// never fires (common under Wayland/WSLg).
|
||||
// Default trigger: clicks when the platform supports it, otherwise the
|
||||
// user-selected fallback (timer or hotkey-only). That keeps Linux from
|
||||
// silently dropping into an unwanted 5-second timer when click capture
|
||||
// is unavailable.
|
||||
let interval = intervalSec;
|
||||
if (interval == null) {
|
||||
interval = this.clickCaptureAvailable() ? 0 : (this.settings.get('capture.autoIntervalSec') || 5);
|
||||
if (this.clickCaptureAvailable()) {
|
||||
interval = 0;
|
||||
} else if (this.fallbackCaptureTrigger() === 'hotkey') {
|
||||
interval = 0;
|
||||
} else {
|
||||
interval = this.fallbackIntervalSec();
|
||||
}
|
||||
}
|
||||
// Sessions start paused: nothing hides and no capturing happens until
|
||||
// the user explicitly presses "Start recording" in the capture bar, so
|
||||
@@ -204,6 +367,9 @@ class CaptureService {
|
||||
this.session = { guideId, paused: true, count: 0, intervalSec: interval };
|
||||
if (this.settings.get('capture.captureOutsideClicks') !== false) this.startClickWatcher();
|
||||
this.applyInterval();
|
||||
// A new session starts paused, so it must NOT hold the power blocker yet
|
||||
// (it did before, leaking the blocker while idle). syncPower reconciles.
|
||||
this.syncPower();
|
||||
this.notify('capture:state', this.state());
|
||||
|
||||
// (Skipped for the dev screenshot hook, which needs a visible page.)
|
||||
@@ -300,6 +466,7 @@ class CaptureService {
|
||||
showWindow() {
|
||||
const win = this.getWindow();
|
||||
if (win && !win.isDestroyed()) {
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
}
|
||||
@@ -321,7 +488,14 @@ class CaptureService {
|
||||
const sec = this.session && this.session.intervalSec;
|
||||
if (sec > 0) {
|
||||
this.intervalTimer = setInterval(() => {
|
||||
this.sessionCapture('interval').catch(() => {});
|
||||
// Don't let a slow capture (e.g. a multi-second software PNG encode on
|
||||
// a GPU-less host) overlap with the next tick — overlapping requests
|
||||
// would pile up and could trip the backend's failure counter.
|
||||
if (this.intervalCapturing) return;
|
||||
this.intervalCapturing = true;
|
||||
this.sessionCapture('interval')
|
||||
.catch(() => {})
|
||||
.finally(() => { this.intervalCapturing = false; });
|
||||
}, sec * 1000);
|
||||
}
|
||||
}
|
||||
@@ -341,6 +515,9 @@ class CaptureService {
|
||||
this.stopFrameLoop();
|
||||
this.stopClickFrameBackend();
|
||||
}
|
||||
// Recording only while unpaused: this is the one place tray/second-instance
|
||||
// pauses previously bypassed, leaking the power blocker. syncPower owns it.
|
||||
this.syncPower();
|
||||
if (this.rebuildTrayMenu) this.rebuildTrayMenu();
|
||||
this.notify('capture:state', this.state());
|
||||
}
|
||||
@@ -358,8 +535,13 @@ class CaptureService {
|
||||
armRecording() {
|
||||
const win = this.getWindow();
|
||||
const wantHide = Boolean(this.hiddenForSession && win && !win.isDestroyed());
|
||||
const recorderWanted = this.settings.get('capture.captureOutsideClicks') !== false
|
||||
&& this.clickCaptureAvailable();
|
||||
// Always start the frame recorder when stream capture is enabled — it
|
||||
// buffers frames for click captures AND is used for interval/hotkey
|
||||
// captures to avoid calling desktopCapturer.getSources() on every capture.
|
||||
// On Linux/Wayland, each getSources() call goes through the XDG portal and
|
||||
// shows a permission dialog; the stream backend eliminates that by keeping
|
||||
// a live video stream open for the duration of the recording session.
|
||||
const recorderWanted = this.settings.get('capture.streamCapture') !== false;
|
||||
// Recording is not "live" until the window is hidden and the buffer is
|
||||
// primed. While warming up, the window is still visible and over the
|
||||
// user's work, so clicks in this period are ignored (onOsClick checks
|
||||
@@ -383,7 +565,17 @@ class CaptureService {
|
||||
if (!this.session || this.session.paused) { this.warmingUp = false; return; }
|
||||
}
|
||||
if (wantHide && win && !win.isDestroyed() && win.isVisible()) {
|
||||
win.hide();
|
||||
// On Linux, always minimize rather than hide. GNOME's system tray
|
||||
// (StatusNotifier) is unreliable — it can fail or half-export over
|
||||
// dbus — so a hidden window can be impossible to bring back, leaving
|
||||
// the user unable to stop the recording. A minimized window is always
|
||||
// restorable from the taskbar, and minimized windows aren't rendered
|
||||
// so they still stay out of the fullscreen capture.
|
||||
if (process.platform === 'linux') {
|
||||
win.minimize();
|
||||
} else {
|
||||
win.hide();
|
||||
}
|
||||
// Let a couple of frames of the now-unobscured screen land before
|
||||
// the user's first click, so that frame shows their work, not the
|
||||
// app window that was just dismissed.
|
||||
@@ -405,6 +597,9 @@ class CaptureService {
|
||||
this.stopClickFrameBackend();
|
||||
this.destroySessionTray();
|
||||
this.session = null;
|
||||
// A finished session never records: release the power blocker here so it
|
||||
// can't outlive the recording (finish from any path — bar, tray, quit).
|
||||
this.syncPower();
|
||||
if (this.hiddenForSession) {
|
||||
this.hiddenForSession = false;
|
||||
this.showWindow();
|
||||
@@ -412,6 +607,23 @@ class CaptureService {
|
||||
this.notify('capture:state', this.state());
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort drain of clicks still encoding in the queue, for application
|
||||
* shutdown. Resolves when the queue settles or the deadline passes so quit
|
||||
* is never blocked indefinitely. Clicks captured mid-session are pinned to
|
||||
* their guide id and stored even after the session ends (see onOsClick).
|
||||
*/
|
||||
async drainPendingClicks(timeoutMs = 2000) {
|
||||
try {
|
||||
await Promise.race([
|
||||
this.clickQueue.catch(() => {}),
|
||||
new Promise((resolve) => setTimeout(resolve, Math.max(0, timeoutMs))),
|
||||
]);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the user is interacting with StepForge itself. Deliberately
|
||||
* based on cursor position over the visible window, not isFocused():
|
||||
@@ -474,11 +686,62 @@ class CaptureService {
|
||||
if (result.ok) this.noteStepAdded(result.step, trigger, guideId);
|
||||
return result;
|
||||
}
|
||||
// No usable frame: fall through to a one-off fresh shot — but only
|
||||
// while still recording. After a stop, a fresh shot would show
|
||||
// whatever replaced the user's workflow on screen.
|
||||
clog('click@', clickAt, 'no frame qualified — falling back to a fresh (post-click) shot');
|
||||
if (!sessionLive) return { ok: false, reason: 'session ended before the fallback shot' };
|
||||
// No usable pre-click frame. In strict mode a fresh shot now would be a
|
||||
// POST-click frame — exactly what strict mode promises never to store.
|
||||
// Skip with a visible diagnostic instead of silently labeling a
|
||||
// post-click fallback as strict. Non-strict mode keeps the legacy
|
||||
// fresh-shot fallback below.
|
||||
if (this.strictClickFrames()) {
|
||||
clog('click@', clickAt, 'strict mode — no pre-click frame; skipping rather than storing a post-click shot');
|
||||
this.notify('capture:diagnostic', {
|
||||
kind: 'strict-click-skipped',
|
||||
guideId,
|
||||
reason: 'No pre-click frame was ready for this click. In strict timing mode StepForge skips the shot rather than capturing the screen after the click.',
|
||||
});
|
||||
return { ok: false, reason: 'strict mode: no pre-click frame available for this click' };
|
||||
}
|
||||
clog('click@', clickAt, 'no frame qualified — falling back to a fresh (post-click) shot');
|
||||
}
|
||||
|
||||
// For non-click triggers (interval, hotkey, manual) pull the latest frame
|
||||
// from the stream backend's ring buffer when available. This avoids a
|
||||
// desktopCapturer.getSources() call per capture — on Linux/Wayland that
|
||||
// call goes through the XDG portal and shows a dialog every time.
|
||||
//
|
||||
// No clickPos: a timed capture has no click position, and passing a cursor
|
||||
// point here is actively harmful on Wayland — getCursorScreenPoint() can
|
||||
// return a stale/out-of-bounds point, which makes the backend reject the
|
||||
// frame (wrong display / out of bounds) and fall through to a getSources()
|
||||
// shot, i.e. a portal dialog on every interval tick.
|
||||
if (trigger !== 'click' && this.streamBackend && this.streamBackend.isActive()) {
|
||||
const frame = await this.streamBackend.frameForClick({
|
||||
clickPos: null,
|
||||
clickAt: Date.now(),
|
||||
strict: false, // no pre/post-click constraint for timed captures
|
||||
leadMs: 0,
|
||||
failable: false, // a slow timed-capture encode must not kill the stream
|
||||
}).catch(() => null);
|
||||
if (frame) {
|
||||
const result = await this.storeFrameAsStep(this.session.guideId, 'fullscreen', frame);
|
||||
if (result.ok) this.noteStepAdded(result.step, trigger);
|
||||
clog(trigger, 'capture stored from stream; total', this.session && this.session.count);
|
||||
return result;
|
||||
}
|
||||
clog(trigger, 'capture: no frame from stream this tick — will retry next tick');
|
||||
} else if (trigger !== 'click' && this.onWayland()) {
|
||||
clog(trigger, 'capture: stream backend not active (active=',
|
||||
Boolean(this.streamBackend && this.streamBackend.isActive()), ')');
|
||||
}
|
||||
|
||||
// On Wayland the only screen-grab fallback below is desktopCapturer
|
||||
// .getSources(), which pops the XDG portal dialog every call. For the
|
||||
// automatic timed triggers that would mean a dialog on every tick, so skip
|
||||
// the fallback and wait for the open stream to deliver a frame on a later
|
||||
// tick. Explicit captures (manual, and click on X11) still fall through —
|
||||
// one dialog for one deliberate action.
|
||||
if (this.onWayland() && (trigger === 'interval' || trigger === 'hotkey')) {
|
||||
return { ok: false, reason: 'waiting for the screen-share stream' };
|
||||
}
|
||||
|
||||
if (this.shooting) return { ok: false, reason: 'capture already in progress' };
|
||||
@@ -642,7 +905,11 @@ class CaptureService {
|
||||
};
|
||||
|
||||
if (this.streamBackend && this.streamBackend.isActive() && grabMode === 'fullscreen') {
|
||||
const frame = await this.streamBackend.frameForClick({ clickPos, clickAt: clickTime, strict, leadMs });
|
||||
// On Wayland the stream is the only capture path (no frame-loop fallback),
|
||||
// so a slow PNG encode must not let the 2-strikes rule tear it down.
|
||||
const frame = await this.streamBackend.frameForClick({
|
||||
clickPos, clickAt: clickTime, strict, leadMs, failable: !this.onWayland(),
|
||||
});
|
||||
if (frame) return frame;
|
||||
// No qualifying frame (or the backend just went unhealthy): fall
|
||||
// through to the loop buffer / fresh-shot fallbacks below.
|
||||
@@ -695,8 +962,11 @@ class CaptureService {
|
||||
async startClickFrameBackend() {
|
||||
const mode = this.settings.get('capture.mode') || 'fullscreen';
|
||||
// The worker streams screens; window-mode grabs need the loop's
|
||||
// source-filtering logic.
|
||||
if (this.settings.get('capture.streamCapture') === false || mode === 'window') {
|
||||
// source-filtering logic. But the loop isn't viable on Wayland (getSources
|
||||
// is broken/portal), so there we always take the stream backend regardless
|
||||
// of the streamCapture/window settings.
|
||||
if (this.canUseFrameLoop()
|
||||
&& (this.settings.get('capture.streamCapture') === false || mode === 'window')) {
|
||||
this.startFrameLoop();
|
||||
return;
|
||||
}
|
||||
@@ -716,7 +986,11 @@ class CaptureService {
|
||||
onUnhealthy: () => this.degradeToFrameLoop(),
|
||||
});
|
||||
const displays = this.screen.getAllDisplays();
|
||||
const sources = await desktopCapturer.getSources({
|
||||
// On Wayland, desktopCapturer.getSources() both fails to yield usable
|
||||
// source ids AND pops the portal dialog. Skip it entirely and drive the
|
||||
// worker through getDisplayMedia (the portal picker chooses the screen).
|
||||
const useDisplayMedia = this.onWayland();
|
||||
const sources = useDisplayMedia ? [] : await desktopCapturer.getSources({
|
||||
types: ['screen'],
|
||||
thumbnailSize: { width: 1, height: 1 }, // ids only — skip thumbnail work
|
||||
});
|
||||
@@ -724,23 +998,38 @@ class CaptureService {
|
||||
displays,
|
||||
sources: sources.map((s) => ({ id: s.id, display_id: s.display_id })),
|
||||
sampleMs: this.settings.get('capture.frameSampleMs') || 100,
|
||||
useDisplayMedia,
|
||||
});
|
||||
const stale = gen !== this.captureGen;
|
||||
if (!ok || stale || !this.session || this.session.paused) {
|
||||
backend.stop();
|
||||
if (!stale && this.session && !this.session.paused) {
|
||||
console.error('[stepforge] stream capture backend failed to start — using in-process frame loop');
|
||||
this.startFrameLoop();
|
||||
if (this.canUseFrameLoop()) {
|
||||
console.error('[stepforge] stream capture backend failed to start — using in-process frame loop');
|
||||
this.startFrameLoop();
|
||||
} else {
|
||||
// On Wayland the frame loop would spam getSources() (portal) with
|
||||
// nothing usable, so there's no fallback — the recording needs the
|
||||
// portal stream. Tell the user how to recover.
|
||||
console.error('[stepforge] screen-share stream did not start — pick a screen in the share dialog, or stop and start recording again');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.streamBackend = backend;
|
||||
clog('stream capture backend active');
|
||||
// Visible in normal output (one line per recording): confirms the screen
|
||||
// stream came up, so a "nothing records" report can be told apart from a
|
||||
// stream that never started (which logs the failure paths above).
|
||||
console.log(`[stepforge] screen-capture stream active (${useDisplayMedia ? 'getDisplayMedia/portal' : 'desktopCapturer'})`);
|
||||
this.notify('capture:state', this.state());
|
||||
} catch (err) {
|
||||
if (gen === this.captureGen && this.session && !this.session.paused) {
|
||||
console.error(`[stepforge] stream capture backend error (${err && err.message}) — using in-process frame loop`);
|
||||
this.startFrameLoop();
|
||||
if (this.canUseFrameLoop()) {
|
||||
console.error(`[stepforge] stream capture backend error (${err && err.message}) — using in-process frame loop`);
|
||||
this.startFrameLoop();
|
||||
} else {
|
||||
console.error(`[stepforge] screen-share stream error (${err && err.message}) — stop and start recording again`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (gen === this.captureGen) this.streamBackendStarting = false;
|
||||
@@ -765,8 +1054,14 @@ class CaptureService {
|
||||
*/
|
||||
degradeToFrameLoop() {
|
||||
this.streamBackend = null;
|
||||
console.error('[stepforge] stream capture backend unhealthy — falling back to in-process frame loop');
|
||||
if (this.session && !this.session.paused) this.startFrameLoop();
|
||||
if (this.canUseFrameLoop()) {
|
||||
console.error('[stepforge] stream capture backend unhealthy — falling back to in-process frame loop');
|
||||
if (this.session && !this.session.paused) this.startFrameLoop();
|
||||
} else {
|
||||
// On Wayland the frame loop isn't viable (getSources is broken/portal),
|
||||
// so there's nothing to fall back to — the stream is the only path.
|
||||
console.error('[stepforge] screen-share stream stopped — stop and start recording again to re-share');
|
||||
}
|
||||
this.notify('capture:state', this.state());
|
||||
}
|
||||
|
||||
@@ -775,26 +1070,40 @@ class CaptureService {
|
||||
try {
|
||||
this.clickWatcherBuf = '';
|
||||
this.linuxEvent = null;
|
||||
if (process.platform === 'linux' && hasBinary('xinput')) {
|
||||
if (process.platform === 'linux' && !this.onWayland() && hasBinary('xinput')) {
|
||||
// Stream raw button events from the X server; one capture per press.
|
||||
// xinput block-buffers stdout when piped, so a press event can sit
|
||||
// in its buffer until later motion events flush it — by then the
|
||||
// cursor read in onOsClick lands where the mouse moved *after* the
|
||||
// click. stdbuf -oL forces line-buffering so events (and the cursor
|
||||
// read) line up with the actual click instant.
|
||||
// (Skipped on Wayland: xinput only sees XWayland events — see isWayland.)
|
||||
const argv = hasBinary('stdbuf')
|
||||
? ['stdbuf', '-oL', 'xinput', 'test-xi2', '--root']
|
||||
: ['xinput', 'test-xi2', '--root'];
|
||||
this.clickWatcher = spawn(argv[0], argv.slice(1), { stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
this.clickSource = 'x11';
|
||||
this.clickWatcher.stdout.on('data', (chunk) => {
|
||||
this.ingestClickWatcherChunk(chunk.toString(), 'linux');
|
||||
});
|
||||
} else if (process.platform === 'linux' && readableEvdevMouseNodes().length > 0) {
|
||||
// Wayland (or X11 without xinput): read mouse buttons from the kernel
|
||||
// input layer. This is the only global click source on Wayland, but it
|
||||
// carries no cursor position — onOsClick gets a null point, so steps are
|
||||
// captured per click without a marker. (X11 prefers the xinput branch
|
||||
// above, which does carry root coordinates for the marker.)
|
||||
this.startEvdevWatcher();
|
||||
} else if (process.platform === 'win32') {
|
||||
// Use a low-level Windows mouse hook instead of polling
|
||||
// GetAsyncKeyState. The low bit from GetAsyncKeyState can be consumed
|
||||
// by other processes and a polling loop can miss short clicks under
|
||||
// load; WH_MOUSE_LL gives us one event for each button-down, with the
|
||||
// hook-time cursor position and timestamp.
|
||||
//
|
||||
// Raw typed-text capture is a keylogging surface, so the hook only
|
||||
// emits printable CHAR events when the user explicitly opted in; by
|
||||
// default the characters never even cross the process boundary.
|
||||
const captureTypedText = this.settings.get('capture.captureTypedText') ? 'true' : 'false';
|
||||
const ps = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -TypeDefinition @'
|
||||
@@ -824,6 +1133,7 @@ public static class SFHook {
|
||||
private const uint PROCESS_POWER_THROTTLING_EXECUTION_SPEED = 0x1;
|
||||
private const uint HIGH_PRIORITY_CLASS = 0x00000080;
|
||||
|
||||
private static readonly bool CaptureTypedText = ${captureTypedText};
|
||||
private static IntPtr hook = IntPtr.Zero;
|
||||
private static IntPtr keyHook = IntPtr.Zero;
|
||||
private static LowLevelMouseProc proc = MouseHookCallback;
|
||||
@@ -1076,8 +1386,10 @@ public static class SFHook {
|
||||
queue.Enqueue("KEY Escape " + unixMs); signal.Set();
|
||||
} else if (vk == 0x0D) {
|
||||
queue.Enqueue("KEY Enter " + unixMs); signal.Set();
|
||||
} else {
|
||||
// Map to Unicode character using current keyboard layout + shift state.
|
||||
} else if (CaptureTypedText) {
|
||||
// Map to Unicode character using current keyboard layout + shift
|
||||
// state. Only reached when the user opted into typed-text capture;
|
||||
// otherwise raw characters are never read or emitted.
|
||||
byte[] ks = new byte[256];
|
||||
GetKeyboardState(ks);
|
||||
var sb = new System.Text.StringBuilder(4);
|
||||
@@ -1136,6 +1448,7 @@ public static class SFHook {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
this.clickSource = 'windows-hook';
|
||||
this.clickWatcher.stdout.on('data', (chunk) => {
|
||||
this.ingestClickWatcherChunk(chunk.toString(), 'win32');
|
||||
});
|
||||
@@ -1173,7 +1486,9 @@ public static class SFHook {
|
||||
console.error(`[stepforge] click watcher stopped${detail ? `: ${detail}` : ''}`);
|
||||
if (!this.session) return;
|
||||
if (!this.session.intervalSec) {
|
||||
this.session.intervalSec = this.settings.get('capture.autoIntervalSec') || 5;
|
||||
this.session.intervalSec = this.fallbackCaptureTrigger() === 'hotkey'
|
||||
? 0
|
||||
: this.fallbackIntervalSec();
|
||||
this.applyInterval();
|
||||
}
|
||||
this.notify('capture:state', this.state());
|
||||
@@ -1184,12 +1499,77 @@ public static class SFHook {
|
||||
try { this.clickWatcher.kill(); } catch { /* already gone */ }
|
||||
this.clickWatcher = null;
|
||||
}
|
||||
this.stopEvdevWatcher();
|
||||
this.clickSource = 'unavailable';
|
||||
this.clickWatcherBuf = '';
|
||||
this.linuxEvent = null;
|
||||
this.discardPendingRawClick();
|
||||
this.lastAcceptedClickByButton.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open every readable mouse device node and turn button-down events into
|
||||
* onOsClick calls. One physical mouse is normally one node; the leading-edge
|
||||
* debounce in onOsClick collapses any cross-node duplicates. Frames are still
|
||||
* served by the stream backend; this only supplies the click *trigger*.
|
||||
*/
|
||||
startEvdevWatcher() {
|
||||
const nodes = readableEvdevMouseNodes();
|
||||
this.evdevStreams = [];
|
||||
for (const node of nodes) {
|
||||
try {
|
||||
const stream = fs.createReadStream(node);
|
||||
let buf = Buffer.alloc(0);
|
||||
stream.on('data', (chunk) => {
|
||||
buf = buf.length ? Buffer.concat([buf, chunk]) : chunk;
|
||||
const { presses, rest } = decodeEvdevButtonPresses(buf);
|
||||
buf = rest;
|
||||
for (const button of presses) this.onOsClick(Date.now(), null, button);
|
||||
});
|
||||
// A device can disappear (unplugged): drop that stream, and if it was
|
||||
// the last live click source, fall back like any other watcher loss
|
||||
// instead of silently going dark.
|
||||
stream.on('error', () => this.handleEvdevStreamLoss(stream, 'device read error'));
|
||||
stream.on('close', () => this.handleEvdevStreamLoss(stream, 'device closed'));
|
||||
this.evdevStreams.push(stream);
|
||||
} catch {
|
||||
// Node became unreadable between enumeration and open — skip it.
|
||||
}
|
||||
}
|
||||
if (!this.evdevStreams.length) {
|
||||
this.clickSource = 'unavailable';
|
||||
console.error('[stepforge] no readable mouse input devices for per-click capture (see docs/linux for the least-privilege device-access setup)');
|
||||
} else {
|
||||
// evdev carries no cursor position on Wayland (no marker); on X11 without
|
||||
// xinput it is the click source but likewise has no root coordinates.
|
||||
this.clickSource = this.onWayland() ? 'evdev-wayland' : 'evdev-x11';
|
||||
console.log(`[stepforge] per-click capture via evdev on ${this.evdevStreams.length} device(s)${this.onWayland() ? ' (Wayland: no click marker)' : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** One evdev device stream ended; drop it and fall back if none remain. */
|
||||
handleEvdevStreamLoss(stream, reason) {
|
||||
if (!this.evdevStreams) return; // already stopped deliberately
|
||||
const idx = this.evdevStreams.indexOf(stream);
|
||||
if (idx === -1) return;
|
||||
this.evdevStreams.splice(idx, 1);
|
||||
try { stream.destroy(); } catch { /* already gone */ }
|
||||
if (this.evdevStreams.length === 0) {
|
||||
this.evdevStreams = null;
|
||||
this.clickSource = 'unavailable';
|
||||
this.handleClickWatcherLoss(`evdev ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
stopEvdevWatcher() {
|
||||
if (!this.evdevStreams) return;
|
||||
const streams = this.evdevStreams;
|
||||
this.evdevStreams = null; // clear first so close handlers no-op
|
||||
for (const stream of streams) {
|
||||
try { stream.destroy(); } catch { /* already closed */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffer stdout chunks and only parse complete lines: a chunk boundary
|
||||
* can split an event line in half, which used to corrupt press/release
|
||||
@@ -1396,7 +1776,11 @@ public static class SFHook {
|
||||
// filtered by the cursor-position check in sessionCapture, not by
|
||||
// window focus — WSLg reports focus unreliably.)
|
||||
let clickPos = osPoint ? this.osPointToDip(osPoint) : null;
|
||||
if (!clickPos) clickPos = this.screen.getCursorScreenPoint();
|
||||
// Read the live cursor as a fallback only off Wayland: Wayland refuses to
|
||||
// report the global pointer position (getCursorScreenPoint returns 0,0), so
|
||||
// a fallback there would stamp every click marker in the top-left corner.
|
||||
// Leaving clickPos null means the step is captured with no (wrong) marker.
|
||||
if (!clickPos && !this.onWayland()) clickPos = this.screen.getCursorScreenPoint();
|
||||
clog('click@', clickAt, button, 'os', osPoint, '-> dip', clickPos);
|
||||
this.pendingClickOsPoint = osPoint && Number.isFinite(osPoint.x) && Number.isFinite(osPoint.y)
|
||||
? { x: osPoint.x, y: osPoint.y }
|
||||
@@ -1428,20 +1812,32 @@ public static class SFHook {
|
||||
* scaled away from 100% and on secondary monitors.
|
||||
*/
|
||||
osPointToDip(osPoint) {
|
||||
if (this.screen && typeof this.screen.screenToDipPoint === 'function') {
|
||||
try {
|
||||
const dip = this.screen.screenToDipPoint(osPoint);
|
||||
if (dip && Number.isFinite(dip.x) && Number.isFinite(dip.y)) return dip;
|
||||
} catch { /* fall through to manual conversion */ }
|
||||
}
|
||||
let geometryDip = null;
|
||||
try {
|
||||
const displays = this.screen && typeof this.screen.getAllDisplays === 'function'
|
||||
? this.screen.getAllDisplays()
|
||||
: [];
|
||||
const dip = physicalToDip(osPoint, displays);
|
||||
if (dip) return dip;
|
||||
} catch { /* no display geometry available */ }
|
||||
return osPoint;
|
||||
geometryDip = physicalToDip(osPoint, displays);
|
||||
} catch {
|
||||
geometryDip = null;
|
||||
}
|
||||
if (this.screen && typeof this.screen.screenToDipPoint === 'function') {
|
||||
try {
|
||||
const dip = this.screen.screenToDipPoint(osPoint);
|
||||
if (dip && Number.isFinite(dip.x) && Number.isFinite(dip.y)) {
|
||||
if (!geometryDip) return dip;
|
||||
const offByX = Math.abs(dip.x - geometryDip.x);
|
||||
const offByY = Math.abs(dip.y - geometryDip.y);
|
||||
// Some Windows/Electron combinations have been observed to return a
|
||||
// raw physical point here. That keeps the click marker off-screen on
|
||||
// scaled displays, so trust the geometry path when the two disagree
|
||||
// by more than a tiny rounding margin.
|
||||
if (offByX <= 1 && offByY <= 1) return dip;
|
||||
return geometryDip;
|
||||
}
|
||||
} catch { /* fall through to manual conversion */ }
|
||||
}
|
||||
return geometryDip || osPoint;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1500,6 +1896,11 @@ public static class SFHook {
|
||||
this._keyLastAt = now;
|
||||
|
||||
if (type === 'CHAR') {
|
||||
// Raw typed-text capture is off by default: it can record passwords or
|
||||
// other secrets. Only buffer printable characters when the user has
|
||||
// explicitly opted in via capture.captureTypedText. Shortcut/navigation
|
||||
// keys below are unaffected.
|
||||
if (!this.settings.get('capture.captureTypedText')) return;
|
||||
const ch = typeof data === 'number' ? String.fromCharCode(data) : String(data);
|
||||
this._keyBuffer = (this._keyBuffer + ch).slice(-200);
|
||||
} else if (type === 'KEY') {
|
||||
@@ -1704,13 +2105,43 @@ public static class SFHook {
|
||||
const cropped = image.crop(rect);
|
||||
const size = cropped.getSize();
|
||||
if (!size.width || !size.height) return { ok: false, reason: 'empty selection' };
|
||||
const step = await this.storeFrameAsStep(guideId, 'region', {
|
||||
// storeFrameAsStep already returns { ok, step }; return it directly so
|
||||
// callers see result.step.stepId — not result.step.step.stepId, which is
|
||||
// what wrapping it in another { ok, step } produced (region selection and
|
||||
// region auto-documentation both read result.step.stepId).
|
||||
return this.storeFrameAsStep(guideId, 'region', {
|
||||
image: cropped,
|
||||
size,
|
||||
display,
|
||||
cursor: null,
|
||||
}, null, null);
|
||||
return { ok: true, step };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an overlay selection (display px) into an image-space crop rect,
|
||||
* clamped to the image bounds. Returns null for an empty/invalid rect.
|
||||
*/
|
||||
overlayRectToImageRect(rect, display, imgSize) {
|
||||
if (!rect || !imgSize) return null;
|
||||
const { width: iw, height: ih } = imgSize;
|
||||
if (!iw || !ih) return null;
|
||||
const sx = iw / display.bounds.width;
|
||||
const sy = ih / display.bounds.height;
|
||||
const toNum = (v) => (Number.isFinite(v) ? v : 0);
|
||||
let x = Math.round(toNum(rect.x) * sx);
|
||||
let y = Math.round(toNum(rect.y) * sy);
|
||||
let w = Math.round(toNum(rect.w) * sx);
|
||||
let h = Math.round(toNum(rect.h) * sy);
|
||||
// Normalize negative-size drags (drawn up/left).
|
||||
if (w < 0) { x += w; w = -w; }
|
||||
if (h < 0) { y += h; h = -h; }
|
||||
// Clamp to the image so image.crop can never read out of bounds.
|
||||
x = Math.min(Math.max(0, x), iw);
|
||||
y = Math.min(Math.max(0, y), ih);
|
||||
w = Math.min(w, iw - x);
|
||||
h = Math.min(h, ih - y);
|
||||
if (w <= 0 || h <= 0) return null;
|
||||
return { x, y, width: w, height: h };
|
||||
}
|
||||
|
||||
/** Fullscreen overlay window that resolves with a crop rect (image px). */
|
||||
@@ -1729,35 +2160,38 @@ public static class SFHook {
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'region-preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
},
|
||||
});
|
||||
// The overlay may only display region.html; deny navigation/popups.
|
||||
require('./security').installWindowSecurity(overlay, 'region');
|
||||
const { ipcMain } = require('electron');
|
||||
let settled = false;
|
||||
// Idempotent cleanup: remove the IPC listener and close the overlay
|
||||
// exactly once, whether the user picked, cancelled, closed, or the page
|
||||
// failed to load. Previously the listener was only removed on a pick, so
|
||||
// cancelling/closing leaked it (and the captured overlay/image refs).
|
||||
const finish = (rect) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
ipcMain.removeListener('region:picked', onPick);
|
||||
if (!overlay.isDestroyed()) overlay.close();
|
||||
resolve(rect);
|
||||
};
|
||||
const { ipcMain } = require('electron');
|
||||
const onPick = (event, rect) => {
|
||||
if (event.sender !== overlay.webContents) return;
|
||||
ipcMain.removeListener('region:picked', onPick);
|
||||
if (!rect) return finish(null);
|
||||
const imgSize = image.getSize();
|
||||
const sx = imgSize.width / display.bounds.width;
|
||||
const sy = imgSize.height / display.bounds.height;
|
||||
finish({
|
||||
x: Math.round(rect.x * sx),
|
||||
y: Math.round(rect.y * sy),
|
||||
width: Math.round(rect.w * sx),
|
||||
height: Math.round(rect.h * sy),
|
||||
});
|
||||
finish(this.overlayRectToImageRect(rect, display, image.getSize()));
|
||||
};
|
||||
ipcMain.on('region:picked', onPick);
|
||||
overlay.on('closed', () => finish(null));
|
||||
overlay.loadFile(path.join(__dirname, 'renderer', 'region.html'));
|
||||
overlay.webContents.on('did-fail-load', () => finish(null));
|
||||
overlay.loadFile(path.join(__dirname, 'renderer', 'region.html')).catch(() => finish(null));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CaptureService;
|
||||
// Exposed for unit tests (pure, no device access).
|
||||
module.exports.decodeEvdevButtonPresses = decodeEvdevButtonPresses;
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
const {
|
||||
app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, globalShortcut,
|
||||
clipboard, nativeImage, screen, powerSaveBlocker,
|
||||
clipboard, nativeImage, screen, powerSaveBlocker, session, desktopCapturer,
|
||||
} = require('electron');
|
||||
|
||||
const { GuideStore } = require('../core/store');
|
||||
@@ -15,12 +16,15 @@ const { TemplateManager, FORMATS, FORMAT_LABELS } = require('../core/templates')
|
||||
const { buildRenderAst } = require('../core/renderast');
|
||||
const { runExport, EXPORTERS } = require('../exporters');
|
||||
const { runExportInWorker } = require('./export-runner');
|
||||
const { exportGuideArchive, importGuideArchive, saveLinkedGuide, readArchive } = require('../core/archive');
|
||||
const { createSnapshot, listSnapshots, restoreSnapshot } = require('../core/snapshots');
|
||||
const { exportGuideArchive, importGuideArchive, saveLinkedGuide } = require('../core/archive');
|
||||
const { createSnapshot, listSnapshots, restoreSnapshot, autoSnapshotIfDue } = require('../core/snapshots');
|
||||
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'));
|
||||
|
||||
const APP_ID = 'com.stepforge.app';
|
||||
|
||||
@@ -62,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 {
|
||||
@@ -69,6 +77,9 @@ function reindex(guideId) {
|
||||
} catch {
|
||||
// index failures must never block saves
|
||||
}
|
||||
// Automatic backup policy runs on the same save choke point. It is
|
||||
// self-contained and never throws, so it can't affect the save either.
|
||||
autoSnapshotIfDue(store, guideId, settings);
|
||||
}
|
||||
|
||||
function orderedSteps(guideId) {
|
||||
@@ -81,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,
|
||||
@@ -94,9 +142,29 @@ function createWindow() {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
spellcheck: Boolean(settings.get('spellcheck')),
|
||||
// During a recording the window is minimized (Linux) or hidden (Windows).
|
||||
// A throttled renderer stops processing capture:added events, so the step
|
||||
// list and capture bar appear "stuck" even though steps are saved. Keep
|
||||
// the renderer live so the UI updates in real time while recording.
|
||||
backgroundThrottling: false,
|
||||
},
|
||||
});
|
||||
// The main window may only ever display our index.html: all navigation
|
||||
// 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();
|
||||
@@ -192,7 +260,12 @@ function createWindow() {
|
||||
// Second scenario, reproducing the "I clicked many times but only
|
||||
// got two screenshots" report: a fast burst of clicks immediately
|
||||
// followed by finishing the session, so most clicks are still
|
||||
// queued (frames still encoding) when the stop lands.
|
||||
// queued (frames still encoding) when the stop lands. This scenario
|
||||
// tests the queue DRAIN, not strict timing — 30ms-apart clicks
|
||||
// outpace the frame sampler, so run it in balanced mode where every
|
||||
// queued click stores. (Strict-mode skip-vs-store is covered by the
|
||||
// marker scenario above and by unit tests.)
|
||||
settings.set('capture.strictClickFrames', false);
|
||||
const burstGuide = store.createGuide({ title: 'burst selftest' });
|
||||
capture.startSession(burstGuide.guideId, { intervalSec: 0 });
|
||||
capture.stopClickWatcher();
|
||||
@@ -216,6 +289,7 @@ function createWindow() {
|
||||
const burstSteps = store.getGuide(burstGuide.guideId).stepsOrder.length;
|
||||
console.log('CLICK-SELFTEST burst:', burstSteps, 'of', burstCount,
|
||||
burstSteps === burstCount ? 'OK — no clicks dropped on finish' : 'FAIL — clicks lost');
|
||||
settings.set('capture.strictClickFrames', true); // restore for later scenarios
|
||||
|
||||
// Helper: wait until armRecording has finished warming (window
|
||||
// hidden, buffer primed) so an injected click counts as a real
|
||||
@@ -226,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
|
||||
@@ -246,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),
|
||||
@@ -273,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),
|
||||
@@ -343,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 {
|
||||
@@ -369,7 +489,45 @@ function sendToRenderer(channel, payload) {
|
||||
// ---- IPC ------------------------------------------------------------------
|
||||
|
||||
function setupIpc() {
|
||||
const h = (channel, fn) => ipcMain.handle(channel, async (event, args = {}) => fn(args));
|
||||
// Every invoke channel is guarded: the event must come from the current
|
||||
// main window's top frame showing our index.html, the argument bag must be
|
||||
// a plain object within a per-channel payload budget, and channels with
|
||||
// risky inputs additionally validate fields before the handler runs.
|
||||
const trustedSender = security.makeIpcSenderGuard({
|
||||
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;
|
||||
ipcMain.handle(channel, async (event, args = {}) => {
|
||||
if (!trustedSender(event)) {
|
||||
throw new Error(`${channel}: rejected — untrusted IPC sender`);
|
||||
}
|
||||
const a = args === undefined || args === null ? {} : args;
|
||||
if (!security.isPlainArgs(a) || !security.payloadWithinBudget(a, maxChars)) {
|
||||
throw new Error(`${channel}: rejected — invalid or oversized arguments`);
|
||||
}
|
||||
if (validate && !validate(a)) {
|
||||
throw new Error(`${channel}: rejected — arguments failed validation`);
|
||||
}
|
||||
return fn(a);
|
||||
});
|
||||
};
|
||||
|
||||
// Files the main process itself produced (exports, previews); only these
|
||||
// may be re-opened via the shell on renderer request.
|
||||
const producedFiles = new security.ProducedFiles();
|
||||
// Output directories the user actually picked in a dialog this session.
|
||||
const chosenOutputDirs = new Set();
|
||||
|
||||
// library
|
||||
h('library:list', () => ({
|
||||
@@ -387,60 +545,71 @@ function setupIpc() {
|
||||
});
|
||||
reindex(guide.guideId);
|
||||
return guide;
|
||||
});
|
||||
}, { validate: (a) => c.optionalString(a.title, 500) });
|
||||
h('library:duplicate', ({ guideId }) => {
|
||||
const copy = store.duplicateGuide(guideId);
|
||||
reindex(copy.guideId);
|
||||
return copy;
|
||||
});
|
||||
}, { validate: (a) => c.id(a.guideId) });
|
||||
h('library:delete', ({ guideId }) => {
|
||||
store.deleteGuide(guideId);
|
||||
searchIndex.removeGuide(guideId);
|
||||
return true;
|
||||
});
|
||||
h('library:setFavorite', ({ guideId, favorite }) => store.setFavorite(guideId, favorite));
|
||||
}, { validate: (a) => c.id(a.guideId) });
|
||||
h('library:setFavorite', ({ guideId, favorite }) => store.setFavorite(guideId, favorite),
|
||||
{ validate: (a) => c.id(a.guideId) });
|
||||
h('library:trash:list', () => store.listTrash());
|
||||
h('library:trash:restore', ({ name }) => {
|
||||
const id = store.restoreFromTrash(name);
|
||||
reindex(id);
|
||||
return id;
|
||||
});
|
||||
}, { validate: (a) => c.fileName(a.name) });
|
||||
h('library:trash:purge', ({ names } = {}) => {
|
||||
if (names && names.length) store.purgeTrashItems(names);
|
||||
else store.purgeTrash();
|
||||
return true;
|
||||
}, {
|
||||
validate: (a) => a.names === undefined || a.names === null
|
||||
|| (Array.isArray(a.names) && a.names.length <= 1000 && a.names.every((n) => c.fileName(n))),
|
||||
});
|
||||
h('folders:create', ({ name, parentId }) => store.createFolder(name, parentId || null));
|
||||
h('folders:rename', ({ folderId, name }) => store.renameFolder(folderId, name));
|
||||
h('folders:delete', ({ folderId }) => store.deleteFolder(folderId));
|
||||
h('folders:moveGuide', ({ guideId, folderId }) => store.moveGuideToFolder(guideId, folderId || null));
|
||||
h('folders:create', ({ name, parentId }) => store.createFolder(name, parentId || null),
|
||||
{ validate: (a) => c.string(a.name, 200) && c.optionalId(a.parentId) });
|
||||
h('folders:rename', ({ folderId, name }) => store.renameFolder(folderId, name),
|
||||
{ validate: (a) => c.id(a.folderId) && c.string(a.name, 200) });
|
||||
h('folders:delete', ({ folderId }) => store.deleteFolder(folderId),
|
||||
{ validate: (a) => c.id(a.folderId) });
|
||||
h('folders:moveGuide', ({ guideId, folderId }) => store.moveGuideToFolder(guideId, folderId || null),
|
||||
{ validate: (a) => c.id(a.guideId) && c.optionalId(a.folderId) });
|
||||
|
||||
// guide + steps
|
||||
h('guide:get', ({ guideId }) => ({
|
||||
guide: store.getGuide(guideId),
|
||||
steps: orderedSteps(guideId),
|
||||
}));
|
||||
}), { validate: (a) => c.id(a.guideId) });
|
||||
h('guide:save', ({ guide }) => {
|
||||
const saved = store.saveGuide(guide);
|
||||
reindex(guide.guideId);
|
||||
return saved;
|
||||
});
|
||||
}, { validate: (a) => security.isPlainArgs(a.guide) && a.guide && c.id(a.guide.guideId) });
|
||||
h('step:add', ({ guideId, fields, imageBase64, size, position }) => {
|
||||
const buf = imageBase64 ? Buffer.from(imageBase64, 'base64') : null;
|
||||
const step = store.addStep(guideId, fields || {}, buf, size || null, { position });
|
||||
reindex(guideId);
|
||||
return step;
|
||||
}, {
|
||||
maxChars: IMAGE_BUDGET,
|
||||
validate: (a) => c.id(a.guideId) && c.optionalBase64(a.imageBase64) && c.optionalNumber(a.position, 0, 100000),
|
||||
});
|
||||
h('step:save', ({ guideId, step }) => {
|
||||
const saved = store.saveStep(guideId, step);
|
||||
reindex(guideId);
|
||||
return saved;
|
||||
});
|
||||
}, { validate: (a) => c.id(a.guideId) && security.isPlainArgs(a.step) && a.step && c.id(a.step.stepId) });
|
||||
h('step:delete', ({ guideId, stepId }) => {
|
||||
store.deleteStep(guideId, stepId);
|
||||
reindex(guideId);
|
||||
return true;
|
||||
});
|
||||
}, { validate: (a) => c.id(a.guideId) && c.id(a.stepId) });
|
||||
h('step:restore', ({ guideId, step, originalBase64, workingBase64, position }) => {
|
||||
const images = {
|
||||
original: originalBase64 ? Buffer.from(originalBase64, 'base64') : null,
|
||||
@@ -449,20 +618,39 @@ function setupIpc() {
|
||||
const restored = store.restoreStep(guideId, step, images, position);
|
||||
reindex(guideId);
|
||||
return restored;
|
||||
}, {
|
||||
maxChars: IMAGE_BUDGET,
|
||||
validate: (a) => c.id(a.guideId) && security.isPlainArgs(a.step) && a.step
|
||||
&& c.optionalBase64(a.originalBase64) && c.optionalBase64(a.workingBase64)
|
||||
&& c.optionalNumber(a.position, 0, 100000),
|
||||
});
|
||||
h('steps:reorder', ({ guideId, order }) => store.reorderSteps(guideId, order), {
|
||||
validate: (a) => c.id(a.guideId)
|
||||
&& Array.isArray(a.order) && a.order.length <= 100000 && a.order.every((id) => c.id(id)),
|
||||
});
|
||||
h('steps:reorder', ({ guideId, order }) => store.reorderSteps(guideId, order));
|
||||
h('step:imagePath', ({ guideId, stepId, which }) => {
|
||||
const p = store.stepImagePath(guideId, stepId, which || 'working');
|
||||
return p && fs.existsSync(p) ? `file://${p}?v=${fs.statSync(p).mtimeMs}` : null;
|
||||
if (!p || !fs.existsSync(p)) return null;
|
||||
// pathToFileURL correctly encodes spaces, #, %, drive letters, etc.; the
|
||||
// mtime is a cache-buster so the renderer reloads after an edit.
|
||||
const url = pathToFileURL(p);
|
||||
url.searchParams.set('v', String(fs.statSync(p).mtimeMs));
|
||||
return url.href;
|
||||
}, {
|
||||
validate: (a) => c.id(a.guideId) && c.id(a.stepId)
|
||||
&& (a.which === undefined || a.which === null || c.oneOf(a.which, ['original', 'working'])),
|
||||
});
|
||||
h('step:setWorkingImage', ({ guideId, stepId, pngBase64, size, step }) =>
|
||||
store.setWorkingImage(guideId, stepId, Buffer.from(pngBase64, 'base64'), size, step || null));
|
||||
store.setWorkingImage(guideId, stepId, Buffer.from(pngBase64, 'base64'), size, step || null), {
|
||||
maxChars: IMAGE_BUDGET,
|
||||
validate: (a) => c.id(a.guideId) && c.id(a.stepId) && c.base64(a.pngBase64),
|
||||
});
|
||||
h('step:resetWorkingImage', ({ guideId, stepId }) => {
|
||||
const p = store.stepImagePath(guideId, stepId, 'original');
|
||||
const img = nativeImage.createFromPath(p);
|
||||
const { width, height } = img.getSize();
|
||||
return store.resetWorkingImage(guideId, stepId, { width, height });
|
||||
});
|
||||
}, { validate: (a) => c.id(a.guideId) && c.id(a.stepId) });
|
||||
h('step:fromClipboard', ({ guideId, position }) => {
|
||||
const img = clipboard.readImage();
|
||||
if (img.isEmpty()) return { ok: false, reason: 'clipboard has no image' };
|
||||
@@ -473,7 +661,7 @@ function setupIpc() {
|
||||
}, img.toPNG(), { width, height }, { position });
|
||||
reindex(guideId);
|
||||
return { ok: true, step };
|
||||
});
|
||||
}, { validate: (a) => c.id(a.guideId) && c.optionalNumber(a.position, 0, 100000) });
|
||||
h('step:importImage', async ({ guideId }) => {
|
||||
const res = await dialog.showOpenDialog(mainWindow, {
|
||||
title: 'Import images as steps',
|
||||
@@ -491,11 +679,13 @@ function setupIpc() {
|
||||
}
|
||||
reindex(guideId);
|
||||
return { ok: true, steps };
|
||||
});
|
||||
}, { validate: (a) => c.id(a.guideId) });
|
||||
|
||||
// search
|
||||
h('search:query', ({ q, guideId }) => searchIndex.search(q, { guideId: guideId || null }));
|
||||
h('search:titles', ({ q }) => searchIndex.searchTitles(q));
|
||||
h('search:query', ({ q, guideId }) => searchIndex.search(q, { guideId: guideId || null }),
|
||||
{ validate: (a) => c.optionalString(a.q, 1000) && c.optionalId(a.guideId) });
|
||||
h('search:titles', ({ q }) => searchIndex.searchTitles(q),
|
||||
{ validate: (a) => c.optionalString(a.q, 1000) });
|
||||
|
||||
// settings + placeholders
|
||||
h('settings:all', () => settings.data);
|
||||
@@ -504,13 +694,13 @@ function setupIpc() {
|
||||
if (keyPath === 'appearance') applyTheme();
|
||||
if (keyPath.startsWith('capture.hotkey')) registerHotkeys();
|
||||
return settings.data;
|
||||
});
|
||||
}, { validate: (a) => c.settingsKeyPath(a.keyPath) });
|
||||
h('ai:test', async ({ enabled = null, ollama = null } = {}) => {
|
||||
return textIntel.testAiConnection({
|
||||
enabled,
|
||||
ollama,
|
||||
});
|
||||
});
|
||||
}, { validate: (a) => (a.ollama === undefined || a.ollama === null || security.isPlainArgs(a.ollama)) });
|
||||
h('ai:fillStep', async ({ guideId, stepId, target = 'all', blockId = null } = {}) => {
|
||||
const result = await textIntel.generateStepPatch({
|
||||
guideId,
|
||||
@@ -520,10 +710,22 @@ function setupIpc() {
|
||||
});
|
||||
if (result.ok) reindex(guideId);
|
||||
return result;
|
||||
}, {
|
||||
validate: (a) => c.id(a.guideId) && c.id(a.stepId) && c.optionalId(a.blockId)
|
||||
&& (a.target === undefined || c.oneOf(a.target, ['all', 'title', 'description', 'block'])),
|
||||
});
|
||||
h('ai:rewriteText', async ({ text, guideTitle = '', stepTitle = '' } = {}) => {
|
||||
return textIntel.rewriteText({ text, guideTitle, stepTitle });
|
||||
}, {
|
||||
validate: (a) => c.string(a.text, 200000)
|
||||
&& c.optionalString(a.guideTitle, 1000) && c.optionalString(a.stepTitle, 1000),
|
||||
});
|
||||
// Cancel outstanding AI requests, e.g. when a guide/editor closes, so a
|
||||
// slow response can't resolve against data the user has moved on from.
|
||||
h('ai:cancel', ({ guideId = null } = {}) => {
|
||||
textIntel.cancelInflight(guideId || null);
|
||||
return true;
|
||||
}, { validate: (a) => c.optionalId(a.guideId) });
|
||||
h('placeholders:globals:get', () => settings.getGlobalPlaceholders());
|
||||
h('placeholders:globals:set', (values) => settings.setGlobalPlaceholders(values));
|
||||
|
||||
@@ -546,6 +748,10 @@ function setupIpc() {
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, {
|
||||
validate: (a) => c.id(a.guideId)
|
||||
&& (a.mode === undefined || c.oneOf(a.mode, ['fullscreen', 'window', 'region']))
|
||||
&& c.optionalNumber(a.delayMs, 0, 600000),
|
||||
});
|
||||
h('capture:region', async ({ guideId }) => {
|
||||
const result = await capture.regionCapture(guideId);
|
||||
@@ -565,50 +771,31 @@ function setupIpc() {
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
let capturePowerBlocker = -1;
|
||||
const startCapturePower = () => {
|
||||
if (!powerSaveBlocker.isStarted(capturePowerBlocker)) {
|
||||
capturePowerBlocker = powerSaveBlocker.start('prevent-app-suspension');
|
||||
}
|
||||
};
|
||||
const stopCapturePower = () => {
|
||||
if (powerSaveBlocker.isStarted(capturePowerBlocker)) {
|
||||
powerSaveBlocker.stop(capturePowerBlocker);
|
||||
}
|
||||
};
|
||||
|
||||
// Opt every live Electron process (browser, GPU, the screen-capture utility,
|
||||
// any renderers) out of EcoQoS for the duration of a recording. The hidden
|
||||
// capture-worker renderer is created later, during warmup, so it opts itself
|
||||
// out separately (see stream-backend.js); this covers the rest.
|
||||
const keepCaptureProcessesResponsive = () => {
|
||||
try {
|
||||
keepProcessesResponsive(app.getAppMetrics().map((m) => m.pid));
|
||||
} catch { /* metrics unavailable — best effort */ }
|
||||
};
|
||||
}, { validate: (a) => c.id(a.guideId) });
|
||||
|
||||
// Power/throttling state is owned entirely by the capture service's
|
||||
// recording transitions (see createCapturePowerPolicy) so there is exactly
|
||||
// one owner: it is held iff a session is actively recording, and paused,
|
||||
// finished, tray, and second-instance transitions all release it correctly.
|
||||
h('capture:session', async ({ action, guideId, intervalSec }) => {
|
||||
if (action === 'start') {
|
||||
capture.startSession(guideId, { intervalSec: intervalSec ?? null });
|
||||
startCapturePower();
|
||||
keepCaptureProcessesResponsive();
|
||||
} else if (action === 'pause') {
|
||||
capture.togglePause(true);
|
||||
stopCapturePower();
|
||||
} else if (action === 'resume') {
|
||||
capture.togglePause(false);
|
||||
startCapturePower();
|
||||
keepCaptureProcessesResponsive();
|
||||
} else if (action === 'finish') {
|
||||
capture.finishSession();
|
||||
stopCapturePower();
|
||||
} else if (action === 'interval') {
|
||||
capture.setInterval(intervalSec);
|
||||
}
|
||||
const state = capture.state();
|
||||
sendToRenderer('capture:state', state);
|
||||
return state;
|
||||
}, {
|
||||
validate: (a) => c.oneOf(a.action, ['start', 'pause', 'resume', 'finish', 'interval'])
|
||||
&& (a.action !== 'start' || c.id(a.guideId))
|
||||
&& c.optionalNumber(a.intervalSec, 0, 86400),
|
||||
});
|
||||
h('capture:state', () => capture.state());
|
||||
|
||||
@@ -623,7 +810,7 @@ function setupIpc() {
|
||||
if (res.canceled) return { ok: false };
|
||||
exportGuideArchive(store, guideId, res.filePath);
|
||||
return { ok: true, path: res.filePath };
|
||||
});
|
||||
}, { validate: (a) => c.id(a.guideId) });
|
||||
h('archive:open', async ({ mode }) => {
|
||||
const res = await dialog.showOpenDialog(mainWindow, {
|
||||
title: 'Open guide archive',
|
||||
@@ -638,30 +825,45 @@ function setupIpc() {
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err.message };
|
||||
}
|
||||
});
|
||||
h('archive:peek', ({ file }) => {
|
||||
const { manifest } = readArchive(file);
|
||||
return manifest;
|
||||
});
|
||||
h('archive:saveLinked', ({ guideId, force }) => saveLinkedGuide(store, guideId, { force: Boolean(force) }));
|
||||
}, { validate: (a) => a.mode === undefined || c.oneOf(a.mode, ['copy', 'linked']) });
|
||||
// archive:peek was removed: nothing in the renderer used it, and it let a
|
||||
// compromised renderer read arbitrary local archives by path.
|
||||
h('archive:saveLinked', ({ guideId, force }) => saveLinkedGuide(store, guideId, { force: Boolean(force) }),
|
||||
{ validate: (a) => c.id(a.guideId) });
|
||||
|
||||
// snapshots
|
||||
h('snapshots:list', ({ guideId }) => listSnapshots(store, guideId));
|
||||
h('snapshots:list', ({ guideId }) => listSnapshots(store, guideId),
|
||||
{ validate: (a) => c.id(a.guideId) });
|
||||
h('snapshots:create', ({ guideId, label }) =>
|
||||
createSnapshot(store, guideId, { label: label || 'manual', keepLast: settings.get('backups.keepLast') }));
|
||||
createSnapshot(store, guideId, { label: label || 'manual', keepLast: settings.get('backups.keepLast') }),
|
||||
{ validate: (a) => c.id(a.guideId) && c.optionalString(a.label, 200) });
|
||||
h('snapshots:restore', ({ guideId, name }) => {
|
||||
const guide = restoreSnapshot(store, guideId, name);
|
||||
reindex(guideId);
|
||||
return guide;
|
||||
});
|
||||
}, { validate: (a) => c.id(a.guideId) && c.fileName(a.name) });
|
||||
|
||||
// recovery status: corrupt files quarantined this session and search index
|
||||
// health, so the UI can surface them instead of data silently vanishing.
|
||||
h('recovery:status', () => ({
|
||||
quarantined: store.getRecoveryReport(),
|
||||
searchStatus: searchIndex.status,
|
||||
}));
|
||||
|
||||
// templates
|
||||
h('templates:list', ({ format }) => templates.list(format));
|
||||
h('templates:load', ({ format, name }) => templates.load(format, name));
|
||||
h('templates:save', ({ format, name, options }) => templates.save(format, name, options));
|
||||
h('templates:delete', ({ format, name }) => templates.remove(format, name));
|
||||
h('templates:rename', ({ format, name, newName }) => templates.rename(format, name, newName));
|
||||
h('templates:duplicate', ({ format, name }) => templates.duplicate(format, name));
|
||||
const validFormat = (v) => c.oneOf(v, FORMATS);
|
||||
h('templates:list', ({ format }) => templates.list(format),
|
||||
{ validate: (a) => validFormat(a.format) });
|
||||
h('templates:load', ({ format, name }) => templates.load(format, name),
|
||||
{ validate: (a) => validFormat(a.format) && c.fileName(a.name) });
|
||||
h('templates:save', ({ format, name, options }) => templates.save(format, name, options),
|
||||
{ validate: (a) => validFormat(a.format) && c.fileName(a.name) && security.isPlainArgs(a.options) });
|
||||
h('templates:delete', ({ format, name }) => templates.remove(format, name),
|
||||
{ validate: (a) => validFormat(a.format) && c.fileName(a.name) });
|
||||
h('templates:rename', ({ format, name, newName }) => templates.rename(format, name, newName),
|
||||
{ validate: (a) => validFormat(a.format) && c.fileName(a.name) && c.fileName(a.newName) });
|
||||
h('templates:duplicate', ({ format, name }) => templates.duplicate(format, name),
|
||||
{ validate: (a) => validFormat(a.format) && c.fileName(a.name) });
|
||||
h('templates:export', async ({ format, name }) => {
|
||||
const res = await dialog.showSaveDialog(mainWindow, {
|
||||
defaultPath: `${name}.sfglt`,
|
||||
@@ -670,7 +872,7 @@ function setupIpc() {
|
||||
if (res.canceled) return { ok: false };
|
||||
templates.exportTemplate(format, name, res.filePath);
|
||||
return { ok: true };
|
||||
});
|
||||
}, { validate: (a) => validFormat(a.format) && c.fileName(a.name) });
|
||||
h('templates:import', async () => {
|
||||
const res = await dialog.showOpenDialog(mainWindow, {
|
||||
filters: [{ name: 'StepForge template', extensions: ['sfglt'] }],
|
||||
@@ -703,15 +905,24 @@ function setupIpc() {
|
||||
}[format];
|
||||
if (!mod) return {};
|
||||
return { ...require(mod).DEFAULT_TEMPLATE };
|
||||
});
|
||||
}, { validate: (a) => c.string(a.format, 40) });
|
||||
h('export:run', async ({ guideId, format, options, outDir }) => {
|
||||
let dir = outDir || settings.get(`exports.lastOutputDirs.${format}`);
|
||||
// The renderer may only nominate directories that came from this main
|
||||
// process: a dialog pick from this session or a remembered last-output
|
||||
// directory. Anything else is ignored and re-asked via the dialog.
|
||||
const rememberedDirs = Object.values(settings.get('exports.lastOutputDirs') || {});
|
||||
let dir = null;
|
||||
if (outDir && (chosenOutputDirs.has(outDir) || rememberedDirs.includes(outDir))) {
|
||||
dir = outDir;
|
||||
}
|
||||
if (!dir) dir = settings.get(`exports.lastOutputDirs.${format}`);
|
||||
if (!dir) {
|
||||
const res = await dialog.showOpenDialog(mainWindow, {
|
||||
title: 'Choose output folder', properties: ['openDirectory', 'createDirectory'],
|
||||
});
|
||||
if (res.canceled) return { ok: false };
|
||||
dir = res.filePaths[0];
|
||||
chosenOutputDirs.add(dir);
|
||||
}
|
||||
settings.set(`exports.lastOutputDirs.${format}`, dir);
|
||||
const result = await runExportInWorker({
|
||||
@@ -722,17 +933,23 @@ function setupIpc() {
|
||||
outDir: dir,
|
||||
globals: settings.getGlobalPlaceholders(),
|
||||
});
|
||||
producedFiles.add(result.file);
|
||||
if (settings.get('exports.openFolderAfterExport')) shell.showItemInFolder(result.file);
|
||||
return { ok: true, ...result };
|
||||
}, {
|
||||
validate: (a) => c.id(a.guideId) && validFormat(a.format)
|
||||
&& (a.options === undefined || security.isPlainArgs(a.options))
|
||||
&& c.optionalString(a.outDir, 1000),
|
||||
});
|
||||
h('export:chooseDir', async ({ format }) => {
|
||||
const res = await dialog.showOpenDialog(mainWindow, {
|
||||
title: 'Choose output folder', properties: ['openDirectory', 'createDirectory'],
|
||||
});
|
||||
if (res.canceled) return null;
|
||||
chosenOutputDirs.add(res.filePaths[0]);
|
||||
settings.set(`exports.lastOutputDirs.${format}`, res.filePaths[0]);
|
||||
return res.filePaths[0];
|
||||
});
|
||||
}, { validate: (a) => validFormat(a.format) });
|
||||
h('export:preview', ({ guideId, format, options }) => {
|
||||
const previewDir = path.join(store.tempDir, `preview-${guideId}-${format}`);
|
||||
fs.rmSync(previewDir, { recursive: true, force: true });
|
||||
@@ -741,7 +958,11 @@ function setupIpc() {
|
||||
maxSteps: settings.get('exports.previewStepCount') || 3,
|
||||
});
|
||||
const result = runExport(format, ast, previewDir, options || {});
|
||||
return { ok: true, file: result.file, fileUrl: `file://${result.file}` };
|
||||
producedFiles.add(result.file);
|
||||
return { ok: true, file: result.file, fileUrl: pathToFileURL(result.file).href };
|
||||
}, {
|
||||
validate: (a) => c.id(a.guideId) && validFormat(a.format)
|
||||
&& (a.options === undefined || security.isPlainArgs(a.options)),
|
||||
});
|
||||
h('preview:cleanup', () => {
|
||||
for (const entry of fs.readdirSync(store.tempDir)) {
|
||||
@@ -752,14 +973,47 @@ function setupIpc() {
|
||||
return true;
|
||||
});
|
||||
|
||||
// shell helpers
|
||||
h('shell:openPath', ({ target }) => shell.openPath(target));
|
||||
h('shell:showItemInFolder', ({ target }) => shell.showItemInFolder(target));
|
||||
// shell helpers — intent-specific, no arbitrary paths from the renderer.
|
||||
// Only files this main process produced (exports/previews) may be opened.
|
||||
h('shell:openProduced', ({ target }) => {
|
||||
if (!producedFiles.has(target)) {
|
||||
return { ok: false, reason: 'not a StepForge-produced file' };
|
||||
}
|
||||
shell.openPath(target);
|
||||
return { ok: true };
|
||||
}, { validate: (a) => c.string(a.target, 2000) });
|
||||
// Reveal the linked archive of a guide; the path comes from the store,
|
||||
// never from the renderer.
|
||||
h('shell:revealLinkedArchive', ({ guideId }) => {
|
||||
const guide = store.getGuide(guideId);
|
||||
const target = guide && guide.linkedSource && guide.linkedSource.path;
|
||||
if (!target || !fs.existsSync(target)) return { ok: false, reason: 'no linked archive' };
|
||||
shell.showItemInFolder(target);
|
||||
return { ok: true };
|
||||
}, { validate: (a) => c.id(a.guideId) });
|
||||
// Open a user-clicked link in the system browser. Scheme-validated;
|
||||
// everything that is not plain http(s)/mailto is refused.
|
||||
h('shell:openExternal', ({ url }) => {
|
||||
const safe = security.validateExternalUrl(url);
|
||||
if (!safe) return { ok: false, reason: 'blocked URL' };
|
||||
shell.openExternal(safe);
|
||||
return { ok: true };
|
||||
}, { validate: (a) => c.string(a.url, 2048) });
|
||||
h('app:info', () => ({
|
||||
version: app.getVersion(),
|
||||
buildVersion: PACKAGE_JSON.buildVersion || app.getVersion(),
|
||||
dataDir: store.root,
|
||||
platform: process.platform,
|
||||
}));
|
||||
// Platform capture-capability profile (session type, portal/PipeWire,
|
||||
// xinput, click source, actionable messages) for the diagnostics UI, plus
|
||||
// the honest active trigger for this machine and settings.
|
||||
h('platform:capabilities', () => {
|
||||
const platform = require('./platform');
|
||||
const caps = platform.detectCapabilities();
|
||||
const activeTrigger = platform.chooseCaptureTrigger(caps, settings.get('capture.fallbackTrigger') || 'interval');
|
||||
return { ...caps, activeTrigger };
|
||||
});
|
||||
}
|
||||
|
||||
// ---- lifecycle --------------------------------------------------------------
|
||||
@@ -787,6 +1041,17 @@ if (!gotLock) {
|
||||
store = new GuideStore(dataDir);
|
||||
settings = new Settings(store.settingsDir);
|
||||
searchIndex = new SearchIndex(store.indexDir);
|
||||
// Rebuild/reconcile the index against the library at startup so a missing,
|
||||
// corrupt, or version-mismatched index recovers instead of silently
|
||||
// returning nothing.
|
||||
try {
|
||||
const summary = searchIndex.reconcile(store);
|
||||
if (summary.reindexed || summary.removed || summary.status !== 'ok') {
|
||||
console.log(`[stepforge] search index reconciled: ${JSON.stringify(summary)}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[stepforge] search reconcile failed: ${err && err.message}`);
|
||||
}
|
||||
templates = new TemplateManager(store.templatesDir);
|
||||
textIntel = new TextIntelService({
|
||||
store,
|
||||
@@ -827,14 +1092,78 @@ if (!gotLock) {
|
||||
}
|
||||
};
|
||||
|
||||
// Single owner of OS power/throttling state for recording. The capture
|
||||
// service calls setRecording(true/false) on every recording transition;
|
||||
// this holds a power-save blocker and opts live Electron processes out of
|
||||
// EcoQoS while recording, and releases the blocker when recording stops.
|
||||
const capturePowerPolicy = (() => {
|
||||
let blocker = -1;
|
||||
const keepResponsive = () => {
|
||||
try { keepProcessesResponsive(app.getAppMetrics().map((m) => m.pid)); } catch { /* best effort */ }
|
||||
};
|
||||
return {
|
||||
setRecording(recording) {
|
||||
if (recording) {
|
||||
if (!powerSaveBlocker.isStarted(blocker)) {
|
||||
blocker = powerSaveBlocker.start('prevent-app-suspension');
|
||||
}
|
||||
keepResponsive();
|
||||
} else if (powerSaveBlocker.isStarted(blocker)) {
|
||||
powerSaveBlocker.stop(blocker);
|
||||
}
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
capture = new CaptureService({
|
||||
store,
|
||||
settings,
|
||||
getWindow: () => mainWindow,
|
||||
notify: captureNotify,
|
||||
textIntel,
|
||||
powerPolicy: capturePowerPolicy,
|
||||
});
|
||||
|
||||
// Deny-by-default permission policy. The only grant in the entire app is
|
||||
// display capture (and the media permission getDisplayMedia consults) for
|
||||
// the dedicated hidden capture-worker page. Electron 29+ requires that
|
||||
// explicit grant; everything else — including our own main window — is
|
||||
// rejected. "Content is local" is not a security control.
|
||||
session.defaultSession.setPermissionCheckHandler((wc, permission, requestingOrigin, details) => {
|
||||
const url = (details && details.requestingUrl) || (wc && wc.getURL()) || requestingOrigin;
|
||||
return security.permissionAllowed(permission, url);
|
||||
});
|
||||
session.defaultSession.setPermissionRequestHandler((wc, permission, cb, details) => {
|
||||
const url = (details && details.requestingUrl) || (wc && wc.getURL());
|
||||
cb(security.permissionAllowed(permission, url));
|
||||
});
|
||||
|
||||
// On GNOME Wayland the only working screen-capture path is the portal-backed
|
||||
// getDisplayMedia (desktopCapturer source ids fail with "device not found").
|
||||
// The worker calls getDisplayMedia; this handler answers it. Calling
|
||||
// getSources() *inside* the handler is the documented Wayland path: it
|
||||
// drives the XDG portal picker (shown once when a recording starts), and
|
||||
// the chosen source then streams for the whole session. (useSystemPicker is
|
||||
// macOS-only today, harmless elsewhere.)
|
||||
session.defaultSession.setDisplayMediaRequestHandler((request, callback) => {
|
||||
// Only the capture worker page may open a desktop stream.
|
||||
const frameUrl = request && request.frame && request.frame.url;
|
||||
if (!security.isAppPageUrl(frameUrl, 'captureWorker')) {
|
||||
console.error('[stepforge] display-media request denied for', frameUrl || '(unknown frame)');
|
||||
callback({});
|
||||
return;
|
||||
}
|
||||
desktopCapturer.getSources({ types: ['screen'] })
|
||||
.then((sources) => {
|
||||
console.log(`[stepforge] display-media request resolved: ${sources.length} screen source(s)`);
|
||||
callback(sources.length ? { video: sources[0] } : {});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`[stepforge] display-media getSources failed: ${err && err.message}`);
|
||||
callback({});
|
||||
});
|
||||
}, { useSystemPicker: true });
|
||||
|
||||
applyTheme();
|
||||
setupIpc();
|
||||
createWindow();
|
||||
@@ -845,6 +1174,19 @@ if (!gotLock) {
|
||||
});
|
||||
});
|
||||
|
||||
// Drain clicks still encoding in the capture queue before the app exits, so
|
||||
// a fast burst immediately before quit is not lost. Defer the quit exactly
|
||||
// once with a bounded deadline, then let it proceed.
|
||||
let quitDrained = false;
|
||||
app.on('before-quit', (event) => {
|
||||
if (quitDrained || !capture) return;
|
||||
quitDrained = true;
|
||||
event.preventDefault();
|
||||
// Stop new clicks from being queued, then wait for the queue to settle.
|
||||
capture.stopClickWatcher();
|
||||
capture.drainPendingClicks(2000).finally(() => app.quit());
|
||||
});
|
||||
|
||||
app.on('will-quit', () => {
|
||||
globalShortcut.unregisterAll();
|
||||
if (capture) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
'use strict';
|
||||
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
/**
|
||||
* macOS WindowContextProvider using AppleScript / System Events. Extracted
|
||||
* verbatim from text-intel.js. macOS is not a primary support target, but the
|
||||
* adapter is kept so the shared code has no `process.platform` branch and the
|
||||
* behavior is preserved where it exists. Never throws.
|
||||
*/
|
||||
function createDarwinWindowContextProvider() {
|
||||
return {
|
||||
async collect() {
|
||||
const script = `
|
||||
set appName to ""
|
||||
set windowTitle to ""
|
||||
tell application "System Events"
|
||||
try
|
||||
set frontApp to first application process whose frontmost is true
|
||||
set appName to name of frontApp
|
||||
try
|
||||
set windowTitle to name of front window of frontApp
|
||||
end try
|
||||
end try
|
||||
end tell
|
||||
return appName & linefeed & windowTitle
|
||||
`;
|
||||
try {
|
||||
const result = execFileSync('osascript', ['-e', script], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 1200,
|
||||
}).trimEnd();
|
||||
const [appName = '', windowTitle = ''] = result.split(/\r?\n/);
|
||||
return { appName, windowTitle };
|
||||
} catch {
|
||||
return { appName: '', windowTitle: '' };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createDarwinWindowContextProvider };
|
||||
@@ -0,0 +1,81 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* The single factory that selects a platform implementation. The rest of the
|
||||
* app depends on the interfaces in ./interfaces.js and asks this module for a
|
||||
* concrete adapter — it never branches on `process.platform` itself.
|
||||
*
|
||||
* As Linux runtime capture is implemented, its ClickSource / ScreenFrameSource
|
||||
* adapters are added here; today this provides the WindowContextProvider for
|
||||
* every platform and the Linux capability diagnostics.
|
||||
*/
|
||||
|
||||
const { assertWindowContextProvider } = require('./interfaces');
|
||||
|
||||
function detectPlatform(platform = process.platform) {
|
||||
if (platform === 'win32') return 'windows';
|
||||
if (platform === 'darwin') return 'darwin';
|
||||
if (platform === 'linux') return 'linux';
|
||||
return 'unsupported';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the WindowContextProvider for the current OS. `platform` is injectable
|
||||
* so the selection logic is unit-testable off the target OS.
|
||||
*/
|
||||
function createWindowContextProvider({ platform = process.platform } = {}) {
|
||||
const os = detectPlatform(platform);
|
||||
let provider;
|
||||
switch (os) {
|
||||
case 'windows':
|
||||
provider = require('./windows/window-context').createWindowsWindowContextProvider();
|
||||
break;
|
||||
case 'darwin':
|
||||
provider = require('./darwin/window-context').createDarwinWindowContextProvider();
|
||||
break;
|
||||
case 'linux':
|
||||
provider = require('./linux/window-context').createLinuxWindowContextProvider();
|
||||
break;
|
||||
default:
|
||||
// Unsupported OS: a null-object provider so callers still work.
|
||||
provider = { async collect() { return { appName: '', windowTitle: '' }; } };
|
||||
}
|
||||
return assertWindowContextProvider(provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability profile for the current OS (used by diagnostics UI). Only Linux
|
||||
* has a rich profile today; other platforms report their OS and a capable
|
||||
* baseline.
|
||||
*/
|
||||
function detectCapabilities({ platform = process.platform, env = process.env } = {}) {
|
||||
const os = detectPlatform(platform);
|
||||
if (os === 'linux') {
|
||||
return require('./linux/diagnostics').detectLinuxCapabilities({ env });
|
||||
}
|
||||
return {
|
||||
os,
|
||||
sessionType: os,
|
||||
isWayland: false,
|
||||
clickCapture: os === 'windows' ? 'windows-hook' : os,
|
||||
screenCapture: os,
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The honest capture-trigger decision for the current capabilities. On Linux
|
||||
* this defers to the diagnostics helper (which never promises per-click
|
||||
* capture with coordinates on Wayland); other platforms have a fixed answer.
|
||||
*/
|
||||
function chooseCaptureTrigger(capabilities, userTriggerPreference = 'interval') {
|
||||
if (capabilities && capabilities.os === 'linux') {
|
||||
return require('./linux/diagnostics').chooseCaptureTrigger(capabilities, userTriggerPreference);
|
||||
}
|
||||
if (capabilities && capabilities.os === 'windows') {
|
||||
return { trigger: 'click', clickSource: 'windows-hook', coordinates: true, marker: true, note: '' };
|
||||
}
|
||||
return { trigger: 'click', clickSource: capabilities ? capabilities.os : 'unavailable', coordinates: true, marker: true, note: '' };
|
||||
}
|
||||
|
||||
module.exports = { detectPlatform, createWindowContextProvider, detectCapabilities, chooseCaptureTrigger };
|
||||
@@ -0,0 +1,62 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Platform adapter interfaces (documentation + light runtime shape checks).
|
||||
*
|
||||
* The platform-neutral capture/text-intel code consumes these interfaces and
|
||||
* never inspects `process.platform` itself. `app/platform/index.js` is the
|
||||
* only module that selects a concrete implementation. New OS support is a new
|
||||
* set of files under `app/platform/<os>/`, not more conditionals inside the
|
||||
* shared code.
|
||||
*
|
||||
* ---------------------------------------------------------------------------
|
||||
* WindowContextProvider
|
||||
* collect(osPoint?: {x,y}) -> Promise<{
|
||||
* appName, windowTitle,
|
||||
* elementLabel?, elementRole?, elementClass?, elementValue?
|
||||
* }>
|
||||
* Best-effort foreground window / clicked-element context. Never throws;
|
||||
* returns {} (or partial) when unavailable.
|
||||
*
|
||||
* ClickSource (runtime capture — implemented incrementally per platform)
|
||||
* describe() -> { source, coordinates: boolean, keyboard: boolean }
|
||||
* source ∈ 'windows-hook' | 'x11' | 'evdev-x11' | 'evdev-wayland' |
|
||||
* 'wayland-portal' | 'hotkey' | 'interval' | 'unavailable'
|
||||
*
|
||||
* PowerPolicy
|
||||
* setRecording(recording: boolean) -> void
|
||||
* Holds/releases OS power + throttling state for the recording lifecycle.
|
||||
*
|
||||
* PlatformCapabilities (from index.detectCapabilities())
|
||||
* { os, sessionType, isWayland, hasXinput, canSandbox, ... }
|
||||
* ---------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
// Interface names, exported so adapters and tests can reference a single
|
||||
// source of truth for the contract identifiers.
|
||||
const INTERFACES = Object.freeze([
|
||||
'WindowContextProvider',
|
||||
'ClickSource',
|
||||
'PowerPolicy',
|
||||
]);
|
||||
|
||||
const CLICK_SOURCES = Object.freeze([
|
||||
'windows-hook',
|
||||
'x11',
|
||||
'evdev-x11',
|
||||
'evdev-wayland',
|
||||
'wayland-portal',
|
||||
'hotkey',
|
||||
'interval',
|
||||
'unavailable',
|
||||
]);
|
||||
|
||||
/** Assert a value looks like a WindowContextProvider (has async collect()). */
|
||||
function assertWindowContextProvider(provider) {
|
||||
if (!provider || typeof provider.collect !== 'function') {
|
||||
throw new Error('platform: WindowContextProvider must implement collect()');
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
module.exports = { INTERFACES, CLICK_SOURCES, assertWindowContextProvider };
|
||||
@@ -0,0 +1,156 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
/**
|
||||
* Linux capture-capability diagnostics. Detects the session type, portal /
|
||||
* PipeWire availability, xinput, readable input devices, and the sandbox
|
||||
* situation, and turns them into an actionable capability profile the UI can
|
||||
* show instead of console-only failures.
|
||||
*
|
||||
* Pure detection with injectable probes so it is unit-testable without a real
|
||||
* desktop session.
|
||||
*/
|
||||
|
||||
function defaultHasBinary(name) {
|
||||
try {
|
||||
execFileSync('which', [name], { stdio: 'pipe' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function detectSessionType(env = process.env) {
|
||||
const t = String(env.XDG_SESSION_TYPE || '').toLowerCase();
|
||||
if (t === 'wayland' || t === 'x11') return t;
|
||||
if (env.WAYLAND_DISPLAY) return 'wayland';
|
||||
if (env.DISPLAY) return 'x11';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function detectLinuxCapabilities({
|
||||
env = process.env,
|
||||
hasBinary = defaultHasBinary,
|
||||
existsSync = fs.existsSync,
|
||||
readdirSync = fs.readdirSync,
|
||||
} = {}) {
|
||||
const sessionType = detectSessionType(env);
|
||||
const isWayland = sessionType === 'wayland';
|
||||
|
||||
// XDG Desktop Portal + PipeWire are how Wayland screen capture works.
|
||||
const hasPortalBus = Boolean(env.DBUS_SESSION_BUS_ADDRESS);
|
||||
let hasPipeWire = false;
|
||||
try {
|
||||
hasPipeWire = hasBinary('pipewire') || existsSync(`/run/user/${process.getuid ? process.getuid() : ''}/pipewire-0`);
|
||||
} catch {
|
||||
hasPipeWire = hasBinary('pipewire');
|
||||
}
|
||||
|
||||
const hasXinput = hasBinary('xinput');
|
||||
const hasXprop = hasBinary('xprop');
|
||||
|
||||
// Readable /dev/input event nodes gate the evdev click fallback.
|
||||
let readableInputDevices = 0;
|
||||
try {
|
||||
for (const name of readdirSync('/dev/input')) {
|
||||
if (!/^event\d+$/.test(name)) continue;
|
||||
try { fs.accessSync(`/dev/input/${name}`, fs.constants.R_OK); readableInputDevices += 1; } catch { /* not readable */ }
|
||||
}
|
||||
} catch { /* /dev/input not present */ }
|
||||
|
||||
// Determine the click-capture profile for this session.
|
||||
let clickCapture;
|
||||
if (!isWayland && hasXinput) clickCapture = 'x11-xinput';
|
||||
else if (readableInputDevices > 0) clickCapture = isWayland ? 'evdev-wayland' : 'evdev-x11';
|
||||
else clickCapture = 'hotkey-or-interval-only';
|
||||
|
||||
const messages = [];
|
||||
if (isWayland && !hasPipeWire) {
|
||||
messages.push('Wayland screen capture needs PipeWire and the XDG Desktop Portal. Install pipewire and xdg-desktop-portal.');
|
||||
}
|
||||
if (isWayland && !hasPortalBus) {
|
||||
messages.push('No D-Bus session bus detected; the screen-share portal cannot be reached.');
|
||||
}
|
||||
if (!isWayland && !hasXinput) {
|
||||
messages.push('xinput not found: per-click capture with a marker is unavailable on X11 without it.');
|
||||
}
|
||||
if (clickCapture === 'hotkey-or-interval-only') {
|
||||
messages.push('No global click source available. Recording falls back to a hotkey or interval trigger.');
|
||||
}
|
||||
|
||||
return {
|
||||
os: 'linux',
|
||||
sessionType,
|
||||
isWayland,
|
||||
hasPortalBus,
|
||||
hasPipeWire,
|
||||
hasXinput,
|
||||
hasXprop,
|
||||
readableInputDevices,
|
||||
clickCapture,
|
||||
// Portal capture is the safe Wayland baseline; X11 can grab directly.
|
||||
screenCapture: isWayland ? 'wayland-portal' : 'x11-direct',
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the honest capture trigger for a Linux capability profile. StepForge
|
||||
* must never *promise* per-click capture with coordinates on Wayland, because
|
||||
* the platform does not expose pointer position to apps. Returns the trigger,
|
||||
* whether clicks carry coordinates, whether a marker can be drawn, and a
|
||||
* user-facing note. `userTriggerPreference` is the capture.fallbackTrigger
|
||||
* setting ('interval' | 'hotkey') used only when no click source exists.
|
||||
*/
|
||||
function chooseCaptureTrigger(capabilities, userTriggerPreference = 'interval') {
|
||||
const caps = capabilities || {};
|
||||
const click = caps.clickCapture;
|
||||
|
||||
if (click === 'x11-xinput') {
|
||||
return {
|
||||
trigger: 'click',
|
||||
clickSource: 'x11',
|
||||
coordinates: true,
|
||||
marker: true,
|
||||
note: 'Per-click capture with an accurate marker (X11 + xinput).',
|
||||
};
|
||||
}
|
||||
if (click === 'evdev-x11') {
|
||||
return {
|
||||
trigger: 'click',
|
||||
clickSource: 'evdev-x11',
|
||||
coordinates: true,
|
||||
marker: true,
|
||||
note: 'Per-click capture via kernel input devices (X11, no xinput).',
|
||||
};
|
||||
}
|
||||
if (click === 'evdev-wayland') {
|
||||
// Wayland exposes button presses (via evdev, if permitted) but NOT pointer
|
||||
// position, so a step is captured per click but without a marker. This is
|
||||
// only reached when the user opted into the least-privilege device rule.
|
||||
return {
|
||||
trigger: 'click',
|
||||
clickSource: 'evdev-wayland',
|
||||
coordinates: false,
|
||||
marker: false,
|
||||
note: 'Per-click capture on Wayland has no pointer position, so no marker is drawn.',
|
||||
};
|
||||
}
|
||||
|
||||
// No global click source: the safe baseline is the user's chosen fallback.
|
||||
const trigger = userTriggerPreference === 'hotkey' ? 'hotkey' : 'interval';
|
||||
return {
|
||||
trigger,
|
||||
clickSource: trigger,
|
||||
coordinates: false,
|
||||
marker: false,
|
||||
note: caps.isWayland
|
||||
? 'Wayland does not expose global clicks; recording uses your ' + trigger + ' trigger. '
|
||||
+ 'Screen sharing is requested once per recording via the portal.'
|
||||
: 'No global click source available; recording uses your ' + trigger + ' trigger.',
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { detectLinuxCapabilities, detectSessionType, chooseCaptureTrigger };
|
||||
@@ -0,0 +1,51 @@
|
||||
'use strict';
|
||||
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
function hasBinary(name) {
|
||||
try {
|
||||
execFileSync('which', [name], { stdio: 'pipe' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Linux (X11) WindowContextProvider using xprop on the active window. On
|
||||
* Wayland xprop only sees XWayland clients, so context is best-effort; the
|
||||
* portal-based capture path does not depend on it. Extracted verbatim from
|
||||
* text-intel.js. Never throws.
|
||||
*/
|
||||
function createLinuxWindowContextProvider() {
|
||||
return {
|
||||
async collect() {
|
||||
try {
|
||||
if (!hasBinary('xprop')) return { appName: '', windowTitle: '' };
|
||||
const active = execFileSync('xprop', ['-root', '_NET_ACTIVE_WINDOW'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 1200,
|
||||
});
|
||||
const activeMatch = active.match(/window id # (0x[0-9a-fA-F]+)/);
|
||||
if (!activeMatch) return { appName: '', windowTitle: '' };
|
||||
const winId = activeMatch[1];
|
||||
const details = execFileSync('xprop', ['-id', winId, '_NET_WM_NAME', 'WM_NAME', 'WM_CLASS'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 1200,
|
||||
});
|
||||
const titleMatch = details.match(/(?:_NET_WM_NAME\(UTF8_STRING\)|WM_NAME\(STRING\)|WM_NAME\(UTF8_STRING\)) = "([^"]*)"/);
|
||||
const classMatch = details.match(/WM_CLASS\(STRING\) = "([^"]*)"(?:, "([^"]*)")?/);
|
||||
return {
|
||||
appName: classMatch ? (classMatch[2] || classMatch[1] || '') : '',
|
||||
windowTitle: titleMatch ? titleMatch[1] : '',
|
||||
};
|
||||
} catch {
|
||||
return { appName: '', windowTitle: '' };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createLinuxWindowContextProvider, hasBinary };
|
||||
@@ -0,0 +1,88 @@
|
||||
'use strict';
|
||||
|
||||
const { execFile } = require('node:child_process');
|
||||
|
||||
/**
|
||||
* Windows WindowContextProvider. Reads the foreground window (Win32) and, when
|
||||
* a click point is given, the UI Automation element under it. Best-effort:
|
||||
* resolves {} on any failure. Extracted verbatim from text-intel.js so the
|
||||
* shared code carries no `process.platform` branch.
|
||||
*/
|
||||
function createWindowsWindowContextProvider() {
|
||||
return {
|
||||
async collect(osPoint = null) {
|
||||
const hasPoint = osPoint && Number.isFinite(osPoint.x) && Number.isFinite(osPoint.y);
|
||||
const clickX = hasPoint ? Number(osPoint.x) : 0;
|
||||
const clickY = hasPoint ? Number(osPoint.y) : 0;
|
||||
const script = `
|
||||
$clickX = ${clickX};
|
||||
$clickY = ${clickY};
|
||||
$elementLabel = '';
|
||||
$elementRole = '';
|
||||
$elementClass = '';
|
||||
$elementProcessId = 0;
|
||||
$elementValue = '';
|
||||
if (${hasPoint ? '$true' : '$false'}) {
|
||||
try {
|
||||
Add-Type -AssemblyName UIAutomationClient,UIAutomationTypes,WindowsBase | Out-Null
|
||||
$point = New-Object System.Windows.Point($clickX, $clickY);
|
||||
$element = [System.Windows.Automation.AutomationElement]::FromPoint($point);
|
||||
if ($element) {
|
||||
$current = $element.Current;
|
||||
$elementLabel = $current.Name;
|
||||
$elementRole = $current.LocalizedControlType;
|
||||
$elementClass = $current.ClassName;
|
||||
$elementProcessId = $current.ProcessId;
|
||||
try {
|
||||
$valPattern = [System.Windows.Automation.ValuePattern]::Pattern;
|
||||
if ($element.GetSupportedPatterns() -contains $valPattern) {
|
||||
$elementValue = $element.GetCurrentPattern($valPattern).Current.Value;
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
public static class Win32 {
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
|
||||
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
||||
}
|
||||
"@;
|
||||
$hWnd = [Win32]::GetForegroundWindow();
|
||||
$sb = New-Object System.Text.StringBuilder 512;
|
||||
[void][Win32]::GetWindowText($hWnd, $sb, $sb.Capacity);
|
||||
$pid = 0;
|
||||
[void][Win32]::GetWindowThreadProcessId($hWnd, [ref]$pid);
|
||||
$proc = Get-Process -Id $pid -ErrorAction SilentlyContinue | Select-Object -First 1;
|
||||
$out = [ordered]@{
|
||||
appName = if ($proc) { $proc.ProcessName } else { '' };
|
||||
windowTitle = $sb.ToString();
|
||||
elementLabel = $elementLabel;
|
||||
elementRole = $elementRole;
|
||||
elementClass = $elementClass;
|
||||
elementValue = $elementValue;
|
||||
elementProcessId = $elementProcessId;
|
||||
pid = $pid;
|
||||
};
|
||||
$out | ConvertTo-Json -Compress;
|
||||
`;
|
||||
return new Promise((resolve) => {
|
||||
execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
|
||||
encoding: 'utf8',
|
||||
timeout: 4000,
|
||||
windowsHide: true,
|
||||
}, (err, stdout) => {
|
||||
if (err) { resolve({}); return; }
|
||||
try { resolve(JSON.parse(stdout.trim() || '{}')); } catch { resolve({}); }
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createWindowsWindowContextProvider };
|
||||
@@ -56,6 +56,7 @@ const api = {
|
||||
test: invoke('ai:test'),
|
||||
fillStep: invoke('ai:fillStep'),
|
||||
rewriteText: invoke('ai:rewriteText'),
|
||||
cancel: invoke('ai:cancel'),
|
||||
},
|
||||
capture: {
|
||||
shoot: invoke('capture:shoot'),
|
||||
@@ -66,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'),
|
||||
@@ -76,6 +81,9 @@ const api = {
|
||||
create: invoke('snapshots:create'),
|
||||
restore: invoke('snapshots:restore'),
|
||||
},
|
||||
recovery: {
|
||||
status: invoke('recovery:status'),
|
||||
},
|
||||
templates: {
|
||||
list: invoke('templates:list'),
|
||||
load: invoke('templates:load'),
|
||||
@@ -95,11 +103,15 @@ const api = {
|
||||
cleanupPreviews: invoke('preview:cleanup'),
|
||||
},
|
||||
shell: {
|
||||
openPath: invoke('shell:openPath'),
|
||||
showItemInFolder: invoke('shell:showItemInFolder'),
|
||||
// Intent-specific shell access only: files the main process produced,
|
||||
// the guide's linked archive, and scheme-validated external links.
|
||||
openProduced: invoke('shell:openProduced'),
|
||||
revealLinkedArchive: invoke('shell:revealLinkedArchive'),
|
||||
openExternal: invoke('shell:openExternal'),
|
||||
},
|
||||
app: {
|
||||
info: invoke('app:info'),
|
||||
platformCapabilities: invoke('platform:capabilities'),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -122,6 +122,7 @@ class StepForgeApp {
|
||||
api.library.trashList(),
|
||||
]);
|
||||
this.state.info = info;
|
||||
document.body.classList.toggle('platform-linux', info.platform === 'linux');
|
||||
this.state.settings = settings;
|
||||
this.state.library = {
|
||||
guides: library.guides || [],
|
||||
@@ -171,7 +172,7 @@ class StepForgeApp {
|
||||
el('div.welcome', {},
|
||||
el('div.welcome-title', {},
|
||||
el('h1', {}, 'StepForge'),
|
||||
el('p.muted', {}, 'Capture, annotate, and export step-by-step guides, fully offline.'),
|
||||
el('p.muted', {}, 'Capture, annotate, and export step-by-step guides. Local-first, no telemetry.'),
|
||||
),
|
||||
el('div.welcome-actions', {},
|
||||
el('button.welcome-btn.primary', {
|
||||
@@ -263,15 +264,26 @@ class StepForgeApp {
|
||||
updateCaptureState(state) {
|
||||
this.captureState = state || { active: false };
|
||||
clearNode(this.captureStatus);
|
||||
// The capture bar only makes sense alongside the editor it's recording
|
||||
// into — hide it everywhere else (e.g. the library) even if a session
|
||||
// is still active in the background.
|
||||
if (!this.captureState.active || this.state.view !== 'editor') {
|
||||
// The capture bar is editor-only — hide it everywhere else (library, welcome).
|
||||
if (this.state.view !== 'editor') {
|
||||
this.captureStatus.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
this.captureStatus.classList.remove('hidden');
|
||||
const s = this.captureState;
|
||||
|
||||
// No active session: show a button to start a new one for the open guide.
|
||||
if (!s.active) {
|
||||
this.captureStatus.append(
|
||||
el('span', {}, 'Recording - stopped'),
|
||||
el('button', {
|
||||
type: 'button',
|
||||
onClick: () => this.armCaptureSession(this.editor.guideId),
|
||||
}, 'New recording'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const send = (payload) => api.capture.session(payload).then((next) => this.updateCaptureState(next));
|
||||
|
||||
// What is currently triggering captures, so the user knows what to do.
|
||||
@@ -389,7 +401,7 @@ class StepForgeApp {
|
||||
el('div', { style: { fontWeight: 650 } }, folderLabel),
|
||||
q ? el('div.muted', {}, `Search: ${q}`) : el('div.muted', {}, `${this.state.library.guides.length} guides`),
|
||||
),
|
||||
el('div.muted', {}, this.state.info ? `StepForge ${this.state.info.version}` : ''),
|
||||
el('div.muted', {}, this.state.info ? `StepForge ${this.state.info.buildVersion || this.state.info.version}` : ''),
|
||||
),
|
||||
this.domBulkBar = el('div', {}),
|
||||
this.domLibraryResults = el('div', {}),
|
||||
@@ -922,6 +934,24 @@ class StepForgeApp {
|
||||
|
||||
window.StepForgeApp = StepForgeApp;
|
||||
|
||||
// Links never navigate this window. http(s)/mailto links from guide content
|
||||
// open externally via the scheme-validated main-process handler; internal
|
||||
// step:/# links are handled by their own click handlers; everything else is
|
||||
// inert. The main process additionally denies all navigation, so this is the
|
||||
// user-experience half of a two-layer guarantee.
|
||||
document.addEventListener('click', (e) => {
|
||||
const anchor = e.target && e.target.closest ? e.target.closest('a[href]') : null;
|
||||
if (!anchor) return;
|
||||
const href = anchor.getAttribute('href') || '';
|
||||
if (/^(https?|mailto):/i.test(href)) {
|
||||
e.preventDefault();
|
||||
api.shell.openExternal({ url: href });
|
||||
return;
|
||||
}
|
||||
// Ensure a stray href can never navigate the privileged window.
|
||||
if (!href.startsWith('#')) e.preventDefault();
|
||||
}, true);
|
||||
|
||||
function boot() {
|
||||
const app = new StepForgeApp();
|
||||
app.init();
|
||||
|
||||
@@ -68,24 +68,39 @@
|
||||
};
|
||||
streams.set(key, state);
|
||||
try {
|
||||
// The chromeMediaSource constraint set is Electron's documented bridge
|
||||
// from a desktopCapturer source id to a live media stream.
|
||||
state.media = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: {
|
||||
mandatory: {
|
||||
chromeMediaSource: 'desktop',
|
||||
chromeMediaSourceId: cmd.sourceId,
|
||||
minWidth: physWidth,
|
||||
maxWidth: physWidth,
|
||||
minHeight: physHeight,
|
||||
maxHeight: physHeight,
|
||||
// No maxFrameRate: sampling cadence is controlled by the setInterval
|
||||
// timer below, so the actual capture rate is always sampleMs-driven
|
||||
// regardless of display refresh rate or power mode.
|
||||
if (cmd.useDisplayMedia) {
|
||||
// GNOME Wayland path: desktopCapturer source ids fail with
|
||||
// getUserMedia, so go through the portal-backed getDisplayMedia. The
|
||||
// main process installs a setDisplayMediaRequestHandler that answers
|
||||
// this request; the OS portal picker chooses the screen. The stream
|
||||
// then stays open for the whole session — one prompt, not one per shot.
|
||||
state.media = await navigator.mediaDevices.getDisplayMedia({
|
||||
audio: false,
|
||||
video: true,
|
||||
});
|
||||
} else {
|
||||
// Keep the legacy desktop-capture constraint wrapper here: it binds
|
||||
// the stream to the exact desktop source chosen in the main process.
|
||||
// Without it Chromium can treat the request like a normal media
|
||||
// request and pick the default camera device instead.
|
||||
state.media = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: {
|
||||
mandatory: {
|
||||
chromeMediaSource: 'desktop',
|
||||
chromeMediaSourceId: cmd.sourceId,
|
||||
minWidth: physWidth,
|
||||
maxWidth: physWidth,
|
||||
minHeight: physHeight,
|
||||
maxHeight: physHeight,
|
||||
// No maxFrameRate: sampling cadence is controlled by the
|
||||
// setInterval timer below, so the actual capture rate is always
|
||||
// sampleMs-driven regardless of display refresh rate or power
|
||||
// mode.
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
const video = document.createElement('video');
|
||||
video.muted = true;
|
||||
video.srcObject = state.media;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -312,6 +327,11 @@ function showSettingsDialog({
|
||||
const previewCount = makeInput(settings.exports?.previewStepCount ?? 3, 'number', { min: 1, step: 1 });
|
||||
const openFolder = el('input', { type: 'checkbox', checked: Boolean(settings.exports?.openFolderAfterExport) });
|
||||
const captureOutside = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.captureOutsideClicks) });
|
||||
const fallbackTrigger = makeSelect(settings.capture?.fallbackTrigger || 'interval', [
|
||||
{ value: 'interval', label: 'Timed interval' },
|
||||
{ value: 'hotkey', label: 'Hotkey only' },
|
||||
]);
|
||||
const autoIntervalSec = makeInput(settings.capture?.autoIntervalSec ?? 5, 'number', { min: 1, step: 1 });
|
||||
const confirmSimple = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.confirmSimpleCapture) });
|
||||
const keepLast = makeInput(settings.backups?.keepLast ?? 10, 'number', { min: 0, step: 1 });
|
||||
const aiEnabled = el('input', { type: 'checkbox', checked: Boolean(settings.ai?.enabled) });
|
||||
@@ -326,6 +346,12 @@ function showSettingsDialog({
|
||||
void api.settings.set({ keyPath: 'ai.ollama.model', value: model }).catch(() => {});
|
||||
}, 250);
|
||||
|
||||
const syncFallbackUi = () => {
|
||||
autoIntervalSec.disabled = fallbackTrigger.value === 'hotkey';
|
||||
};
|
||||
fallbackTrigger.addEventListener('change', syncFallbackUi);
|
||||
syncFallbackUi();
|
||||
|
||||
const updateAiStatus = (message, { error = false } = {}) => {
|
||||
aiStatus.textContent = message;
|
||||
aiStatus.classList.toggle('error', Boolean(error));
|
||||
@@ -403,9 +429,14 @@ function showSettingsDialog({
|
||||
labeledRow('Delay (ms)', delayMs),
|
||||
labeledRow('Click marker', clickMarker),
|
||||
labeledRow('Capture outside clicks', captureOutside),
|
||||
labeledRow('When clicks are unavailable', fallbackTrigger),
|
||||
labeledRow('Timer interval (seconds)', autoIntervalSec),
|
||||
labeledRow('Confirm simple capture', confirmSimple),
|
||||
labeledRow('Capture hotkey', captureHotkey),
|
||||
labeledRow('Pause / resume hotkey', pauseHotkey),
|
||||
el('div.muted', {},
|
||||
'Hotkey fallback uses the Capture hotkey. Timer fallback uses the interval above.',
|
||||
),
|
||||
),
|
||||
el('fieldset', {},
|
||||
el('legend', {}, 'Editor'),
|
||||
@@ -455,6 +486,8 @@ function showSettingsDialog({
|
||||
delayMs: Number(delayMs.value || 0),
|
||||
mode: captureMode.value,
|
||||
clickMarker: clickMarker.checked,
|
||||
fallbackTrigger: fallbackTrigger.value === 'hotkey' ? 'hotkey' : 'interval',
|
||||
autoIntervalSec: Math.max(1, Number(autoIntervalSec.value || 5)),
|
||||
hotkeyCapture: captureHotkey.value.trim(),
|
||||
hotkeyPauseResume: pauseHotkey.value.trim(),
|
||||
captureOutsideClicks: captureOutside.checked,
|
||||
|
||||
@@ -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;
|
||||
@@ -124,6 +143,7 @@ class GuideEditor {
|
||||
this.currentZoom = 'fit';
|
||||
this.pendingSave = false;
|
||||
this.pendingGuideSave = false;
|
||||
this.saveError = null;
|
||||
this.canvasHistory = [];
|
||||
this.canvasFuture = [];
|
||||
this.beforeCanvasSnapshot = null;
|
||||
@@ -140,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);
|
||||
}
|
||||
@@ -151,6 +178,19 @@ 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),
|
||||
// and cancel any in-flight AI request for this guide so a slow response
|
||||
// can't resolve against a guide the user has closed.
|
||||
if (this.pendingSave || this.pendingGuideSave) {
|
||||
this.saveAll().catch(() => {});
|
||||
}
|
||||
api.ai.cancel({ guideId: this.guideId }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
setSettings(settings) {
|
||||
@@ -314,6 +354,7 @@ class GuideEditor {
|
||||
selectedAnnotationId: this.selectedAnnotationId,
|
||||
linked: Boolean(this.guide && this.guide.linkedSource),
|
||||
dirty: this.pendingSave || this.pendingGuideSave || this.descriptionDirty || this.titleDirty,
|
||||
saveError: this.saveError || null,
|
||||
view: 'editor',
|
||||
};
|
||||
}
|
||||
@@ -1170,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' });
|
||||
@@ -1206,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));
|
||||
@@ -1336,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'
|
||||
@@ -1419,8 +1483,22 @@ class GuideEditor {
|
||||
|
||||
async flushStep(step = this.currentStep) {
|
||||
if (!step) return;
|
||||
// Clear the dirty flag only AFTER a durable save. Clearing it first meant
|
||||
// a rejected IPC save silently lost the unsaved state (and this runs from
|
||||
// a debounce that does not handle rejections). Keep it dirty on failure,
|
||||
// surface it, and retry on the next edit or explicit save.
|
||||
let saved;
|
||||
try {
|
||||
saved = await api.step.save({ guideId: this.guideId, step });
|
||||
} catch (err) {
|
||||
this.pendingSave = true;
|
||||
this.saveError = (err && err.message) || 'Save failed';
|
||||
this.emitMeta();
|
||||
this.onToast('Could not save this step — your changes are kept. Retrying…', { error: true });
|
||||
return null;
|
||||
}
|
||||
this.pendingSave = false;
|
||||
const saved = await api.step.save({ guideId: this.guideId, step });
|
||||
this.saveError = null;
|
||||
const committed = this.commitSavedStep(saved);
|
||||
if (this.selectedStepId === committed.stepId) {
|
||||
this.renderStepList();
|
||||
@@ -1465,8 +1543,17 @@ class GuideEditor {
|
||||
|
||||
async flushGuide() {
|
||||
if (!this.guide) return;
|
||||
try {
|
||||
await api.guide.save({ guide: this.guide });
|
||||
} catch (err) {
|
||||
this.pendingGuideSave = true;
|
||||
this.saveError = (err && err.message) || 'Save failed';
|
||||
this.emitMeta();
|
||||
this.onToast('Could not save guide details — your changes are kept. Retrying…', { error: true });
|
||||
return;
|
||||
}
|
||||
this.pendingGuideSave = false;
|
||||
await api.guide.save({ guide: this.guide });
|
||||
this.saveError = null;
|
||||
this.emitMeta();
|
||||
}
|
||||
|
||||
@@ -1899,7 +1986,7 @@ class GuideEditor {
|
||||
onPreview: async ({ format, options }) => {
|
||||
const preview = await api.export.preview({ guideId: this.guideId, format, options });
|
||||
if (preview && preview.file) {
|
||||
await api.shell.openPath({ target: preview.file }); // open in default viewer
|
||||
await api.shell.openProduced({ target: preview.file }); // open in default viewer
|
||||
this.onToast('Preview opened (first steps only).');
|
||||
}
|
||||
return true;
|
||||
@@ -1935,7 +2022,7 @@ class GuideEditor {
|
||||
else this.onToast('Could not save linked archive.', { error: true });
|
||||
},
|
||||
onOpenArchive: async () => {
|
||||
await api.shell.showItemInFolder({ target: this.guide.linkedSource.path });
|
||||
await api.shell.revealLinkedArchive({ guideId: this.guideId });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2133,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) {
|
||||
@@ -2192,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();
|
||||
@@ -2235,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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -49,6 +49,16 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.platform-linux button {
|
||||
padding: 5px 11px;
|
||||
}
|
||||
|
||||
body.platform-linux input,
|
||||
body.platform-linux select,
|
||||
body.platform-linux textarea {
|
||||
padding: 6px 9px;
|
||||
}
|
||||
|
||||
*::selection { background: rgba(0, 104, 255, 0.2); }
|
||||
|
||||
#app { display: flex; flex-direction: column; height: 100vh; }
|
||||
@@ -183,6 +193,21 @@ kbd {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
body.platform-linux #topbar {
|
||||
height: 52px;
|
||||
gap: 10px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
body.platform-linux #capture-status {
|
||||
padding: 5px 9px;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
body.platform-linux #capture-status button {
|
||||
padding: 2px 7px;
|
||||
}
|
||||
|
||||
.library, .editor { flex: 1; min-height: 0; display: flex; }
|
||||
|
||||
.lib-side {
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Privilege-boundary policy for every renderer surface.
|
||||
*
|
||||
* This module is deliberately loadable under plain Node (no electron
|
||||
* require at module scope) so the decision logic is unit-testable:
|
||||
* - which URLs count as our own app pages,
|
||||
* - whether a navigation/popup may proceed (it may not),
|
||||
* - which permission a renderer may be granted (display capture for the
|
||||
* dedicated capture worker only),
|
||||
* - whether an IPC event comes from the trusted main-window frame,
|
||||
* - which external URLs may be handed to shell.openExternal,
|
||||
* - which filesystem paths the renderer may ask the shell to open
|
||||
* (only files the main process itself produced).
|
||||
*/
|
||||
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
|
||||
const RENDERER_DIR = path.join(__dirname, 'renderer');
|
||||
|
||||
const APP_PAGES = {
|
||||
main: pathToFileURL(path.join(RENDERER_DIR, 'index.html')).href,
|
||||
region: pathToFileURL(path.join(RENDERER_DIR, 'region.html')).href,
|
||||
captureWorker: pathToFileURL(path.join(RENDERER_DIR, 'capture-worker.html')).href,
|
||||
};
|
||||
|
||||
/** Normalize a URL for identity comparison: drop query/hash, decode path. */
|
||||
function normalizeAppUrl(rawUrl) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(String(rawUrl));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== 'file:') return null;
|
||||
let pathname;
|
||||
try {
|
||||
pathname = decodeURIComponent(url.pathname);
|
||||
} catch {
|
||||
pathname = url.pathname;
|
||||
}
|
||||
// Windows drive letters may differ in case between loadFile and senderFrame.
|
||||
if (process.platform === 'win32') pathname = pathname.toLowerCase();
|
||||
return pathname;
|
||||
}
|
||||
|
||||
function isAppPageUrl(rawUrl, page) {
|
||||
const expected = APP_PAGES[page];
|
||||
if (!expected) return false;
|
||||
const a = normalizeAppUrl(rawUrl);
|
||||
const b = normalizeAppUrl(expected);
|
||||
return a !== null && a === b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation policy: a privileged window may only ever stay on the exact
|
||||
* page it was created with. Everything else — remote URLs, other local
|
||||
* files, javascript:, data: — is denied.
|
||||
*/
|
||||
function navigationAllowed(targetUrl, page) {
|
||||
return isAppPageUrl(targetUrl, page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission policy for Electron sessions. Display capture (and the media
|
||||
* permission that getDisplayMedia consults) is granted only to the dedicated
|
||||
* hidden capture-worker page. Everything else is denied for everyone,
|
||||
* including our own main window.
|
||||
*/
|
||||
function permissionAllowed(permission, requestingUrl) {
|
||||
if (permission === 'media' || permission === 'display-capture') {
|
||||
return isAppPageUrl(requestingUrl, 'captureWorker');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* External-link policy: only well-formed http(s) and mailto URLs may reach
|
||||
* shell.openExternal. Returns the normalized URL string or null.
|
||||
*/
|
||||
function validateExternalUrl(rawUrl) {
|
||||
if (typeof rawUrl !== 'string' || rawUrl.length > 2048) return null;
|
||||
let url;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol === 'https:' || url.protocol === 'http:') {
|
||||
if (!url.hostname) return null;
|
||||
return url.href;
|
||||
}
|
||||
if (url.protocol === 'mailto:') return url.href;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* IPC sender guard: an invoke event is trusted only when it originates from
|
||||
* the current main window's top frame, and that frame is our index.html.
|
||||
* Destroyed frames, subframes, other windows, and navigated-away frames are
|
||||
* all rejected.
|
||||
*/
|
||||
function makeIpcSenderGuard({ getMainWebContents }) {
|
||||
return function trustedSender(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
const expected = getMainWebContents();
|
||||
if (!expected || event.sender !== expected) return false;
|
||||
const frame = event.senderFrame;
|
||||
if (!frame) return false;
|
||||
try {
|
||||
if (frame.parent) return false; // top frame only
|
||||
return isAppPageUrl(frame.url, 'main');
|
||||
} catch {
|
||||
// Accessing a disposed WebFrameMain throws — reject.
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap recursive payload budget: sums string lengths (and key counts) with
|
||||
* early exit. Protects handlers from absurd payloads without JSON.stringify
|
||||
* on hundred-megabyte image saves.
|
||||
*/
|
||||
function payloadWithinBudget(value, maxChars, depth = 0) {
|
||||
let budget = maxChars;
|
||||
|
||||
const walk = (v, d) => {
|
||||
if (budget < 0 || d > 16) return false;
|
||||
if (v == null) return true;
|
||||
const t = typeof v;
|
||||
if (t === 'string') {
|
||||
budget -= v.length;
|
||||
return budget >= 0;
|
||||
}
|
||||
if (t === 'number' || t === 'boolean') {
|
||||
budget -= 8;
|
||||
return budget >= 0;
|
||||
}
|
||||
if (t === 'function' || t === 'symbol' || t === 'bigint') return false;
|
||||
if (Array.isArray(v)) {
|
||||
if (v.length > 100000) return false;
|
||||
for (const item of v) if (!walk(item, d + 1)) return false;
|
||||
return true;
|
||||
}
|
||||
if (t === 'object') {
|
||||
const keys = Object.keys(v);
|
||||
if (keys.length > 4096) return false;
|
||||
for (const key of keys) {
|
||||
budget -= key.length;
|
||||
if (budget < 0) return false;
|
||||
if (!walk(v[key], d + 1)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return walk(value, depth);
|
||||
}
|
||||
|
||||
/** True for plain-object argument bags (what every IPC channel expects). */
|
||||
function isPlainArgs(args) {
|
||||
if (args === undefined || args === null) return true;
|
||||
if (typeof args !== 'object' || Array.isArray(args)) return false;
|
||||
const proto = Object.getPrototypeOf(args);
|
||||
return proto === Object.prototype || proto === null;
|
||||
}
|
||||
|
||||
// ---- field validators (used by main.js per-channel checks) ----------------
|
||||
|
||||
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
|
||||
const check = {
|
||||
/** Guide/step/folder/template identifiers and snapshot/trash entry names:
|
||||
* single path segment, no separators, no dot-dot, sane length. */
|
||||
id(v) {
|
||||
return typeof v === 'string' && ID_RE.test(v) && !v.includes('..');
|
||||
},
|
||||
optionalId(v) {
|
||||
return v === null || v === undefined || check.id(v);
|
||||
},
|
||||
string(v, max = 4096) {
|
||||
return typeof v === 'string' && v.length <= max;
|
||||
},
|
||||
optionalString(v, max = 4096) {
|
||||
return v === null || v === undefined || check.string(v, max);
|
||||
},
|
||||
bool(v) {
|
||||
return typeof v === 'boolean';
|
||||
},
|
||||
number(v, min = -1e15, max = 1e15) {
|
||||
return typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max;
|
||||
},
|
||||
optionalNumber(v, min, max) {
|
||||
return v === null || v === undefined || check.number(v, min, max);
|
||||
},
|
||||
oneOf(v, values) {
|
||||
return values.includes(v);
|
||||
},
|
||||
base64(v, maxChars = 192 * 1024 * 1024) {
|
||||
return typeof v === 'string' && v.length <= maxChars && /^[A-Za-z0-9+/=\r\n]*$/.test(v.slice(0, 4096));
|
||||
},
|
||||
optionalBase64(v, maxChars) {
|
||||
return v === null || v === undefined || check.base64(v, maxChars);
|
||||
},
|
||||
/** Single filesystem name (template/snapshot/trash entries): no path
|
||||
* separators, no traversal, no NUL, printable, bounded length. */
|
||||
fileName(v, max = 160) {
|
||||
return (
|
||||
typeof v === 'string' &&
|
||||
v.trim().length > 0 &&
|
||||
v.length <= max &&
|
||||
!/[/\\\0]/.test(v) &&
|
||||
!v.includes('..') &&
|
||||
v !== '.' &&
|
||||
// eslint-disable-next-line no-control-regex
|
||||
!/[\x00-\x1f]/.test(v)
|
||||
);
|
||||
},
|
||||
optionalFileName(v, max) {
|
||||
return v === null || v === undefined || check.fileName(v, max);
|
||||
},
|
||||
/** settings keyPath: dotted segments, no prototype-pollution segments. */
|
||||
settingsKeyPath(v) {
|
||||
if (typeof v !== 'string' || v.length === 0 || v.length > 200) return false;
|
||||
const segments = v.split('.');
|
||||
return segments.every(
|
||||
(segment) =>
|
||||
/^[A-Za-z0-9_-]+$/.test(segment) &&
|
||||
!['__proto__', 'constructor', 'prototype'].includes(segment)
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Registry of files the main process itself produced (export outputs,
|
||||
* previews) and may therefore re-open on renderer request. Bounded LRU.
|
||||
*/
|
||||
class ProducedFiles {
|
||||
constructor(limit = 256) {
|
||||
this.limit = limit;
|
||||
this.paths = new Set();
|
||||
}
|
||||
|
||||
key(p) {
|
||||
const resolved = path.resolve(String(p));
|
||||
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
||||
}
|
||||
|
||||
add(p) {
|
||||
if (!p || typeof p !== 'string') return;
|
||||
const key = this.key(p);
|
||||
this.paths.delete(key);
|
||||
this.paths.add(key);
|
||||
while (this.paths.size > this.limit) {
|
||||
this.paths.delete(this.paths.values().next().value);
|
||||
}
|
||||
}
|
||||
|
||||
has(p) {
|
||||
if (!p || typeof p !== 'string') return false;
|
||||
return this.paths.has(this.key(p));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the navigation/popup policy to a BrowserWindow. `page` names the
|
||||
* app page this window is allowed to display (key of APP_PAGES).
|
||||
*/
|
||||
function installWindowSecurity(win, page) {
|
||||
const contents = win.webContents;
|
||||
contents.setWindowOpenHandler(() => ({ action: 'deny' }));
|
||||
contents.on('will-navigate', (event, url) => {
|
||||
if (!navigationAllowed(url, page)) event.preventDefault();
|
||||
});
|
||||
contents.on('will-frame-navigate', (details) => {
|
||||
if (!navigationAllowed(details.url, page) && typeof details.preventDefault === 'function') {
|
||||
details.preventDefault();
|
||||
}
|
||||
});
|
||||
// Defense in depth: if a disallowed document somehow starts loading,
|
||||
// never let it attach webviews either.
|
||||
contents.on('will-attach-webview', (event) => event.preventDefault());
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
APP_PAGES,
|
||||
ProducedFiles,
|
||||
check,
|
||||
installWindowSecurity,
|
||||
isAppPageUrl,
|
||||
isPlainArgs,
|
||||
makeIpcSenderGuard,
|
||||
navigationAllowed,
|
||||
normalizeAppUrl,
|
||||
payloadWithinBudget,
|
||||
permissionAllowed,
|
||||
validateExternalUrl,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -83,9 +83,19 @@ class StreamCaptureBackend {
|
||||
* Spin up the worker and one stream per display that has a matching screen
|
||||
* source. Resolves true when at least one stream is delivering frames.
|
||||
*/
|
||||
async start({ displays = [], sources = [], sampleMs = DEFAULT_SAMPLE_MS, retentionMs = null, frameLimit = null } = {}) {
|
||||
async start({
|
||||
displays = [], sources = [], sampleMs = DEFAULT_SAMPLE_MS,
|
||||
retentionMs = null, frameLimit = null, useDisplayMedia = false,
|
||||
} = {}) {
|
||||
if (this.host) return this.active;
|
||||
const pairs = pairDisplaysToSources(displays, sources);
|
||||
// On GNOME Wayland, desktopCapturer source ids can't be reopened with
|
||||
// getUserMedia ("Requested device not found") — the portal-backed
|
||||
// getDisplayMedia path is the only one that works. There's no per-display
|
||||
// source id in that mode, so capture a single stream against the primary
|
||||
// display (the portal picker decides which screen is actually shared).
|
||||
const pairs = useDisplayMedia
|
||||
? (displays.length ? [{ display: displays[0], sourceId: null }] : [])
|
||||
: pairDisplaysToSources(displays, sources);
|
||||
if (!pairs.length) return false;
|
||||
try {
|
||||
this.host = await this.createHost((msg) => this.handleWorkerEvent(msg));
|
||||
@@ -99,6 +109,7 @@ class StreamCaptureBackend {
|
||||
type: 'start-stream',
|
||||
displayId: display.id,
|
||||
sourceId,
|
||||
useDisplayMedia,
|
||||
// The worker needs the physical pixel size to request a full-res
|
||||
// stream; bounds stay in DIP for marker math back in the main process.
|
||||
display: {
|
||||
@@ -154,6 +165,9 @@ class StreamCaptureBackend {
|
||||
stream.ready = msg.type === 'stream-ready';
|
||||
stream.failed = msg.type === 'stream-error';
|
||||
}
|
||||
if (msg.type === 'stream-error') {
|
||||
console.error(`[stepforge] capture worker stream-error display=${msg.displayId}: ${msg.reason}`);
|
||||
}
|
||||
for (const check of [...this.startWaiters]) check();
|
||||
return;
|
||||
}
|
||||
@@ -167,7 +181,7 @@ class StreamCaptureBackend {
|
||||
clearTimeout(pending.timer);
|
||||
pending.timer = setTimeout(() => {
|
||||
this.settleRequest(msg.requestId, null);
|
||||
this.noteFailure();
|
||||
if (pending.failable !== false) this.noteFailure();
|
||||
}, this.encodeTimeoutMs);
|
||||
return;
|
||||
}
|
||||
@@ -210,7 +224,7 @@ class StreamCaptureBackend {
|
||||
* Resolves null when no frame qualifies (caller falls back) — and also on
|
||||
* timeout, which additionally counts toward unhealthiness.
|
||||
*/
|
||||
frameForClick({ clickPos = null, clickAt = Date.now(), strict = true, leadMs = 0 } = {}) {
|
||||
frameForClick({ clickPos = null, clickAt = Date.now(), strict = true, leadMs = 0, failable = true } = {}) {
|
||||
if (!this.active || !this.host) return Promise.resolve(null);
|
||||
const displays = [...this.streams.values()].filter((s) => s.ready).map((s) => s.display);
|
||||
const display = clickPos ? displayForDipPoint(clickPos, displays) : (displays[0] || null);
|
||||
@@ -222,10 +236,15 @@ class StreamCaptureBackend {
|
||||
if (clickPos && !pointInBounds(clickPos, display.bounds)) return Promise.resolve(null);
|
||||
const requestId = this.nextRequestId++;
|
||||
return new Promise((resolve) => {
|
||||
const pending = { resolve, display, timer: null };
|
||||
// failable=false: a timeout resolves null but does NOT count toward
|
||||
// unhealthiness. Interval/timed captures use this so a single slow PNG
|
||||
// encode (common on software-rendered hosts with no GPU) can't trip the
|
||||
// 2-strikes rule and tear down an otherwise-healthy stream — the bug
|
||||
// that left Wayland recordings "stuck after two captures".
|
||||
const pending = { resolve, display, timer: null, failable };
|
||||
pending.timer = setTimeout(() => {
|
||||
this.settleRequest(requestId, null);
|
||||
this.noteFailure();
|
||||
if (failable) this.noteFailure();
|
||||
}, this.ackTimeoutMs);
|
||||
this.requests.set(requestId, pending);
|
||||
this.hostSend({
|
||||
@@ -324,11 +343,14 @@ async function createElectronHost(onEvent) {
|
||||
preload: path.join(__dirname, 'capture-worker-preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
// The worker must keep sampling while hidden — throttling a hidden
|
||||
// window is exactly the wrong default for a frame recorder.
|
||||
backgroundThrottling: false,
|
||||
},
|
||||
});
|
||||
// The worker may only display capture-worker.html; deny navigation/popups.
|
||||
require('./security').installWindowSecurity(win, 'captureWorker');
|
||||
const listener = (event, msg) => {
|
||||
if (event.sender === win.webContents) onEvent(msg);
|
||||
};
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync, execFile } = require('node:child_process');
|
||||
|
||||
const {
|
||||
DEFAULT_CAPTURE_TITLES,
|
||||
buildCaptureTitle,
|
||||
normalizeOllamaHost,
|
||||
validateOllamaHost,
|
||||
normalizeAiPatch,
|
||||
buildAiPrompt,
|
||||
applyAiPatchToStep,
|
||||
@@ -22,15 +22,6 @@ const OCR_CROP = {
|
||||
height: 220,
|
||||
};
|
||||
|
||||
function hasBinary(name) {
|
||||
try {
|
||||
execFileSync('which', [name], { stdio: 'pipe' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(v, min, max) {
|
||||
return Math.min(max, Math.max(min, v));
|
||||
}
|
||||
@@ -62,6 +53,7 @@ class TextIntelService {
|
||||
dataDir,
|
||||
fetchImpl = global.fetch,
|
||||
screenApi = null,
|
||||
windowContextProvider = null,
|
||||
}) {
|
||||
this.store = store;
|
||||
this.settings = settings;
|
||||
@@ -69,14 +61,110 @@ class TextIntelService {
|
||||
this.dataDir = dataDir;
|
||||
this.fetch = fetchImpl;
|
||||
this.screen = screenApi;
|
||||
// OS-specific foreground-window/element detection is a platform adapter.
|
||||
// This code no longer branches on process.platform; the factory selects it.
|
||||
this.windowContext = windowContextProvider
|
||||
|| require('./platform').createWindowContextProvider();
|
||||
this.worker = null;
|
||||
this.workerPromise = null;
|
||||
this.workerQueue = Promise.resolve();
|
||||
this.ocrDataDir = path.join(dataDir, 'ocr', 'eng');
|
||||
this.modelCapabilityCache = new Map();
|
||||
// In-flight AI request controllers, grouped by guide, so closing a guide
|
||||
// (or quitting) can cancel outstanding requests instead of leaving them
|
||||
// to resolve against stale data.
|
||||
this.inflight = new Set();
|
||||
// Bounded concurrency for AI network calls.
|
||||
this.maxConcurrent = 2;
|
||||
this.activeCount = 0;
|
||||
this.waitQueue = [];
|
||||
}
|
||||
|
||||
aiNetworkOptions() {
|
||||
const ai = this.settings.get('ai') || {};
|
||||
const timeoutMs = Number.isFinite(ai.timeoutMs) && ai.timeoutMs > 0 ? ai.timeoutMs : 60000;
|
||||
const maxImageBytes = Number.isFinite(ai.maxImageBytes) && ai.maxImageBytes > 0
|
||||
? ai.maxImageBytes
|
||||
: 12 * 1024 * 1024;
|
||||
return {
|
||||
allowRemote: Boolean(ai.allowRemoteHost),
|
||||
attachScreenshots: ai.attachScreenshots !== false,
|
||||
timeoutMs,
|
||||
maxImageBytes,
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve the endpoint against the local-first policy or throw a clear error.
|
||||
resolveHost(host) {
|
||||
const { allowRemote } = this.aiNetworkOptions();
|
||||
const result = validateOllamaHost(host, { allowRemote });
|
||||
if (!result.ok) {
|
||||
const err = new Error(result.reason);
|
||||
err.code = 'STEPFORGE_AI_HOST_BLOCKED';
|
||||
throw err;
|
||||
}
|
||||
return result.host;
|
||||
}
|
||||
|
||||
// fetch with a hard deadline and cooperative cancellation. Every AI network
|
||||
// call goes through here so a dead endpoint can never hang the UI.
|
||||
async fetchJson(url, { method = 'GET', body = null, guideId = null } = {}) {
|
||||
const { timeoutMs } = this.aiNetworkOptions();
|
||||
const controller = new AbortController();
|
||||
if (guideId) controller._guideId = guideId;
|
||||
this.inflight.add(controller);
|
||||
const timer = setTimeout(() => controller.abort(new Error('timeout')), timeoutMs);
|
||||
try {
|
||||
const res = await this.fetch(url, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
return res;
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) {
|
||||
// The abort reason distinguishes an explicit cancel from a timeout.
|
||||
const reasonMsg = controller.signal.reason && controller.signal.reason.message;
|
||||
const cancelled = reasonMsg === 'cancelled';
|
||||
const wrapped = new Error(cancelled ? 'AI request cancelled.' : 'AI request timed out.');
|
||||
wrapped.code = 'STEPFORGE_AI_ABORTED';
|
||||
throw wrapped;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
this.inflight.delete(controller);
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel in-flight AI requests, optionally scoped to one guide.
|
||||
cancelInflight(guideId = null) {
|
||||
for (const controller of [...this.inflight]) {
|
||||
if (!guideId || controller._guideId === guideId) {
|
||||
try { controller.abort(new Error('cancelled')); } catch { /* already settled */ }
|
||||
this.inflight.delete(controller);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bounded-concurrency gate for AI network work.
|
||||
async withConcurrency(fn) {
|
||||
if (this.activeCount >= this.maxConcurrent) {
|
||||
await new Promise((resolve) => this.waitQueue.push(resolve));
|
||||
}
|
||||
this.activeCount += 1;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.activeCount -= 1;
|
||||
const next = this.waitQueue.shift();
|
||||
if (next) next();
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
this.cancelInflight();
|
||||
if (this.worker) {
|
||||
try {
|
||||
await this.worker.terminate();
|
||||
@@ -178,135 +266,13 @@ class TextIntelService {
|
||||
|
||||
async collectForegroundWindowContext(osPoint = null) {
|
||||
try {
|
||||
if (process.platform === 'win32') return this.collectWindowsWindowContext(osPoint);
|
||||
if (process.platform === 'darwin') return this.collectMacWindowContext();
|
||||
if (process.platform === 'linux') return this.collectLinuxWindowContext();
|
||||
return await this.windowContext.collect(osPoint);
|
||||
} catch {
|
||||
// best effort only
|
||||
return { appName: '', windowTitle: '' };
|
||||
}
|
||||
return { appName: '', windowTitle: '' };
|
||||
}
|
||||
|
||||
async collectWindowsWindowContext(osPoint = null) {
|
||||
const hasPoint = osPoint && Number.isFinite(osPoint.x) && Number.isFinite(osPoint.y);
|
||||
const clickX = hasPoint ? Number(osPoint.x) : 0;
|
||||
const clickY = hasPoint ? Number(osPoint.y) : 0;
|
||||
const script = `
|
||||
$clickX = ${clickX};
|
||||
$clickY = ${clickY};
|
||||
$elementLabel = '';
|
||||
$elementRole = '';
|
||||
$elementClass = '';
|
||||
$elementProcessId = 0;
|
||||
$elementValue = '';
|
||||
if (${hasPoint ? '$true' : '$false'}) {
|
||||
try {
|
||||
Add-Type -AssemblyName UIAutomationClient,UIAutomationTypes,WindowsBase | Out-Null
|
||||
$point = New-Object System.Windows.Point($clickX, $clickY);
|
||||
$element = [System.Windows.Automation.AutomationElement]::FromPoint($point);
|
||||
if ($element) {
|
||||
$current = $element.Current;
|
||||
$elementLabel = $current.Name;
|
||||
$elementRole = $current.LocalizedControlType;
|
||||
$elementClass = $current.ClassName;
|
||||
$elementProcessId = $current.ProcessId;
|
||||
try {
|
||||
$valPattern = [System.Windows.Automation.ValuePattern]::Pattern;
|
||||
if ($element.GetSupportedPatterns() -contains $valPattern) {
|
||||
$elementValue = $element.GetCurrentPattern($valPattern).Current.Value;
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
public static class Win32 {
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
|
||||
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
||||
}
|
||||
"@;
|
||||
$hWnd = [Win32]::GetForegroundWindow();
|
||||
$sb = New-Object System.Text.StringBuilder 512;
|
||||
[void][Win32]::GetWindowText($hWnd, $sb, $sb.Capacity);
|
||||
$pid = 0;
|
||||
[void][Win32]::GetWindowThreadProcessId($hWnd, [ref]$pid);
|
||||
$proc = Get-Process -Id $pid -ErrorAction SilentlyContinue | Select-Object -First 1;
|
||||
$out = [ordered]@{
|
||||
appName = if ($proc) { $proc.ProcessName } else { '' };
|
||||
windowTitle = $sb.ToString();
|
||||
elementLabel = $elementLabel;
|
||||
elementRole = $elementRole;
|
||||
elementClass = $elementClass;
|
||||
elementValue = $elementValue;
|
||||
elementProcessId = $elementProcessId;
|
||||
pid = $pid;
|
||||
};
|
||||
$out | ConvertTo-Json -Compress;
|
||||
`;
|
||||
return new Promise(resolve => {
|
||||
execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
|
||||
encoding: 'utf8',
|
||||
timeout: 4000,
|
||||
windowsHide: true,
|
||||
}, (err, stdout) => {
|
||||
if (err) { resolve({}); return; }
|
||||
try { resolve(JSON.parse(stdout.trim() || '{}')); }
|
||||
catch { resolve({}); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
collectMacWindowContext() {
|
||||
const script = `
|
||||
set appName to ""
|
||||
set windowTitle to ""
|
||||
tell application "System Events"
|
||||
try
|
||||
set frontApp to first application process whose frontmost is true
|
||||
set appName to name of frontApp
|
||||
try
|
||||
set windowTitle to name of front window of frontApp
|
||||
end try
|
||||
end try
|
||||
end tell
|
||||
return appName & linefeed & windowTitle
|
||||
`;
|
||||
const result = execFileSync('osascript', ['-e', script], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 1200,
|
||||
}).trimEnd();
|
||||
const [appName = '', windowTitle = ''] = result.split(/\r?\n/);
|
||||
return { appName, windowTitle };
|
||||
}
|
||||
|
||||
collectLinuxWindowContext() {
|
||||
if (!hasBinary('xprop')) return { appName: '', windowTitle: '' };
|
||||
const active = execFileSync('xprop', ['-root', '_NET_ACTIVE_WINDOW'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 1200,
|
||||
});
|
||||
const activeMatch = active.match(/window id # (0x[0-9a-fA-F]+)/);
|
||||
if (!activeMatch) return { appName: '', windowTitle: '' };
|
||||
const winId = activeMatch[1];
|
||||
const details = execFileSync('xprop', ['-id', winId, '_NET_WM_NAME', 'WM_NAME', 'WM_CLASS'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 1200,
|
||||
});
|
||||
const titleMatch = details.match(/(?:_NET_WM_NAME\(UTF8_STRING\)|WM_NAME\(STRING\)|WM_NAME\(UTF8_STRING\)) = "([^"]*)"/);
|
||||
const classMatch = details.match(/WM_CLASS\(STRING\) = "([^"]*)"(?:, "([^"]*)")?/);
|
||||
return {
|
||||
appName: classMatch ? (classMatch[2] || classMatch[1] || '') : '',
|
||||
windowTitle: titleMatch ? titleMatch[1] : '',
|
||||
};
|
||||
}
|
||||
|
||||
async buildCaptureTitle({ mode, frame, clickPos, clickMeta = null }) {
|
||||
const ctx = await this.buildCaptureContext({ mode, frame, clickPos, clickMeta });
|
||||
@@ -374,8 +340,19 @@ public static class Win32 {
|
||||
if (!config.ollama.host) {
|
||||
return { ok: false, reason: 'Set an Ollama host first.' };
|
||||
}
|
||||
const tagsUrl = new URL('/api/tags', `${config.ollama.host.replace(/\/+$/, '')}/`);
|
||||
const res = await this.fetch(tagsUrl, { method: 'GET' });
|
||||
let host;
|
||||
try {
|
||||
host = this.resolveHost(config.ollama.host);
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err.message };
|
||||
}
|
||||
const tagsUrl = new URL('/api/tags', `${host.replace(/\/+$/, '')}/`);
|
||||
let res;
|
||||
try {
|
||||
res = await this.fetchJson(tagsUrl, { method: 'GET' });
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err.message };
|
||||
}
|
||||
if (!res.ok) {
|
||||
return { ok: false, reason: `Ollama check failed (${res.status})` };
|
||||
}
|
||||
@@ -383,7 +360,7 @@ public static class Win32 {
|
||||
const models = Array.isArray(data?.models) ? data.models.map((model) => model.name).filter(Boolean) : [];
|
||||
const installed = config.ollama.model ? models.includes(config.ollama.model) : false;
|
||||
const vision = installed ? await this.modelSupportsVision({
|
||||
host: config.ollama.host,
|
||||
host,
|
||||
model: config.ollama.model,
|
||||
}) : false;
|
||||
return {
|
||||
@@ -391,7 +368,7 @@ public static class Win32 {
|
||||
installed,
|
||||
vision,
|
||||
models,
|
||||
host: config.ollama.host,
|
||||
host,
|
||||
model: config.ollama.model,
|
||||
};
|
||||
}
|
||||
@@ -407,10 +384,9 @@ public static class Win32 {
|
||||
const url = new URL('/api/show', `${normalizedHost.replace(/\/+$/, '')}/`);
|
||||
let capabilities = [];
|
||||
try {
|
||||
const response = await this.fetch(url, {
|
||||
const response = await this.fetchJson(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: normalizedModel }),
|
||||
body: { model: normalizedModel },
|
||||
});
|
||||
if (response.ok) {
|
||||
const payload = await response.json();
|
||||
@@ -439,12 +415,12 @@ public static class Win32 {
|
||||
return fs.readFileSync(imagePath).toString('base64');
|
||||
}
|
||||
|
||||
async callOllamaText({ host, model, prompt, systemPrompt }) {
|
||||
async callOllamaText({ host, model, prompt, systemPrompt, guideId = null }) {
|
||||
const url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`);
|
||||
const response = await this.fetch(url, {
|
||||
const response = await this.withConcurrency(() => this.fetchJson(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
guideId,
|
||||
body: {
|
||||
model,
|
||||
stream: false,
|
||||
messages: [
|
||||
@@ -452,8 +428,8 @@ public static class Win32 {
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
options: { temperature: 0.4 },
|
||||
}),
|
||||
});
|
||||
},
|
||||
}));
|
||||
if (!response.ok) throw new Error(`Ollama request failed (${response.status})`);
|
||||
const payload = await response.json();
|
||||
const content = payload?.message?.content;
|
||||
@@ -461,16 +437,16 @@ public static class Win32 {
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
async callOllama({ host, model, prompt, systemPrompt, images = [] }) {
|
||||
async callOllama({ host, model, prompt, systemPrompt, images = [], guideId = null }) {
|
||||
const url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`);
|
||||
const userMessage = { role: 'user', content: prompt };
|
||||
if (Array.isArray(images) && images.length) {
|
||||
userMessage.images = images;
|
||||
}
|
||||
const response = await this.fetch(url, {
|
||||
const response = await this.withConcurrency(() => this.fetchJson(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
guideId,
|
||||
body: {
|
||||
model,
|
||||
stream: false,
|
||||
format: 'json',
|
||||
@@ -481,8 +457,8 @@ public static class Win32 {
|
||||
options: {
|
||||
temperature: 0.2,
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
}));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama request failed (${response.status})`);
|
||||
}
|
||||
@@ -508,12 +484,23 @@ public static class Win32 {
|
||||
if (!config.ollama.host || !config.ollama.model) {
|
||||
return { ok: false, reason: 'Configure Ollama host and model in Settings.' };
|
||||
}
|
||||
let host;
|
||||
try {
|
||||
host = this.resolveHost(config.ollama.host);
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err.message };
|
||||
}
|
||||
const netOptions = this.aiNetworkOptions();
|
||||
|
||||
const guide = this.store.getGuide(guideId);
|
||||
const step = this.store.getStep(guideId, stepId);
|
||||
if (!guide || !step) {
|
||||
return { ok: false, reason: 'Guide or step not found.' };
|
||||
}
|
||||
// Snapshot the revision now: AI generation is slow, and the user may
|
||||
// edit the step meanwhile. We save with this expectedRevision so a
|
||||
// response built from stale data cannot overwrite a newer user edit.
|
||||
const baseRevision = Number.isInteger(step.revision) ? step.revision : 0;
|
||||
|
||||
const currentBlock = blockId
|
||||
? [...(step.textBlocks || []), ...(step.codeBlocks || []), ...(step.tableBlocks || [])].find((b) => b.id === blockId) || null
|
||||
@@ -522,10 +509,20 @@ public static class Win32 {
|
||||
return { ok: false, reason: 'Block not found.' };
|
||||
}
|
||||
|
||||
const screenshotBase64 = step.image ? this.readStepImageBase64(guideId, stepId) : '';
|
||||
// Only attach a screenshot when the user allows it, the model can use
|
||||
// it, and it is within the size budget (a full 4K PNG base64-expands to
|
||||
// tens of MB in the request body).
|
||||
let screenshotBase64 = '';
|
||||
if (netOptions.attachScreenshots && step.image) {
|
||||
const candidate = this.readStepImageBase64(guideId, stepId);
|
||||
const bytes = candidate ? Math.floor((candidate.length * 3) / 4) : 0;
|
||||
if (candidate && bytes <= netOptions.maxImageBytes) {
|
||||
screenshotBase64 = candidate;
|
||||
}
|
||||
}
|
||||
const screenshotAttached = Boolean(screenshotBase64)
|
||||
? await this.modelSupportsVision({
|
||||
host: config.ollama.host,
|
||||
host,
|
||||
model: config.ollama.model,
|
||||
})
|
||||
: false;
|
||||
@@ -588,15 +585,34 @@ public static class Win32 {
|
||||
});
|
||||
|
||||
const raw = await this.callOllama({
|
||||
host: config.ollama.host,
|
||||
host,
|
||||
model: config.ollama.model,
|
||||
prompt,
|
||||
systemPrompt,
|
||||
images: screenshotAttached ? [screenshotBase64] : [],
|
||||
guideId,
|
||||
});
|
||||
const patch = normalizeAiPatch(raw);
|
||||
const updated = applyAiPatchToStep(step, patch, { target, blockId });
|
||||
const saved = this.store.saveStep(guideId, updated);
|
||||
// Re-read the step: while generation ran, a capture auto-doc or another
|
||||
// background write may have advanced it. Apply the patch to the current
|
||||
// step and save with the original expected revision so a user edit made
|
||||
// during generation causes a conflict instead of a silent overwrite.
|
||||
let currentStep = step;
|
||||
try {
|
||||
currentStep = this.store.getStep(guideId, stepId) || step;
|
||||
} catch {
|
||||
currentStep = step;
|
||||
}
|
||||
const updated = applyAiPatchToStep(currentStep, patch, { target, blockId });
|
||||
let saved;
|
||||
try {
|
||||
saved = this.store.saveStep(guideId, updated, { expectedRevision: baseRevision });
|
||||
} catch (err) {
|
||||
if (err && err.code === 'STEPFORGE_REVISION_CONFLICT') {
|
||||
return { ok: false, reason: 'The step changed while AI was generating; nothing was overwritten.' };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return { ok: true, step: saved, patch };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err && err.message ? err.message : 'AI generation failed.' };
|
||||
@@ -610,6 +626,12 @@ public static class Win32 {
|
||||
if (!config.ollama.host || !config.ollama.model) {
|
||||
return { ok: false, reason: 'Configure Ollama host and model in Settings.' };
|
||||
}
|
||||
let host;
|
||||
try {
|
||||
host = this.resolveHost(config.ollama.host);
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err.message };
|
||||
}
|
||||
const trimmed = normalizeWhitespace(text);
|
||||
if (!trimmed) return { ok: false, reason: 'No text to rewrite.' };
|
||||
|
||||
@@ -628,7 +650,7 @@ public static class Win32 {
|
||||
].filter((l) => l !== null).join('\n');
|
||||
|
||||
const result = await this.callOllamaText({
|
||||
host: config.ollama.host,
|
||||
host,
|
||||
model: config.ollama.model,
|
||||
prompt,
|
||||
systemPrompt: 'You are a documentation editor. Return only the improved text, nothing else.',
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,110 @@
|
||||
!include LogicLib.nsh
|
||||
!include nsDialogs.nsh
|
||||
|
||||
!ifndef BUILD_UNINSTALLER
|
||||
Var StepForgeDesktopShortcutCheckbox
|
||||
Var StepForgeDesktopShortcutState
|
||||
|
||||
; Assisted installer page for the desktop shortcut choice.
|
||||
!macro customInit
|
||||
StrCpy $StepForgeDesktopShortcutState "true"
|
||||
${If} ${isNoDesktopShortcut}
|
||||
StrCpy $StepForgeDesktopShortcutState "false"
|
||||
${EndIf}
|
||||
!macroend
|
||||
|
||||
!macro customHeader
|
||||
Function StepForgeDesktopShortcutPagePre
|
||||
!insertmacro MUI_PAGE_FUNCTION_CUSTOM PRE
|
||||
!insertmacro MUI_HEADER_TEXT "Desktop Icon" "Choose whether StepForge creates a desktop icon."
|
||||
|
||||
nsDialogs::Create 1018
|
||||
Pop $0
|
||||
${If} $0 == error
|
||||
Abort
|
||||
${EndIf}
|
||||
|
||||
${NSD_CreateLabel} 0u 0u 280u 24u "StepForge can create a desktop icon for quick access from the desktop."
|
||||
Pop $0
|
||||
|
||||
${NSD_CreateCheckbox} 0u 34u 280u 12u "Create a desktop icon"
|
||||
Pop $StepForgeDesktopShortcutCheckbox
|
||||
|
||||
StrCpy $StepForgeDesktopShortcutState "true"
|
||||
${If} ${isNoDesktopShortcut}
|
||||
StrCpy $StepForgeDesktopShortcutState "false"
|
||||
EnableWindow $StepForgeDesktopShortcutCheckbox 0
|
||||
${Else}
|
||||
ReadRegStr $0 SHELL_CONTEXT "${INSTALL_REGISTRY_KEY}" CreateDesktopShortcut
|
||||
${If} $0 == "false"
|
||||
StrCpy $StepForgeDesktopShortcutState "false"
|
||||
${Else}
|
||||
ReadRegStr $1 SHELL_CONTEXT "${INSTALL_REGISTRY_KEY}" ShortcutName
|
||||
${If} $1 == ""
|
||||
StrCpy $1 "${SHORTCUT_NAME}"
|
||||
${EndIf}
|
||||
|
||||
${If} ${FileExists} "$DESKTOP\$1.lnk"
|
||||
StrCpy $StepForgeDesktopShortcutState "true"
|
||||
${ElseIf} ${FileExists} "$INSTDIR\${APP_EXECUTABLE_FILENAME}"
|
||||
StrCpy $StepForgeDesktopShortcutState "false"
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
|
||||
${If} $StepForgeDesktopShortcutState == "false"
|
||||
SendMessage $StepForgeDesktopShortcutCheckbox ${BM_SETCHECK} ${BST_UNCHECKED} 0
|
||||
${Else}
|
||||
SendMessage $StepForgeDesktopShortcutCheckbox ${BM_SETCHECK} ${BST_CHECKED} 0
|
||||
${EndIf}
|
||||
|
||||
!insertmacro MUI_PAGE_FUNCTION_CUSTOM SHOW
|
||||
nsDialogs::Show
|
||||
FunctionEnd
|
||||
|
||||
Function StepForgeDesktopShortcutPageLeave
|
||||
!insertmacro MUI_PAGE_FUNCTION_CUSTOM LEAVE
|
||||
|
||||
SendMessage $StepForgeDesktopShortcutCheckbox ${BM_GETCHECK} 0 0 $StepForgeDesktopShortcutState
|
||||
${If} $StepForgeDesktopShortcutState == ${BST_UNCHECKED}
|
||||
StrCpy $StepForgeDesktopShortcutState "false"
|
||||
${Else}
|
||||
StrCpy $StepForgeDesktopShortcutState "true"
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
!macroend
|
||||
|
||||
!macro customPageAfterChangeDir
|
||||
!insertmacro MUI_PAGE_INIT
|
||||
PageEx custom
|
||||
PageCallbacks StepForgeDesktopShortcutPagePre StepForgeDesktopShortcutPageLeave
|
||||
Caption " "
|
||||
PageExEnd
|
||||
!macroend
|
||||
|
||||
!macro customInstall
|
||||
; Reconcile the desktop shortcut after the default installer logic runs.
|
||||
${If} ${isNoDesktopShortcut}
|
||||
StrCpy $StepForgeDesktopShortcutState "false"
|
||||
${EndIf}
|
||||
|
||||
WriteRegStr SHELL_CONTEXT "${INSTALL_REGISTRY_KEY}" CreateDesktopShortcut "$StepForgeDesktopShortcutState"
|
||||
|
||||
${If} $StepForgeDesktopShortcutState == "false"
|
||||
Delete "$newDesktopLink"
|
||||
Delete "$oldDesktopLink"
|
||||
${Else}
|
||||
${IfNot} ${FileExists} "$newDesktopLink"
|
||||
${If} ${FileExists} "$oldDesktopLink"
|
||||
Rename "$oldDesktopLink" "$newDesktopLink"
|
||||
${Else}
|
||||
CreateShortCut "$newDesktopLink" "$appExe" "" "$appExe" 0 "" "" "${APP_DESCRIPTION}"
|
||||
${EndIf}
|
||||
ClearErrors
|
||||
WinShell::SetLnkAUMI "$newDesktopLink" "${APP_ID}"
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
|
||||
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)'
|
||||
!macroend
|
||||
!endif
|
||||
@@ -124,18 +124,40 @@ function importGuideArchive(store, file, { mode = 'copy' } = {}) {
|
||||
}
|
||||
|
||||
function finalizeImport(store, newGuide, idMap, stepJsons, stepFiles) {
|
||||
// Transactional import: validate the guide and EVERY step first, then write
|
||||
// the whole guide into a temporary staging directory, and only publish it
|
||||
// with a single atomic rename. Previously guide.json was written before the
|
||||
// steps validated, so a bad step left a partial guide in the library.
|
||||
validateGuide(newGuide);
|
||||
writeJsonSync(path.join(store.guideDir(newGuide.guideId), 'guide.json'), newGuide);
|
||||
|
||||
const normalizedSteps = [];
|
||||
for (const [stepId, { raw }] of stepJsons) {
|
||||
const step = normalizeStep({ ...raw, stepId });
|
||||
step.parentStepId = raw.parentStepId ? idMap.get(raw.parentStepId) || null : null;
|
||||
validateStep(step);
|
||||
const dir = store.stepDir(newGuide.guideId, stepId);
|
||||
writeJsonSync(path.join(dir, 'step.json'), step);
|
||||
for (const { name, data } of stepFiles.get(stepId) || []) {
|
||||
atomicWriteFileSync(path.join(dir, name), data);
|
||||
validateStep(step); // throws before anything is written on a bad step
|
||||
normalizedSteps.push([stepId, step]);
|
||||
}
|
||||
|
||||
const finalDir = store.guideDir(newGuide.guideId);
|
||||
if (fs.existsSync(finalDir)) throw new Error(`guide already exists: ${newGuide.guideId}`);
|
||||
const stagingDir = `${finalDir}.importing-${Date.now()}`;
|
||||
fs.rmSync(stagingDir, { recursive: true, force: true });
|
||||
try {
|
||||
fs.mkdirSync(stagingDir, { recursive: true });
|
||||
writeJsonSync(path.join(stagingDir, 'guide.json'), newGuide);
|
||||
for (const [stepId, step] of normalizedSteps) {
|
||||
const dir = path.join(stagingDir, 'steps', stepId);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
writeJsonSync(path.join(dir, 'step.json'), step);
|
||||
for (const { name, data } of stepFiles.get(stepId) || []) {
|
||||
atomicWriteFileSync(path.join(dir, name), data);
|
||||
}
|
||||
}
|
||||
// Publish atomically. If the final dir appeared meanwhile, fail cleanly.
|
||||
if (fs.existsSync(finalDir)) throw new Error(`guide already exists: ${newGuide.guideId}`);
|
||||
fs.renameSync(stagingDir, finalDir);
|
||||
} catch (err) {
|
||||
fs.rmSync(stagingDir, { recursive: true, force: true });
|
||||
throw err;
|
||||
}
|
||||
return store.getGuide(newGuide.guideId);
|
||||
}
|
||||
@@ -160,7 +182,9 @@ function saveLinkedGuide(store, guideId, { force = false } = {}) {
|
||||
store.saveGuide(guide, { touch: false });
|
||||
return { saved: true, path: target };
|
||||
} finally {
|
||||
releaseLock(target);
|
||||
// Release by our acquisition token so we never remove a lock a concurrent
|
||||
// force-steal replaced with theirs.
|
||||
releaseLock(target, { lock: result.lock });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,18 +20,39 @@ function lockPathFor(archivePath) {
|
||||
return path.join(dir, `${stem}.lock-sfgz`);
|
||||
}
|
||||
|
||||
function currentHolder() {
|
||||
function currentProcess() {
|
||||
return { host: os.hostname(), user: os.userInfo().username, pid: process.pid };
|
||||
}
|
||||
|
||||
function currentHolder() {
|
||||
return {
|
||||
...currentProcess(),
|
||||
// Random per-acquisition token so two processes that happen to share
|
||||
// host+user+pid space (containers, pid reuse) still compare distinctly,
|
||||
// and so a steal can be detected by the previous holder.
|
||||
token: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function readLock(archivePath) {
|
||||
return readJsonIfExists(lockPathFor(archivePath), null);
|
||||
}
|
||||
|
||||
function sameHolder(a, b) {
|
||||
// Process identity (host+user+pid). Used to decide whether an existing lock is
|
||||
// held by *this process* (safe to re-acquire) or someone else (a conflict).
|
||||
function sameProcess(a, b) {
|
||||
return a && b && a.host === b.host && a.user === b.user && a.pid === b.pid;
|
||||
}
|
||||
|
||||
// Exact-acquisition identity via the per-acquisition token. Used by release so
|
||||
// a caller only removes the lock it actually took (never one a force-steal
|
||||
// replaced with its own).
|
||||
function sameAcquisition(existing, owner) {
|
||||
if (!existing || !owner) return false;
|
||||
if (owner.token) return existing.token === owner.token;
|
||||
return sameProcess(existing, owner);
|
||||
}
|
||||
|
||||
function isStale(lock, now = Date.now()) {
|
||||
const t = Date.parse(lock && lock.acquiredAt);
|
||||
return !Number.isFinite(t) || now - t > STALE_AFTER_MS;
|
||||
@@ -44,24 +65,49 @@ function isStale(lock, now = Date.now()) {
|
||||
*/
|
||||
function acquireLock(archivePath, { force = false } = {}) {
|
||||
const file = lockPathFor(archivePath);
|
||||
const existing = readLock(archivePath);
|
||||
const me = currentHolder();
|
||||
if (existing && !sameHolder(existing, me) && !isStale(existing) && !force) {
|
||||
const lock = { ...me, acquiredAt: nowIso() };
|
||||
const payload = JSON.stringify(lock, null, 2);
|
||||
|
||||
// Fast path: exclusive create. Only one writer wins the O_CREAT|O_EXCL race,
|
||||
// so two processes can't both believe they hold the lock (the old
|
||||
// read-then-write left exactly that window open).
|
||||
try {
|
||||
fs.writeFileSync(file, payload, { flag: 'wx' });
|
||||
return { acquired: true, lock };
|
||||
} catch (err) {
|
||||
if (err.code !== 'EEXIST') throw err;
|
||||
}
|
||||
|
||||
// A lock already exists. We may take it over only if this process already
|
||||
// holds it, it is stale, or the caller is force-stealing (user confirmed).
|
||||
const existing = readLock(archivePath);
|
||||
if (existing && !sameProcess(existing, me) && !isStale(existing) && !force) {
|
||||
return { acquired: false, conflict: existing };
|
||||
}
|
||||
const lock = { ...me, acquiredAt: nowIso() };
|
||||
fs.writeFileSync(file, JSON.stringify(lock, null, 2));
|
||||
// Overwrite to claim ownership (our token now identifies the lock).
|
||||
fs.writeFileSync(file, payload);
|
||||
return { acquired: true, lock };
|
||||
}
|
||||
|
||||
/** Release only if we are the holder (or force). */
|
||||
function releaseLock(archivePath, { force = false } = {}) {
|
||||
/**
|
||||
* Release only if we are the holder (or force). Pass the `lock` (or its
|
||||
* `token`) returned by acquireLock so ownership is matched by token — the
|
||||
* per-acquisition token means a fresh currentHolder() would not match.
|
||||
*/
|
||||
function releaseLock(archivePath, { force = false, lock = null, token = null } = {}) {
|
||||
const file = lockPathFor(archivePath);
|
||||
const existing = readLock(archivePath);
|
||||
if (!existing) return true;
|
||||
if (!force && !sameHolder(existing, currentHolder())) return false;
|
||||
// With no explicit lock/token, fall back to process identity (the legacy
|
||||
// "release my own lock" path) rather than a fresh token that can't match.
|
||||
const owner = lock || (token ? { token } : currentProcess());
|
||||
if (!force && !sameAcquisition(existing, owner)) return false;
|
||||
fs.rmSync(file, { force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = { lockPathFor, readLock, acquireLock, releaseLock, isStale, STALE_AFTER_MS };
|
||||
module.exports = {
|
||||
lockPathFor, readLock, acquireLock, releaseLock, isStale, STALE_AFTER_MS,
|
||||
sameProcess, sameAcquisition,
|
||||
};
|
||||
|
||||
@@ -52,6 +52,9 @@ function createGuide(fields = {}) {
|
||||
favorite: Boolean(fields.favorite),
|
||||
linkedSource: fields.linkedSource || null,
|
||||
exportProfiles: { ...(fields.exportProfiles || {}) },
|
||||
// Monotonic revision for optimistic concurrency. Absent in v1 data (reads
|
||||
// as 0), bumped on every store write.
|
||||
revision: Number.isInteger(fields.revision) && fields.revision >= 0 ? fields.revision : 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,6 +92,8 @@ function createStep(fields = {}) {
|
||||
captureMetadata: (fields.captureMetadata && typeof fields.captureMetadata === 'object' && !Array.isArray(fields.captureMetadata))
|
||||
? { ...fields.captureMetadata }
|
||||
: null,
|
||||
// Monotonic revision for optimistic concurrency (see createGuide).
|
||||
revision: Number.isInteger(fields.revision) && fields.revision >= 0 ? fields.revision : 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ const { blockText } = require('./blocks');
|
||||
* specific step in the editor.
|
||||
*/
|
||||
|
||||
const INDEX_VERSION = 1;
|
||||
const INDEX_VERSION = 2;
|
||||
|
||||
function tokenize(text) {
|
||||
if (!text) return [];
|
||||
@@ -27,20 +27,82 @@ function tokenize(text) {
|
||||
class SearchIndex {
|
||||
constructor(indexDir) {
|
||||
this.file = path.join(indexDir, 'search-index.json');
|
||||
// Per-guide source fingerprints so a startup reconcile can tell which
|
||||
// guides changed while the app was closed, without re-reading every step.
|
||||
this.fingerprints = {}; // guideId -> fingerprint string
|
||||
// Recovery status surfaced to the UI: 'ok' | 'reset' (missing/corrupt/
|
||||
// version mismatch) | 'reconciled' (rebuilt from the store at startup).
|
||||
this.status = 'ok';
|
||||
const fileExisted = require('node:fs').existsSync(this.file);
|
||||
const stored = readJsonIfExists(this.file, null);
|
||||
if (stored && stored.version === INDEX_VERSION) {
|
||||
if (stored && stored.version === INDEX_VERSION && stored.docs && typeof stored.docs === 'object') {
|
||||
this.docs = stored.docs;
|
||||
this.fingerprints = stored.fingerprints || {};
|
||||
} else {
|
||||
// Missing, corrupt, or an older index version: start empty and mark it,
|
||||
// so reconcile() rebuilds from the store instead of silently staying
|
||||
// blank (which made search "work" but return nothing). A file that
|
||||
// existed but could not be used is a 'reset' (recovery-worthy); a
|
||||
// genuinely absent index on first run is just 'ok'.
|
||||
this.docs = {}; // docKey -> { guideId, stepId, title, text, updatedAt }
|
||||
this.status = fileExisted ? 'reset' : 'ok';
|
||||
}
|
||||
}
|
||||
|
||||
persist() {
|
||||
writeJsonSync(this.file, { version: INDEX_VERSION, docs: this.docs });
|
||||
writeJsonSync(this.file, {
|
||||
version: INDEX_VERSION,
|
||||
docs: this.docs,
|
||||
fingerprints: this.fingerprints,
|
||||
});
|
||||
}
|
||||
|
||||
static fingerprint(guide) {
|
||||
return `${guide.updatedAt || ''}:${Number.isInteger(guide.revision) ? guide.revision : 0}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the index against the store at startup: reindex guides that are
|
||||
* new or changed (by fingerprint), and drop index entries for guides that no
|
||||
* longer exist. Returns a summary with a recovery status for the UI.
|
||||
*/
|
||||
reconcile(store) {
|
||||
const guides = store.listGuides();
|
||||
const liveIds = new Set(guides.map((g) => g.guideId));
|
||||
let reindexed = 0;
|
||||
let removed = 0;
|
||||
|
||||
// Drop docs/fingerprints for guides that are gone.
|
||||
for (const key of Object.keys(this.fingerprints)) {
|
||||
if (!liveIds.has(key)) {
|
||||
this.removeGuide(key, { persist: false });
|
||||
delete this.fingerprints[key];
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const guide of guides) {
|
||||
const fp = SearchIndex.fingerprint(guide);
|
||||
const indexed = this.fingerprints[guide.guideId];
|
||||
const hasDoc = Boolean(this.docs[`g:${guide.guideId}`]);
|
||||
if (indexed === fp && hasDoc) continue; // unchanged
|
||||
try {
|
||||
this.indexGuide(guide, store.listSteps(guide.guideId), { persist: false });
|
||||
reindexed += 1;
|
||||
} catch {
|
||||
// A single unreadable guide must not abort the whole reconcile.
|
||||
}
|
||||
}
|
||||
|
||||
this.persist();
|
||||
if (this.status === 'reset' || reindexed > 0 || removed > 0) {
|
||||
this.status = this.status === 'reset' ? 'reset' : 'reconciled';
|
||||
}
|
||||
return { status: this.status, reindexed, removed, total: guides.length };
|
||||
}
|
||||
|
||||
/** (Re)index one guide and all of its steps. */
|
||||
indexGuide(guide, stepsMap) {
|
||||
indexGuide(guide, stepsMap, { persist = true } = {}) {
|
||||
this.removeGuide(guide.guideId, { persist: false });
|
||||
|
||||
const placeholderText = Object.entries(guide.placeholders || {})
|
||||
@@ -69,13 +131,15 @@ class SearchIndex {
|
||||
updatedAt: guide.updatedAt,
|
||||
};
|
||||
}
|
||||
this.persist();
|
||||
this.fingerprints[guide.guideId] = SearchIndex.fingerprint(guide);
|
||||
if (persist) this.persist();
|
||||
}
|
||||
|
||||
removeGuide(guideId, { persist = true } = {}) {
|
||||
for (const key of Object.keys(this.docs)) {
|
||||
if (this.docs[key].guideId === guideId) delete this.docs[key];
|
||||
}
|
||||
delete this.fingerprints[guideId];
|
||||
if (persist) this.persist();
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ const DEFAULT_SETTINGS = {
|
||||
hotkeyPauseResume: 'CommandOrControl+Shift+2',
|
||||
captureOutsideClicks: true,
|
||||
confirmSimpleCapture: false,
|
||||
// Fallback trigger when click capture is unavailable: keep the old timer
|
||||
// fallback by default, but let users switch to hotkey-only recordings.
|
||||
fallbackTrigger: 'interval', // interval | hotkey
|
||||
// Leading-edge click debounce (ms): clicks of the same button closer
|
||||
// together than this collapse into one step, so accidental fast/double
|
||||
// clicks don't each become a step. Clicks spaced further apart always
|
||||
@@ -42,6 +45,13 @@ const DEFAULT_SETTINGS = {
|
||||
// user is likely to click so the buffer holds frames of the now-visible
|
||||
// screen rather than the just-dismissed app window.
|
||||
postHideSettleMs: 150,
|
||||
// Raw typed-text capture. When true, printable characters typed between
|
||||
// captures are buffered and stored in step capture metadata (and can be
|
||||
// sent to a configured AI host). This can record passwords or other
|
||||
// secrets, so it is OFF by default and must be explicitly opted into.
|
||||
// Shortcut detection (Ctrl+T, Enter, …) is unaffected — only raw
|
||||
// character logging is gated here.
|
||||
captureTypedText: false,
|
||||
},
|
||||
editor: {
|
||||
focusedViewDefaultForNewSteps: false,
|
||||
@@ -49,6 +59,22 @@ const DEFAULT_SETTINGS = {
|
||||
},
|
||||
ai: {
|
||||
enabled: false,
|
||||
// Auto-document captured steps in the background when a session capture
|
||||
// lands. Requires enabled + a reachable model.
|
||||
autoDoc: false,
|
||||
// Local-first: only a loopback Ollama endpoint is contacted unless this
|
||||
// is explicitly turned on. Turning it on sends screenshots and text to
|
||||
// the configured remote host.
|
||||
allowRemoteHost: false,
|
||||
// Attach the step screenshot to AI requests (only for vision-capable
|
||||
// models). Turning this off keeps requests text-only.
|
||||
attachScreenshots: true,
|
||||
// Per-request network deadline (ms). A dead endpoint fails fast instead
|
||||
// of leaving UI actions pending forever.
|
||||
timeoutMs: 60000,
|
||||
// Skip attaching a screenshot larger than this many bytes (pre-base64)
|
||||
// to avoid multi-hundred-MB request bodies.
|
||||
maxImageBytes: 12 * 1024 * 1024,
|
||||
ollama: {
|
||||
host: 'http://127.0.0.1:11434',
|
||||
model: 'llama3.2:1b',
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { zipDirSync, extractZipSync } = require('./zip');
|
||||
const { atomicWriteFileSync } = require('./util');
|
||||
const { atomicWriteFileSync, readJsonSync } = require('./util');
|
||||
const { validateGuide } = require('./schema');
|
||||
|
||||
/**
|
||||
* Snapshot backups: a zip of the guide directory (excluding history/) stored
|
||||
@@ -16,7 +17,11 @@ function snapshotsDir(store, guideId) {
|
||||
}
|
||||
|
||||
function snapshotName(label) {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace(/-\d{3}Z$/, 'Z');
|
||||
// Keep milliseconds: stripping them made two snapshots taken within the same
|
||||
// second collide on filename (the second silently overwrote the first, so
|
||||
// rapid automatic backups produced only one file). ms keeps names unique and
|
||||
// still chronologically sortable.
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
return label ? `${stamp}-${label.replace(/[^A-Za-z0-9_-]+/g, '_')}.zip` : `${stamp}.zip`;
|
||||
}
|
||||
|
||||
@@ -50,20 +55,103 @@ function pruneSnapshots(store, guideId, keepLast) {
|
||||
/**
|
||||
* Restore a snapshot: replaces the guide's current content (guide.json and
|
||||
* steps/) with the snapshot's, keeping the history/ directory intact.
|
||||
*
|
||||
* The extraction is staged and validated BEFORE any live content is touched:
|
||||
* a corrupt or truncated snapshot can no longer destroy the current guide.
|
||||
* The swap itself moves the old content aside, moves the new content in, then
|
||||
* deletes the old — so a failure mid-swap leaves a recoverable state.
|
||||
*/
|
||||
function restoreSnapshot(store, guideId, name) {
|
||||
const file = path.join(snapshotsDir(store, guideId), path.basename(name));
|
||||
if (!fs.existsSync(file)) throw new Error(`snapshot not found: ${name}`);
|
||||
const buf = fs.readFileSync(file);
|
||||
const guideDir = store.guideDir(guideId);
|
||||
// Safety: snapshot the pre-restore state too, so a restore is undoable.
|
||||
createSnapshot(store, guideId, { label: 'pre-restore' });
|
||||
for (const entry of fs.readdirSync(guideDir)) {
|
||||
if (entry === 'history') continue;
|
||||
fs.rmSync(path.join(guideDir, entry), { recursive: true, force: true });
|
||||
|
||||
// 1. Extract + validate into a temp staging dir. Nothing live is touched yet.
|
||||
const staging = `${guideDir}.restoring-${Date.now()}`;
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
try {
|
||||
fs.mkdirSync(staging, { recursive: true });
|
||||
extractZipSync(buf, staging);
|
||||
const guideJson = path.join(staging, 'guide.json');
|
||||
if (!fs.existsSync(guideJson)) throw new Error('snapshot is missing guide.json');
|
||||
validateGuide(readJsonSync(guideJson)); // throws on a corrupt snapshot
|
||||
} catch (err) {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
throw new Error(`snapshot restore aborted (snapshot invalid): ${err.message}`);
|
||||
}
|
||||
extractZipSync(buf, guideDir);
|
||||
|
||||
// 2. Snapshot the pre-restore state so the restore is itself undoable.
|
||||
createSnapshot(store, guideId, { label: 'pre-restore' });
|
||||
|
||||
// 3. Swap in the validated content, preserving history/. Move live content
|
||||
// aside first so we can roll back if a step fails.
|
||||
const backup = `${guideDir}.prev-${Date.now()}`;
|
||||
const liveEntries = fs.readdirSync(guideDir).filter((e) => e !== 'history');
|
||||
fs.mkdirSync(backup, { recursive: true });
|
||||
try {
|
||||
for (const entry of liveEntries) {
|
||||
fs.renameSync(path.join(guideDir, entry), path.join(backup, entry));
|
||||
}
|
||||
for (const entry of fs.readdirSync(staging)) {
|
||||
if (entry === 'history') continue;
|
||||
fs.renameSync(path.join(staging, entry), path.join(guideDir, entry));
|
||||
}
|
||||
} catch (err) {
|
||||
// Roll back: restore whatever we moved aside.
|
||||
for (const entry of fs.readdirSync(backup)) {
|
||||
const dest = path.join(guideDir, entry);
|
||||
fs.rmSync(dest, { recursive: true, force: true });
|
||||
fs.renameSync(path.join(backup, entry), dest);
|
||||
}
|
||||
fs.rmSync(backup, { recursive: true, force: true });
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
throw err;
|
||||
}
|
||||
fs.rmSync(backup, { recursive: true, force: true });
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
return store.getGuide(guideId);
|
||||
}
|
||||
|
||||
module.exports = { createSnapshot, listSnapshots, pruneSnapshots, restoreSnapshot, snapshotsDir };
|
||||
/**
|
||||
* Automatic backup policy. Every guide keeps a small save counter in its
|
||||
* history dir; once `everyNSaves` saves accumulate (and backups.automatic is
|
||||
* on) an automatic snapshot is taken and old ones pruned to backups.keepLast.
|
||||
* Returns the snapshot name when one was taken, else null. Never throws — a
|
||||
* backup failure must not break the save that triggered it.
|
||||
*/
|
||||
function autoSnapshotIfDue(store, guideId, settings) {
|
||||
try {
|
||||
const backups = (settings && settings.get && settings.get('backups')) || {};
|
||||
if (backups.automatic === false) return null;
|
||||
const everyN = Number.isInteger(backups.everyNSaves) && backups.everyNSaves > 0 ? backups.everyNSaves : 25;
|
||||
const keepLast = Number.isInteger(backups.keepLast) && backups.keepLast > 0 ? backups.keepLast : 10;
|
||||
|
||||
const dir = path.join(store.guideDir(guideId), 'history');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const counterFile = path.join(dir, 'autosave-counter.json');
|
||||
let count = 0;
|
||||
try {
|
||||
count = JSON.parse(fs.readFileSync(counterFile, 'utf8')).count || 0;
|
||||
} catch { count = 0; }
|
||||
count += 1;
|
||||
|
||||
if (count >= everyN) {
|
||||
createSnapshot(store, guideId, { label: 'auto', keepLast });
|
||||
count = 0;
|
||||
atomicWriteFileSync(counterFile, JSON.stringify({ count }));
|
||||
return true;
|
||||
}
|
||||
atomicWriteFileSync(counterFile, JSON.stringify({ count }));
|
||||
return null;
|
||||
} catch (err) {
|
||||
// Best effort: report, never break the caller's save.
|
||||
console.error(`[stepforge] automatic backup failed for ${guideId}: ${err && err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createSnapshot, listSnapshots, pruneSnapshots, restoreSnapshot, snapshotsDir,
|
||||
autoSnapshotIfDue,
|
||||
};
|
||||
|
||||
@@ -12,6 +12,22 @@ const {
|
||||
} = require('./schema');
|
||||
const { sanitizeHtml } = require('./sanitize');
|
||||
|
||||
/**
|
||||
* Thrown by revision-aware saves when the on-disk revision no longer matches
|
||||
* the caller's expectation — i.e. someone else wrote in between. Callers that
|
||||
* pass expectedRevision (background/AI/capture writes) use this to avoid
|
||||
* clobbering a newer user edit.
|
||||
*/
|
||||
class RevisionConflictError extends Error {
|
||||
constructor(kind, id, expected, actual) {
|
||||
super(`${kind} ${id} changed since it was read (expected revision ${expected}, found ${actual})`);
|
||||
this.name = 'RevisionConflictError';
|
||||
this.code = 'STEPFORGE_REVISION_CONFLICT';
|
||||
this.expected = expected;
|
||||
this.actual = actual;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Folder-based guide store. One directory per guide, one directory per step,
|
||||
* all JSON written atomically. This is the only module that knows the
|
||||
@@ -27,21 +43,49 @@ class GuideStore {
|
||||
this.guidesDir = path.join(this.libraryDir, 'guides');
|
||||
this.indexDir = path.join(this.libraryDir, 'index');
|
||||
this.trashDir = path.join(this.libraryDir, 'trash');
|
||||
this.quarantineDir = path.join(this.libraryDir, 'quarantine');
|
||||
this.tempDir = path.join(rootDir, 'temp');
|
||||
this.sharedLinksDir = path.join(rootDir, 'shared-links');
|
||||
this.foldersFile = path.join(this.libraryDir, 'folders.json');
|
||||
// In-memory log of files quarantined this session (corrupt/unreadable),
|
||||
// surfaced to the UI instead of silently vanishing.
|
||||
this.recoveryReport = [];
|
||||
this.ensureLayout();
|
||||
}
|
||||
|
||||
ensureLayout() {
|
||||
for (const dir of [
|
||||
this.settingsDir, this.templatesDir, this.guidesDir, this.indexDir,
|
||||
this.trashDir, this.tempDir, this.sharedLinksDir,
|
||||
this.trashDir, this.quarantineDir, this.tempDir, this.sharedLinksDir,
|
||||
]) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a corrupt/unreadable file or directory into quarantine (preserving
|
||||
* the original bytes) and record it, rather than silently dropping it. A
|
||||
* guide/step never just disappears without an explanation.
|
||||
*/
|
||||
quarantine(sourcePath, kind, reason) {
|
||||
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const dest = path.join(this.quarantineDir, `${kind}-${path.basename(sourcePath)}-${stamp}`);
|
||||
try {
|
||||
fs.mkdirSync(this.quarantineDir, { recursive: true });
|
||||
fs.renameSync(sourcePath, dest);
|
||||
} catch {
|
||||
// If we cannot move it (e.g. cross-device or vanished), still record it.
|
||||
}
|
||||
const entry = { kind, source: sourcePath, quarantined: dest, reason: String(reason || 'unreadable'), at: nowIso() };
|
||||
this.recoveryReport.push(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Corrupt files quarantined this session (for a recovery UI). */
|
||||
getRecoveryReport() {
|
||||
return [...this.recoveryReport];
|
||||
}
|
||||
|
||||
guideDir(guideId) {
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(guideId)) throw new Error(`bad guide id: ${guideId}`);
|
||||
return path.join(this.guidesDir, guideId);
|
||||
@@ -70,10 +114,19 @@ class GuideStore {
|
||||
return normalizeGuide(raw);
|
||||
}
|
||||
|
||||
saveGuide(guide, { touch = true } = {}) {
|
||||
saveGuide(guide, { touch = true, expectedRevision = null } = {}) {
|
||||
validateGuide(guide);
|
||||
// Optimistic concurrency: a caller that read the guide can pass the
|
||||
// revision it saw; if disk moved on since, refuse rather than clobber.
|
||||
if (expectedRevision !== null) {
|
||||
const current = this.guideExists(guide.guideId) ? this.getGuide(guide.guideId).revision : 0;
|
||||
if (current !== expectedRevision) {
|
||||
throw new RevisionConflictError('guide', guide.guideId, expectedRevision, current);
|
||||
}
|
||||
}
|
||||
const stored = deepClone(guide);
|
||||
stored.descriptionHtml = sanitizeHtml(stored.descriptionHtml);
|
||||
stored.revision = (Number.isInteger(stored.revision) ? stored.revision : 0) + 1;
|
||||
if (touch) stored.updatedAt = nowIso();
|
||||
writeJsonSync(path.join(this.guideDir(guide.guideId), 'guide.json'), stored);
|
||||
return stored;
|
||||
@@ -83,11 +136,16 @@ class GuideStore {
|
||||
const out = [];
|
||||
for (const entry of fs.readdirSync(this.guidesDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const file = path.join(this.guidesDir, entry.name, 'guide.json');
|
||||
const dir = path.join(this.guidesDir, entry.name);
|
||||
const file = path.join(dir, 'guide.json');
|
||||
if (!fs.existsSync(file)) continue; // in-progress/empty dir, not corruption
|
||||
try {
|
||||
out.push(normalizeGuide(readJsonSync(file)));
|
||||
} catch {
|
||||
// skip unreadable entries rather than failing the whole library
|
||||
} catch (err) {
|
||||
// A corrupt guide.json used to make the guide silently vanish from the
|
||||
// library. Quarantine the directory (preserving it) and record it so
|
||||
// the user can be told, instead of losing it without explanation.
|
||||
this.quarantine(dir, 'guide', err && err.message);
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
|
||||
@@ -211,18 +269,38 @@ class GuideStore {
|
||||
if (!fs.existsSync(stepsRoot)) return map;
|
||||
for (const entry of fs.readdirSync(stepsRoot, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const dir = path.join(stepsRoot, entry.name);
|
||||
const file = path.join(dir, 'step.json');
|
||||
if (!fs.existsSync(file)) continue;
|
||||
try {
|
||||
map.set(entry.name, normalizeStep(readJsonSync(path.join(stepsRoot, entry.name, 'step.json'))));
|
||||
} catch {
|
||||
// skip unreadable step
|
||||
map.set(entry.name, normalizeStep(readJsonSync(file)));
|
||||
} catch (err) {
|
||||
// Quarantine a corrupt step (preserving it) and record it rather than
|
||||
// silently dropping it from the guide.
|
||||
this.quarantine(dir, 'step', err && err.message);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
saveStep(guideId, step) {
|
||||
saveStep(guideId, step, { expectedRevision = null } = {}) {
|
||||
// Optimistic concurrency for background/AI/capture writes: refuse to
|
||||
// overwrite a step that changed since it was read. Direct user edits pass
|
||||
// no expectedRevision (last-write-wins — the user is the authority).
|
||||
if (expectedRevision !== null) {
|
||||
let current = 0;
|
||||
try {
|
||||
current = this.getStep(guideId, step.stepId).revision;
|
||||
} catch {
|
||||
current = 0; // step vanished; treat as revision 0
|
||||
}
|
||||
if (current !== expectedRevision) {
|
||||
throw new RevisionConflictError('step', step.stepId, expectedRevision, current);
|
||||
}
|
||||
}
|
||||
const stored = normalizeStep(deepClone(step));
|
||||
stored.descriptionHtml = sanitizeHtml(stored.descriptionHtml);
|
||||
stored.revision = (Number.isInteger(stored.revision) ? stored.revision : 0) + 1;
|
||||
validateStep(stored);
|
||||
writeJsonSync(path.join(this.stepDir(guideId, step.stepId), 'step.json'), stored);
|
||||
const guide = this.getGuide(guideId);
|
||||
@@ -352,4 +430,4 @@ class GuideStore {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { GuideStore };
|
||||
module.exports = { GuideStore, RevisionConflictError };
|
||||
|
||||
@@ -538,6 +538,60 @@ function normalizeOllamaHost(host) {
|
||||
return `http://${raw.replace(/\/+$/, '')}`;
|
||||
}
|
||||
|
||||
// A hostname/IP that refers to this machine only. StepForge is local-first:
|
||||
// by default the Ollama endpoint must be loopback so screenshots and text
|
||||
// never leave the device, unless the user explicitly opts into a remote host.
|
||||
function isLoopbackHost(host) {
|
||||
const normalized = normalizeOllamaHost(host);
|
||||
if (!normalized) return false;
|
||||
let url;
|
||||
try {
|
||||
url = new URL(normalized);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const name = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
if (name === 'localhost' || name === '::1' || name === '0.0.0.0' || name === '::') return true;
|
||||
// IPv4 loopback block 127.0.0.0/8.
|
||||
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(name);
|
||||
if (m && Number(m[1]) === 127 && m.slice(1).every((o) => Number(o) >= 0 && Number(o) <= 255)) {
|
||||
return true;
|
||||
}
|
||||
// IPv4-mapped IPv6 loopback, e.g. ::ffff:127.0.0.1.
|
||||
if (/^::ffff:127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(name)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a configured Ollama endpoint against the local-first policy.
|
||||
* Returns { ok, host, reason }. Remote hosts are rejected unless the caller
|
||||
* passes allowRemote: true (the explicit ai.allowRemoteHost opt-in).
|
||||
*/
|
||||
function validateOllamaHost(host, { allowRemote = false } = {}) {
|
||||
const normalized = normalizeOllamaHost(host);
|
||||
if (!normalized) return { ok: false, host: '', reason: 'No Ollama host configured.' };
|
||||
let url;
|
||||
try {
|
||||
url = new URL(normalized);
|
||||
} catch {
|
||||
return { ok: false, host: normalized, reason: 'Ollama host is not a valid URL.' };
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return { ok: false, host: normalized, reason: 'Ollama host must use http or https.' };
|
||||
}
|
||||
if (!allowRemote && !isLoopbackHost(normalized)) {
|
||||
return {
|
||||
ok: false,
|
||||
host: normalized,
|
||||
reason:
|
||||
'Remote Ollama hosts are disabled. StepForge only contacts a local (loopback) ' +
|
||||
'Ollama by default. Enable "Allow remote AI host" in AI settings to send ' +
|
||||
'screenshots and text to this host.',
|
||||
};
|
||||
}
|
||||
return { ok: true, host: normalized, reason: '' };
|
||||
}
|
||||
|
||||
function normalizeAiLevel(level) {
|
||||
const key = normalizeWhitespace(level).toLowerCase();
|
||||
return AI_LEVEL_ALIASES.get(key) || (TEXTBLOCK_LEVELS.includes(key) ? key : 'info');
|
||||
@@ -845,6 +899,8 @@ module.exports = {
|
||||
buildCaptureTitle,
|
||||
plainTextToHtml,
|
||||
normalizeOllamaHost,
|
||||
isLoopbackHost,
|
||||
validateOllamaHost,
|
||||
normalizeAiPatch,
|
||||
buildAiPrompt,
|
||||
applyAiPatchToStep,
|
||||
|
||||
@@ -121,8 +121,22 @@ function zipSync(entries, { date = new Date(2026, 0, 1) } = {}) {
|
||||
return Buffer.concat([...localParts, centralBuf, eocd]);
|
||||
}
|
||||
|
||||
/** Parse a zip buffer into [{ name, data }] with CRC verification. */
|
||||
function unzipSync(buffer) {
|
||||
// Resource limits for untrusted archives (share files, snapshots). These cap
|
||||
// memory and disk work so a ZIP bomb can't exhaust the machine. Callers that
|
||||
// build archives themselves may relax them; imports use the defaults.
|
||||
const DEFAULT_UNZIP_LIMITS = {
|
||||
maxEntries: 50000,
|
||||
maxTotalCompressed: 1024 * 1024 * 1024, // 1 GiB of stored bytes
|
||||
maxTotalUncompressed: 4 * 1024 * 1024 * 1024, // 4 GiB inflated total
|
||||
maxEntryUncompressed: 512 * 1024 * 1024, // 512 MiB per entry
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a zip buffer into [{ name, data }] with CRC verification and hard
|
||||
* resource limits. `limits` overrides DEFAULT_UNZIP_LIMITS.
|
||||
*/
|
||||
function unzipSync(buffer, { limits = {} } = {}) {
|
||||
const lim = { ...DEFAULT_UNZIP_LIMITS, ...limits };
|
||||
if (!Buffer.isBuffer(buffer) || buffer.length < 22) throw new Error('zip: too small');
|
||||
// Find end-of-central-directory record (scan backwards over the comment).
|
||||
let eocd = -1;
|
||||
@@ -132,11 +146,14 @@ function unzipSync(buffer) {
|
||||
}
|
||||
if (eocd < 0) throw new Error('zip: end record not found');
|
||||
const count = buffer.readUInt16LE(eocd + 10);
|
||||
if (count > lim.maxEntries) throw new Error(`zip: too many entries (${count} > ${lim.maxEntries})`);
|
||||
let pos = buffer.readUInt32LE(eocd + 16);
|
||||
|
||||
const entries = [];
|
||||
let totalCompressed = 0;
|
||||
let totalUncompressed = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (buffer.readUInt32LE(pos) !== 0x02014b50) throw new Error('zip: bad central header');
|
||||
if (pos + 46 > buffer.length || buffer.readUInt32LE(pos) !== 0x02014b50) throw new Error('zip: bad central header');
|
||||
const method = buffer.readUInt16LE(pos + 10);
|
||||
const crc = buffer.readUInt32LE(pos + 16);
|
||||
const compSize = buffer.readUInt32LE(pos + 20);
|
||||
@@ -151,17 +168,33 @@ function unzipSync(buffer) {
|
||||
assertSafeEntryName(name);
|
||||
if (name.endsWith('/')) continue; // directory entry
|
||||
|
||||
// Budget checks BEFORE allocating/inflating: the declared sizes are
|
||||
// attacker-controlled, so reject oversize claims up front.
|
||||
if (uncompSize > lim.maxEntryUncompressed) {
|
||||
throw new Error(`zip: entry too large (${uncompSize} > ${lim.maxEntryUncompressed}): ${name}`);
|
||||
}
|
||||
totalCompressed += compSize;
|
||||
totalUncompressed += uncompSize;
|
||||
if (totalCompressed > lim.maxTotalCompressed) throw new Error('zip: total compressed size exceeds limit');
|
||||
if (totalUncompressed > lim.maxTotalUncompressed) throw new Error('zip: total inflated size exceeds limit');
|
||||
|
||||
if (buffer.readUInt32LE(localOffset) !== 0x04034b50) throw new Error('zip: bad local header');
|
||||
const lNameLen = buffer.readUInt16LE(localOffset + 26);
|
||||
const lExtraLen = buffer.readUInt16LE(localOffset + 28);
|
||||
const dataStart = localOffset + 30 + lNameLen + lExtraLen;
|
||||
if (dataStart + compSize > buffer.length) throw new Error(`zip: entry data out of range: ${name}`);
|
||||
const raw = buffer.subarray(dataStart, dataStart + compSize);
|
||||
|
||||
let data;
|
||||
if (method === 0) data = Buffer.from(raw);
|
||||
else if (method === 8) data = zlib.inflateRawSync(raw);
|
||||
else throw new Error(`zip: unsupported method ${method} for ${name}`);
|
||||
else if (method === 8) {
|
||||
// Cap inflation so a small deflate stream can't expand to gigabytes —
|
||||
// even if the declared uncompSize lied, this is the real guard.
|
||||
data = zlib.inflateRawSync(raw, { maxOutputLength: lim.maxEntryUncompressed });
|
||||
} else throw new Error(`zip: unsupported method ${method} for ${name}`);
|
||||
|
||||
// Exact length match (not "at least"): the inflated bytes must equal the
|
||||
// declared uncompressed size, and the CRC must verify.
|
||||
if (data.length !== uncompSize) throw new Error(`zip: size mismatch for ${name}`);
|
||||
if (crc32(data) !== crc) throw new Error(`zip: CRC mismatch for ${name}`);
|
||||
entries.push({ name, data });
|
||||
@@ -170,10 +203,10 @@ function unzipSync(buffer) {
|
||||
}
|
||||
|
||||
/** Extract a zip buffer under destDir; every path is traversal-checked. */
|
||||
function extractZipSync(buffer, destDir) {
|
||||
function extractZipSync(buffer, destDir, { limits = {} } = {}) {
|
||||
const resolvedDest = path.resolve(destDir);
|
||||
const written = [];
|
||||
for (const { name, data } of unzipSync(buffer)) {
|
||||
for (const { name, data } of unzipSync(buffer, { limits })) {
|
||||
const target = path.resolve(resolvedDest, name);
|
||||
if (target !== resolvedDest && !target.startsWith(resolvedDest + path.sep)) {
|
||||
throw new Error(`zip: entry escapes destination: ${name}`);
|
||||
@@ -203,4 +236,7 @@ function zipDirSync(dir, { filter = () => true, prefix = '' } = {}) {
|
||||
return zipSync(entries);
|
||||
}
|
||||
|
||||
module.exports = { crc32, zipSync, unzipSync, extractZipSync, zipDirSync, assertSafeEntryName };
|
||||
module.exports = {
|
||||
crc32, zipSync, unzipSync, extractZipSync, zipDirSync, assertSafeEntryName,
|
||||
DEFAULT_UNZIP_LIMITS,
|
||||
};
|
||||
|
||||
@@ -111,7 +111,7 @@ click position. Three pieces make that hold:
|
||||
|
||||
1. **OS click events** (`app/capture.js`): a low-level mouse hook on Windows
|
||||
(`CLICK x y button unixMs` lines), an `xinput test-xi2 --root` watcher on
|
||||
X11. The Linux parser carries event-time `root:` coordinates and merges
|
||||
X11. The Linux (WIP) parser carries event-time `root:` coordinates and merges
|
||||
raw/regular twin blocks structurally — there is no time-based debounce
|
||||
that could drop fast clicks, only suppression of identical duplicate
|
||||
deliveries. Physical coordinates convert to DIP via
|
||||
|
||||
@@ -11,13 +11,19 @@ For the windows installation, please see [windows_installation](windows_installa
|
||||
|
||||
## 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:
|
||||
|
||||
```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
|
||||
|
||||
@@ -25,9 +31,9 @@ That installs Electron and the local packaging tools used by the scripts.
|
||||
npm start
|
||||
```
|
||||
|
||||
The first launch creates the local StepForge data directory. On Linux it is
|
||||
usually under `~/.local/share/stepforge`. On Windows it is usually under
|
||||
`%APPDATA%/stepforge`.
|
||||
The first launch creates the local StepForge data directory. On Linux (WIP)
|
||||
it is usually under `~/.local/share/stepforge`. On Windows it is usually
|
||||
under `%APPDATA%/stepforge`.
|
||||
|
||||
## 3. Create your first guide
|
||||
|
||||
@@ -95,4 +101,5 @@ If you want to find commands quickly, press `Ctrl+/` for Quick Actions.
|
||||
1. `bash scripts/build-release.sh` assembles the offline release layout.
|
||||
2. `npm run package:windows` creates the Windows installer `.exe` in
|
||||
`releases/`.
|
||||
3. `bash scripts/package-linux.sh` creates Linux release artifacts.
|
||||
3. `bash scripts/package-linux.sh` creates Linux release artifacts (WIP;
|
||||
local only).
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# Getting Started with StepForge on Linux
|
||||
|
||||
> ⚠️ **Work in progress.** Linux support is still under active development.
|
||||
> Expect rough edges — especially on Wayland (see the limitations below). X11 /
|
||||
> Xorg is the most complete path today. Please report issues.
|
||||
|
||||
StepForge was built on Windows, where the OS lets an app watch every click and
|
||||
grab the screen freely. Linux is more restrictive, and **how much works depends
|
||||
on whether you are running X11 (Xorg) or Wayland.** This guide explains the
|
||||
difference, how to get the best experience, and how to enable per-click capture.
|
||||
|
||||
## TL;DR
|
||||
|
||||
| | **X11 / "Ubuntu on Xorg"** | **Wayland (default on Ubuntu)** |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| 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).**
|
||||
|
||||
---
|
||||
|
||||
## 1. Check which session you are running
|
||||
|
||||
```bash
|
||||
echo $XDG_SESSION_TYPE
|
||||
```
|
||||
|
||||
- `x11` → you're on Xorg. Everything works, including the red click marker. No setup needed.
|
||||
- `wayland` → you're on Wayland. Read on.
|
||||
|
||||
---
|
||||
|
||||
## Option A (best experience) — use Xorg
|
||||
|
||||
On Xorg, StepForge captures a screenshot **on every click** and draws the **red
|
||||
marker** at the exact click position, exactly like Windows. No dependencies, no
|
||||
permissions, no portal dialogs.
|
||||
|
||||
To switch:
|
||||
|
||||
1. Log out.
|
||||
2. On the login (password) screen, click the **⚙ gear icon** in the bottom-right corner.
|
||||
3. Choose **"Ubuntu on Xorg"**.
|
||||
4. Log back in.
|
||||
|
||||
That's it — open StepForge and record. (To go back to Wayland later, pick
|
||||
"Ubuntu" at the gear menu again.)
|
||||
|
||||
---
|
||||
|
||||
## Option B — stay on Wayland
|
||||
|
||||
Wayland deliberately blocks apps from monitoring global input and from grabbing
|
||||
the screen silently. StepForge works around this as far as the platform allows:
|
||||
|
||||
### Screen capture
|
||||
|
||||
The first time you press **Start recording**, the system shows a **"Share your
|
||||
screen"** dialog (the XDG desktop portal). Pick your screen and click **Share**.
|
||||
This happens **once per recording session** — not per screenshot. The shared
|
||||
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 (optional, least-privilege)
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
bash scripts/linux/enable-click-capture.sh
|
||||
```
|
||||
|
||||
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
|
||||
> at the click. The screenshot is still captured per click — just without the
|
||||
> marker. If you need the marker, use [Option A (Xorg)](#option-a-best-experience--use-xorg).
|
||||
|
||||
### Adjusting the timed-capture interval
|
||||
|
||||
If you don't enable the `input` group, StepForge captures on a timer. Change the
|
||||
fallback in **Settings → Capture**:
|
||||
|
||||
- `When clicks are unavailable` -> `Hotkey only` to use the Capture hotkey
|
||||
instead of a timer.
|
||||
- `When clicks are unavailable` -> `Timed interval`, then set
|
||||
`Timer interval (seconds)` (`capture.autoIntervalSec`, default 5 seconds)
|
||||
if you want timed captures.
|
||||
|
||||
---
|
||||
|
||||
## How StepForge picks a capture method (for reference)
|
||||
|
||||
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) 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.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"It asks to share my screen every time."**
|
||||
You're likely on an older build. Update to the current version — the screen
|
||||
stream is now opened once per recording session. If it persists, confirm
|
||||
`echo $XDG_SESSION_TYPE` and that you clicked **Share** (not Cancel) in the dialog.
|
||||
|
||||
**"Recording captures a couple of steps then stops."**
|
||||
Fixed in the current version (a slow, GPU-less PNG encode used to trip a
|
||||
failure guard and tear down the stream). Update and retry.
|
||||
|
||||
**"The window disappeared and I can't stop the recording."**
|
||||
On Linux the window **minimizes** while recording (GNOME's system tray is
|
||||
unreliable). Bring it back from the **taskbar / dock**, then click **Stop
|
||||
recording**.
|
||||
|
||||
**"No steps at all on Wayland, even after picking a screen."**
|
||||
Run from a terminal with logging and look for the diagnostic lines:
|
||||
|
||||
```bash
|
||||
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 …` — 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,
|
||||
not StepForge, and don't affect recording.
|
||||
@@ -0,0 +1,65 @@
|
||||
# StepForge privacy and network contract
|
||||
|
||||
StepForge is **local-first**. Guides, screenshots, and settings live on your
|
||||
machine and are never uploaded on their own. This document describes exactly
|
||||
what data StepForge collects locally and the one situation in which data
|
||||
leaves your device.
|
||||
|
||||
## What never happens
|
||||
|
||||
- No telemetry or analytics.
|
||||
- No update checks, license checks, or "phone home".
|
||||
- No cloud storage or sync.
|
||||
- No dependency downloads at runtime (dependencies are installed only by you,
|
||||
via `npm ci`).
|
||||
|
||||
## Data StepForge collects locally
|
||||
|
||||
When you capture a step, StepForge may record, **stored only on disk in your
|
||||
data directory**, capture context to help title and describe the step:
|
||||
|
||||
- The screenshot image.
|
||||
- OCR text read from the region around your click (via the bundled Tesseract
|
||||
engine — this runs locally, it is not a network call).
|
||||
- The foreground window title and application name.
|
||||
- The accessibility label/role/value of the clicked UI element (Windows).
|
||||
- Keyboard shortcuts you pressed (for example `Ctrl+T`).
|
||||
|
||||
### Raw typed text is OFF by default
|
||||
|
||||
StepForge can additionally record the **raw printable characters** you type
|
||||
between captures. Because this can capture passwords or other secrets, it is
|
||||
**disabled by default**. It is only recorded when you explicitly enable
|
||||
`capture.captureTypedText`, and even then the characters are used only to
|
||||
title the current step and are not retained beyond it. With the setting off,
|
||||
raw characters are never read or stored (on Windows they never even leave the
|
||||
keyboard-hook process).
|
||||
|
||||
## The one outbound feature: optional AI
|
||||
|
||||
StepForge has an **optional** AI integration that generates step titles and
|
||||
descriptions with a local large-language-model runtime
|
||||
([Ollama](https://ollama.com)). It is **off by default**. When you turn it on
|
||||
and configure an endpoint:
|
||||
|
||||
- StepForge sends the step **screenshot** (only to vision-capable models, only
|
||||
when "Attach screenshots" is on, and only if within the size limit) and the
|
||||
step **text/capture context** to the configured Ollama endpoint.
|
||||
- By default the endpoint must be a **local (loopback) address** — for example
|
||||
`http://127.0.0.1:11434`. StepForge refuses to send data to a non-loopback
|
||||
host unless you explicitly enable **"Allow remote AI host"**. Enabling that
|
||||
option means your screenshots and text are sent to the remote host you
|
||||
configured; StepForge cannot control what that host does with them.
|
||||
- Every AI request has a timeout, can be cancelled (closing the guide cancels
|
||||
in-flight requests), and runs under a bounded concurrency limit.
|
||||
|
||||
## Bundled dependencies
|
||||
|
||||
Beyond the Electron desktop shell, StepForge bundles the Tesseract OCR engine
|
||||
and its English language data as production dependencies. All OCR runs locally.
|
||||
|
||||
## Where your data lives
|
||||
|
||||
- Windows: `%APPDATA%\stepforge`
|
||||
- Linux: `~/.local/share/stepforge` (or `$XDG_DATA_HOME/stepforge`)
|
||||
- Override with the `STEPFORGE_DATA_DIR` environment variable.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "stepforge",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "stepforge",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.2",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"@tesseract.js-data/eng": "^1.0.0",
|
||||
@@ -15,6 +15,9 @@
|
||||
"devDependencies": {
|
||||
"electron": "^41.7.1",
|
||||
"electron-builder": "^26.15.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/asar": {
|
||||
@@ -2137,17 +2140,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -3910,9 +3913,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.26.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz",
|
||||
"integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==",
|
||||
"version": "6.27.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
|
||||
"integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
{
|
||||
"name": "stepforge",
|
||||
"version": "0.1.0",
|
||||
"description": "Fully offline desktop tool for capturing, annotating, and exporting step-by-step guides.",
|
||||
"version": "0.3.2",
|
||||
"buildVersion": "0.3.2.1",
|
||||
"description": "Local-first desktop tool for capturing, annotating, and exporting step-by-step guides, with an optional user-configured local AI integration.",
|
||||
"main": "app/main.js",
|
||||
"author": "StepForge [email protected]",
|
||||
"license": "MPL-2.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node scripts/start-electron.js",
|
||||
"test": "node --test tests/unit/",
|
||||
"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"
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 234 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 352 B |
|
After Width: | Height: | Size: 482 B |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 611 B |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
StepForge application icon — original artwork.
|
||||
A rising staircase of three blocks (the "steps" of a step-by-step guide)
|
||||
over a rounded square, in the app's blue. No third-party assets.
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#2563eb"/>
|
||||
<stop offset="1" stop-color="#1e3a8a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="256" height="256" rx="56" fill="url(#bg)"/>
|
||||
<!-- Three ascending steps -->
|
||||
<g fill="#ffffff">
|
||||
<rect x="52" y="150" width="52" height="54" rx="8"/>
|
||||
<rect x="102" y="116" width="52" height="88" rx="8"/>
|
||||
<rect x="152" y="82" width="52" height="122" rx="8"/>
|
||||
</g>
|
||||
<!-- Capture spark on the top step -->
|
||||
<circle cx="178" cy="60" r="16" fill="#facc15"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 925 B |
@@ -0,0 +1,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"
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env sh
|
||||
# StepForge launcher installed at /usr/bin/stepforge.
|
||||
#
|
||||
# Runs the packaged Electron runtime against the installed app at
|
||||
# /opt/stepforge. It NEVER installs or repairs anything at runtime and it does
|
||||
# NOT silently disable the Chromium sandbox: an unsandboxed launch requires the
|
||||
# explicit STEPFORGE_ALLOW_NO_SANDBOX=1 opt-in (development/CI only).
|
||||
|
||||
set -eu
|
||||
|
||||
APP_DIR=/opt/stepforge
|
||||
ELECTRON="$APP_DIR/node_modules/electron/dist/electron"
|
||||
SANDBOX_HELPER="$APP_DIR/node_modules/electron/dist/chrome-sandbox"
|
||||
|
||||
if [ ! -x "$ELECTRON" ]; then
|
||||
echo "stepforge: Electron runtime missing at $ELECTRON (reinstall the package)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$APP_DIR" || exit 1
|
||||
|
||||
# Linux screen capture: enable the PipeWire path for Wayland portals; harmless
|
||||
# on X11 where Ozone auto-selects.
|
||||
COMMON_ARGS="--enable-features=WebRTCPipeWireCapturer --ozone-platform-hint=auto"
|
||||
|
||||
sandbox_ok() {
|
||||
[ -e "$SANDBOX_HELPER" ] || return 1
|
||||
helper_uid="$(stat -c '%u' "$SANDBOX_HELPER" 2>/dev/null || echo '')"
|
||||
helper_mode="$(stat -c '%a' "$SANDBOX_HELPER" 2>/dev/null || echo '')"
|
||||
[ "$helper_uid" = "0" ] || return 1
|
||||
[ -n "$helper_mode" ] || return 1
|
||||
# setuid bit set?
|
||||
[ $(( $((8#$helper_mode)) & 04000 )) -ne 0 ] || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
userns_ok() {
|
||||
# Namespaced sandbox works without the setuid helper on kernels that allow
|
||||
# unprivileged user namespaces.
|
||||
if [ -r /proc/sys/kernel/unprivileged_userns_clone ]; then
|
||||
[ "$(cat /proc/sys/kernel/unprivileged_userns_clone)" = "1" ] && return 0 || return 1
|
||||
fi
|
||||
if [ -r /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then
|
||||
[ "$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns)" = "0" ] && return 0 || return 1
|
||||
fi
|
||||
[ -e /proc/self/ns/user ] && return 0 || return 1
|
||||
}
|
||||
|
||||
if sandbox_ok || userns_ok; then
|
||||
exec "$ELECTRON" $COMMON_ARGS "$APP_DIR" "$@"
|
||||
fi
|
||||
|
||||
if [ "${STEPFORGE_ALLOW_NO_SANDBOX:-}" = "1" ] || [ "${ELECTRON_DISABLE_SANDBOX:-}" = "1" ]; then
|
||||
echo "stepforge: launching WITHOUT the Chromium sandbox (explicit opt-in)." >&2
|
||||
exec "$ELECTRON" --no-sandbox $COMMON_ARGS "$APP_DIR" "$@"
|
||||
fi
|
||||
|
||||
cat >&2 <<'MSG'
|
||||
stepforge: the Chromium sandbox is not available and StepForge will not launch
|
||||
unsandboxed by default.
|
||||
|
||||
Fix one of the following:
|
||||
* Make the setuid sandbox helper usable:
|
||||
sudo chown root:root /opt/stepforge/node_modules/electron/dist/chrome-sandbox
|
||||
sudo chmod 4755 /opt/stepforge/node_modules/electron/dist/chrome-sandbox
|
||||
* Enable unprivileged user namespaces (kernel/sysctl dependent):
|
||||
sudo sysctl -w kernel.unprivileged_userns_clone=1
|
||||
|
||||
For development/CI only you may set STEPFORGE_ALLOW_NO_SANDBOX=1 to override.
|
||||
MSG
|
||||
exit 1
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
|
||||
<mime-type type="application/x-stepforge-guide">
|
||||
<comment>StepForge guide archive</comment>
|
||||
<glob pattern="*.sfgz"/>
|
||||
<icon name="stepforge"/>
|
||||
</mime-type>
|
||||
<mime-type type="application/x-stepforge-template">
|
||||
<comment>StepForge export template</comment>
|
||||
<glob pattern="*.sfglt"/>
|
||||
<icon name="stepforge"/>
|
||||
</mime-type>
|
||||
</mime-info>
|
||||
@@ -0,0 +1,13 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=StepForge
|
||||
GenericName=Step-by-step guide capture
|
||||
Comment=Capture, annotate, and export step-by-step guides
|
||||
Exec=stepforge %U
|
||||
Icon=stepforge
|
||||
Terminal=false
|
||||
Categories=Office;Graphics;Utility;
|
||||
Keywords=documentation;screenshot;guide;capture;steps;
|
||||
StartupNotify=true
|
||||
StartupWMClass=StepForge
|
||||
MimeType=application/x-stepforge-guide;
|
||||
@@ -0,0 +1,17 @@
|
||||
Package: stepforge
|
||||
Version: @VERSION@
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Architecture: @ARCH@
|
||||
Depends: libnss3, libnspr4, libatk1.0-0, libatk-bridge2.0-0, libcups2, libgbm1, libasound2, libgtk-3-0, libxkbcommon0, libatspi2.0-0
|
||||
Recommends: xinput, x11-utils, xdg-desktop-portal, pipewire
|
||||
Maintainer: @MAINTAINER@
|
||||
Homepage: https://github.com/Twest2/StepForge
|
||||
Description: Local-first step-by-step guide capture and export tool
|
||||
StepForge captures step-by-step workflows as screenshots, lets you annotate
|
||||
and describe each step, and exports to Markdown, PDF, DOCX, PPTX, HTML, and
|
||||
more. Local-first: no telemetry, with an optional user-configured local AI
|
||||
integration.
|
||||
.
|
||||
This package bundles a fixed Electron runtime and only production
|
||||
dependencies; it does not install anything at runtime.
|
||||
@@ -0,0 +1,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"
|
||||
@@ -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"
|
||||
@@ -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).
|
||||
@@ -20,5 +20,5 @@ fi
|
||||
|
||||
node - <<'NODE'
|
||||
const pkg = require('./package.json');
|
||||
console.log(`StepForge ${pkg.version} bootstrap OK`);
|
||||
console.log(`StepForge ${pkg.buildVersion || pkg.version} bootstrap OK`);
|
||||
NODE
|
||||
|
||||
@@ -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" \
|
||||
@@ -70,6 +77,7 @@ for (const rel of walk(examplesRoot, examplesRoot)) {
|
||||
}
|
||||
|
||||
const pkg = require(path.join(rootDir, 'package.json'));
|
||||
const buildVersion = pkg.buildVersion || pkg.version;
|
||||
|
||||
const { execSync } = require('node:child_process');
|
||||
function toolAvailable(cmd) {
|
||||
@@ -88,7 +96,8 @@ const toolRows = Object.entries(tools)
|
||||
|
||||
const report = `# StepForge Build Report
|
||||
|
||||
Version: ${pkg.version}
|
||||
Build version: ${buildVersion}
|
||||
Package version: ${pkg.version}
|
||||
Generated: ${new Date().toISOString()}
|
||||
Host: ${process.platform} ${process.arch} (node ${process.version})
|
||||
|
||||
@@ -133,6 +142,7 @@ fs.writeFileSync(manifestFile, JSON.stringify({
|
||||
version: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
packageVersion: pkg.version,
|
||||
buildVersion,
|
||||
files,
|
||||
}, null, 2) + '\n');
|
||||
NODE
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -1,6 +1,14 @@
|
||||
'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 path = require('node:path');
|
||||
|
||||
@@ -60,6 +68,85 @@ function sanitizeElectronEnv(baseEnv = process.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({
|
||||
electronPath,
|
||||
platform = process.platform,
|
||||
statSync = fs.statSync,
|
||||
env = process.env,
|
||||
userNamespaces = userNamespacesAvailable,
|
||||
} = {}) {
|
||||
if (platform !== 'linux') return [];
|
||||
|
||||
// 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 {
|
||||
// Debian/Ubuntu specific knob; absent elsewhere (treated as enabled).
|
||||
const knob = '/proc/sys/kernel/unprivileged_userns_clone';
|
||||
if (fs.existsSync(knob)) {
|
||||
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 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function electronBinaryCandidates({ packageRoot, distDir, platform }) {
|
||||
const candidatePaths = [];
|
||||
const pathHint = packageRoot ? readElectronPathHint(packageRoot) : null;
|
||||
@@ -75,118 +162,21 @@ function electronBinaryCandidates({ packageRoot, distDir, platform }) {
|
||||
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 }) {
|
||||
const tried = candidatePaths.map((candidate) => ` - ${candidate}`).join('\n');
|
||||
const tried = (candidatePaths || []).map((candidate) => ` - ${candidate}`).join('\n');
|
||||
return [
|
||||
'Electron could not be started because the desktop runtime is missing.',
|
||||
'',
|
||||
`Looked under: ${packageRoot}`,
|
||||
`Expected the binary in: ${distDir}`,
|
||||
`Looked under: ${packageRoot || '(electron package not installed)'}`,
|
||||
`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 rebuild electron --force --foreground-scripts',
|
||||
' make sure ELECTRON_SKIP_BINARY_DOWNLOAD is not set',
|
||||
' npm ci',
|
||||
'',
|
||||
'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:',
|
||||
tried,
|
||||
@@ -199,99 +189,38 @@ function resolveElectronBinary({
|
||||
platform = process.platform,
|
||||
overrideDistPath = process.env.ELECTRON_OVERRIDE_DIST_PATH || null,
|
||||
} = {}) {
|
||||
const repairErrors = [];
|
||||
|
||||
function resolveCurrentPackageRoot() {
|
||||
if (packageRoot) return packageRoot;
|
||||
if (!packageRoot) {
|
||||
const conventionalRoot = path.join(projectRoot, 'node_modules', 'electron');
|
||||
if (fs.existsSync(path.join(conventionalRoot, 'package.json'))) {
|
||||
packageRoot = conventionalRoot;
|
||||
return packageRoot;
|
||||
}
|
||||
packageRoot = resolveElectronPackageRoot();
|
||||
return packageRoot;
|
||||
}
|
||||
|
||||
function tryRepair(label, repairFn) {
|
||||
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) {
|
||||
if (!packageRoot && !overrideDistPath) {
|
||||
throw new Error(
|
||||
'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');
|
||||
let candidatePaths = electronBinaryCandidates({ packageRoot: currentPackageRoot, distDir, platform });
|
||||
let resolved = candidatePaths.find((candidate) => fs.existsSync(candidate));
|
||||
const distDir = overrideDistPath || path.join(packageRoot, 'dist');
|
||||
const candidatePaths = electronBinaryCandidates({ packageRoot, distDir, platform });
|
||||
const resolved = candidatePaths.find((candidate) => fs.existsSync(candidate));
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const repairAttempts = [
|
||||
['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')}`
|
||||
: '')
|
||||
);
|
||||
throw new Error(buildMissingElectronError({ packageRoot, distDir, candidatePaths }));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildMissingElectronError,
|
||||
electronBinaryCandidates,
|
||||
readElectronPathHint,
|
||||
repairElectronInstall,
|
||||
runNpmRebuild,
|
||||
runNpmInstall,
|
||||
sanitizeElectronEnv,
|
||||
noSandboxExplicitlyAllowed,
|
||||
linuxSandboxLaunchArgs,
|
||||
resolveElectronBinary,
|
||||
resolveElectronPackageRoot,
|
||||
platformBinaryCandidates,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install the BUILD toolchain for producing StepForge packages on apt-based
|
||||
# systems. These are for DEVELOPERS/packagers only and are never shipped inside
|
||||
# the end-user package.
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v apt-get >/dev/null 2>&1; then
|
||||
echo "This script is for apt-based systems (Debian/Ubuntu)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SUDO=""
|
||||
if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
|
||||
|
||||
PACKAGES=(
|
||||
dpkg-dev fakeroot # build the .deb
|
||||
desktop-file-utils # validate the .desktop entry
|
||||
ca-certificates # npm ci over https
|
||||
xvfb # headless smoke test under Xvfb
|
||||
)
|
||||
|
||||
echo "Installing StepForge build dependencies via apt..."
|
||||
$SUDO apt-get update
|
||||
$SUDO apt-get install -y --no-install-recommends "${PACKAGES[@]}"
|
||||
|
||||
cat <<'MSG'
|
||||
Done. Also install the pinned Node toolchain (see .nvmrc — Node 22.12+):
|
||||
nvm install && nvm use # or another Node 22 LTS install method
|
||||
Then, from the repo root:
|
||||
npm ci
|
||||
npm run package:linux:deb
|
||||
MSG
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install the RUNTIME libraries StepForge needs on apt-based systems
|
||||
# (Debian/Ubuntu). These are the shared libraries the packaged Electron runtime
|
||||
# links against, plus the X11/portal integration used for capture. This is for
|
||||
# END USERS installing from the tarball; the .deb declares the same set as
|
||||
# Depends so apt pulls them automatically.
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v apt-get >/dev/null 2>&1; then
|
||||
echo "This script is for apt-based systems (Debian/Ubuntu). Use the dnf script on Fedora." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SUDO=""
|
||||
if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
|
||||
|
||||
PACKAGES=(
|
||||
# Chromium/Electron shared libraries
|
||||
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2
|
||||
libgtk-3-0 libgbm1 libasound2 libxkbcommon0 libatspi2.0-0
|
||||
libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libxshmfence1
|
||||
# X11 per-click capture (marker-accurate) — X11 sessions only
|
||||
xinput x11-utils
|
||||
# Wayland screen-share via the XDG portal + PipeWire
|
||||
xdg-desktop-portal pipewire
|
||||
)
|
||||
|
||||
echo "Installing StepForge runtime dependencies via apt..."
|
||||
$SUDO apt-get update
|
||||
$SUDO apt-get install -y --no-install-recommends "${PACKAGES[@]}"
|
||||
echo "Done. StepForge runtime dependencies are installed."
|
||||
@@ -0,0 +1,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
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// Generate the StepForge PNG icon set from original geometry using the repo's
|
||||
// own rasterizer + PNG writer (no external image tooling or third-party art).
|
||||
// Mirrors packaging/assets/stepforge.svg. Output: packaging/assets/icons/.
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createImage, fillRect, fillOval } = require('../core/raster');
|
||||
const { encodePng } = require('../core/png');
|
||||
|
||||
const OUT_DIR = path.join(__dirname, '..', 'packaging', 'assets', 'icons');
|
||||
const SIZES = [16, 32, 48, 64, 128, 256, 512];
|
||||
|
||||
const BG_TOP = [37, 99, 235, 255];
|
||||
const BG_BOTTOM = [30, 58, 138, 255];
|
||||
const WHITE = [255, 255, 255, 255];
|
||||
const SPARK = [250, 204, 21, 255];
|
||||
|
||||
function lerp(a, b, t) {
|
||||
return [
|
||||
Math.round(a[0] + (b[0] - a[0]) * t),
|
||||
Math.round(a[1] + (b[1] - a[1]) * t),
|
||||
Math.round(a[2] + (b[2] - a[2]) * t),
|
||||
255,
|
||||
];
|
||||
}
|
||||
|
||||
function renderIcon(size) {
|
||||
const img = createImage(size, size, [0, 0, 0, 0]);
|
||||
const s = size / 256; // scale from the 256px reference design
|
||||
|
||||
// Rounded-square background approximated by a vertical gradient fill.
|
||||
for (let y = 0; y < size; y += 1) {
|
||||
fillRect(img, 0, y, size, 1, lerp(BG_TOP, BG_BOTTOM, y / size));
|
||||
}
|
||||
|
||||
// Three ascending steps (x, y, w, h in reference px).
|
||||
const steps = [
|
||||
[52, 150, 52, 54],
|
||||
[102, 116, 52, 88],
|
||||
[152, 82, 52, 122],
|
||||
];
|
||||
for (const [x, y, w, h] of steps) {
|
||||
fillRect(img, Math.round(x * s), Math.round(y * s), Math.round(w * s), Math.round(h * s), WHITE);
|
||||
}
|
||||
|
||||
// Capture spark on the top step.
|
||||
const r = Math.max(2, Math.round(16 * s));
|
||||
fillOval(img, Math.round(178 * s - r), Math.round(60 * s - r), r * 2, r * 2, SPARK);
|
||||
|
||||
return img;
|
||||
}
|
||||
|
||||
function main() {
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
for (const size of SIZES) {
|
||||
const png = encodePng(renderIcon(size));
|
||||
fs.writeFileSync(path.join(OUT_DIR, `stepforge-${size}.png`), png);
|
||||
}
|
||||
// A conventional default name for the desktop entry / hicolor 256px slot.
|
||||
fs.copyFileSync(path.join(OUT_DIR, 'stepforge-256.png'), path.join(OUT_DIR, 'stepforge.png'));
|
||||
console.log(`wrote ${SIZES.length + 1} icons to ${OUT_DIR}`);
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { renderIcon, SIZES };
|
||||
@@ -1,79 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
VERSION="$(node -p "require('${ROOT_DIR}/package.json').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
|
||||
cd "$APP_DIR" || exit 1
|
||||
exec "$APP_DIR/node_modules/.bin/electron" "$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"
|
||||
@@ -10,6 +10,7 @@ const { build, Platform } = require('electron-builder');
|
||||
const ROOT_DIR = path.resolve(__dirname, '..');
|
||||
const PACKAGE_JSON = require(path.join(ROOT_DIR, 'package.json'));
|
||||
const APP_ID = 'com.stepforge.app';
|
||||
const BUILD_VERSION = PACKAGE_JSON.buildVersion || PACKAGE_JSON.version;
|
||||
|
||||
function findInstallerExe(dir) {
|
||||
if (!fs.existsSync(dir)) return null;
|
||||
@@ -32,6 +33,7 @@ function createWindowsInstallerConfig(outputDir) {
|
||||
return {
|
||||
appId: APP_ID,
|
||||
productName: 'StepForge',
|
||||
buildVersion: BUILD_VERSION,
|
||||
directories: {
|
||||
output: outputDir,
|
||||
},
|
||||
@@ -52,6 +54,8 @@ function createWindowsInstallerConfig(outputDir) {
|
||||
createDesktopShortcut: true,
|
||||
createStartMenuShortcut: true,
|
||||
shortcutName: 'StepForge',
|
||||
artifactName: '${productName} Setup ${buildVersion}.${ext}',
|
||||
include: 'build/installer.nsh',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -86,7 +90,7 @@ async function buildWindowsInstaller() {
|
||||
const releaseInstaller = path.join(releaseDir, path.basename(builtInstaller));
|
||||
fs.copyFileSync(builtInstaller, releaseInstaller);
|
||||
|
||||
console.log(`StepForge ${PACKAGE_JSON.version} Windows installer written to ${releaseInstaller}`);
|
||||
console.log(`StepForge ${BUILD_VERSION} Windows installer written to ${releaseInstaller}`);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
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) {
|
||||
const files = [];
|
||||
if (!fs.existsSync(rootDir)) return files;
|
||||
|
||||
const walk = (dir) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(full);
|
||||
continue;
|
||||
}
|
||||
if (/\.(test|spec)\.(js|mjs|cjs)$/.test(entry.name)) files.push(full);
|
||||
}
|
||||
};
|
||||
|
||||
walk(rootDir);
|
||||
files.sort((a, b) => a.localeCompare(b));
|
||||
return files;
|
||||
}
|
||||
|
||||
const root = path.join(process.cwd(), 'tests', 'unit');
|
||||
const tests = collectTestFiles(root);
|
||||
|
||||
if (!tests.length) {
|
||||
console.log('No unit test files found under tests/unit, skipping unit tests.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const result = spawnSync(process.execPath, ['--test', ...tests], { stdio: 'inherit' });
|
||||
|
||||
if (result.error) {
|
||||
console.error(result.error.message);
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 0);
|
||||
@@ -0,0 +1,52 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
function readJson(file) {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
}
|
||||
|
||||
function writeJson(file, value) {
|
||||
fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n');
|
||||
}
|
||||
|
||||
function stampVersion(rootDir, version) {
|
||||
if (!version || typeof version !== 'string') {
|
||||
throw new Error('version is required');
|
||||
}
|
||||
|
||||
const normalized = version.replace(/^v/i, '');
|
||||
const parts = normalized.split('.');
|
||||
const isFourPartBuild = parts.length === 4 && parts.every((part) => /^\d+$/.test(part));
|
||||
const packageVersion = isFourPartBuild ? parts.slice(0, 3).join('.') : normalized;
|
||||
const buildVersion = normalized;
|
||||
|
||||
const pkgPath = path.join(rootDir, 'package.json');
|
||||
const pkg = readJson(pkgPath);
|
||||
pkg.version = packageVersion;
|
||||
pkg.buildVersion = buildVersion;
|
||||
writeJson(pkgPath, pkg);
|
||||
|
||||
const lockPath = path.join(rootDir, 'package-lock.json');
|
||||
if (!fs.existsSync(lockPath)) return;
|
||||
|
||||
const lock = readJson(lockPath);
|
||||
lock.version = packageVersion;
|
||||
if (lock.packages && lock.packages['']) {
|
||||
lock.packages[''].version = packageVersion;
|
||||
}
|
||||
writeJson(lockPath, lock);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
const version = process.argv[2] || process.env.VERSION;
|
||||
stampVersion(process.cwd(), version);
|
||||
} catch (err) {
|
||||
console.error(err && err.message ? err.message : err);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { stampVersion };
|
||||
@@ -3,18 +3,39 @@
|
||||
|
||||
const { spawn } = require('node:child_process');
|
||||
|
||||
const { resolveElectronBinary, sanitizeElectronEnv } = require('./electron-launcher');
|
||||
const { assertSupportedNode } = require('./check-node-version');
|
||||
const {
|
||||
linuxSandboxLaunchArgs,
|
||||
resolveElectronBinary,
|
||||
sanitizeElectronEnv,
|
||||
} = require('./electron-launcher');
|
||||
|
||||
let electronPath;
|
||||
let sandboxArgs;
|
||||
try {
|
||||
assertSupportedNode();
|
||||
electronPath = resolveElectronBinary();
|
||||
sandboxArgs = linuxSandboxLaunchArgs({ electronPath });
|
||||
} catch (error) {
|
||||
console.error(error && error.message ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
const env = sanitizeElectronEnv();
|
||||
if (sandboxArgs.includes('--no-sandbox')) {
|
||||
console.warn(
|
||||
'[stepforge] launching WITHOUT the Chromium sandbox (explicitly allowed via ' +
|
||||
'STEPFORGE_ALLOW_NO_SANDBOX/ELECTRON_DISABLE_SANDBOX — development/CI only)'
|
||||
);
|
||||
}
|
||||
|
||||
const child = spawn(electronPath, ['.'], {
|
||||
// On Linux, prefer the native Ozone path when available and enable PipeWire-
|
||||
// based screen capture so desktopCapturer can go through the XDG Desktop
|
||||
// Portal on Wayland without affecting X11.
|
||||
const extraArgs = process.platform === 'linux'
|
||||
? ['--enable-features=WebRTCPipeWireCapturer', '--ozone-platform-hint=auto', ...sandboxArgs]
|
||||
: [];
|
||||
|
||||
const child = spawn(electronPath, [...extraArgs, '.'], {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
windowsHide: false,
|
||||
|
||||
@@ -13,14 +13,22 @@
|
||||
# arm: warmup click ignored, first armed click captured
|
||||
# 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),
|
||||
# the scenarios never print, so the check skips rather than failing CI.
|
||||
# Skip policy (kept honest on purpose): the ONLY allowed skip is the upfront
|
||||
# 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
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
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)"
|
||||
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
|
||||
set -e
|
||||
|
||||
# The self-test always prints this first line once it begins; without it the
|
||||
# app never reached the scenarios (couldn't launch / no capture environment).
|
||||
# The self-test always prints this line once the app is up (the frame source
|
||||
# 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
|
||||
echo "click capture selftest SKIPPED (no capture environment on this host)"
|
||||
exit 0
|
||||
echo "click capture selftest FAILED: the app never reached the self-test scenarios" >&2
|
||||
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
|
||||
|
||||
fail() {
|
||||
|
||||
@@ -7,6 +7,13 @@ set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
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)"
|
||||
trap 'rm -rf "$TMP_ROOT"' EXIT
|
||||
|
||||
|
||||
@@ -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"))"
|
||||
@@ -0,0 +1,265 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { makeTmpDir, rmrf } = require('./helpers');
|
||||
const { isLoopbackHost, validateOllamaHost } = require('../../core/text-intel');
|
||||
const { TextIntelService } = require('../../app/text-intel');
|
||||
const CaptureService = require('../../app/capture');
|
||||
|
||||
function makeSettings(ai = {}) {
|
||||
const data = {
|
||||
ai: {
|
||||
enabled: true,
|
||||
allowRemoteHost: false,
|
||||
attachScreenshots: true,
|
||||
timeoutMs: 60000,
|
||||
maxImageBytes: 12 * 1024 * 1024,
|
||||
ollama: { host: 'http://127.0.0.1:11434', model: 'llama3.2:1b' },
|
||||
...ai,
|
||||
ollama: { host: 'http://127.0.0.1:11434', model: 'llama3.2:1b', ...(ai.ollama || {}) },
|
||||
},
|
||||
};
|
||||
return {
|
||||
get(key) {
|
||||
return key.split('.').reduce((acc, part) => (acc == null ? undefined : acc[part]), data);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- loopback policy (pure core) -------------------------------------------
|
||||
|
||||
test('isLoopbackHost recognizes local addresses and rejects remote ones', () => {
|
||||
for (const host of [
|
||||
'http://127.0.0.1:11434',
|
||||
'127.0.0.1',
|
||||
'localhost:11434',
|
||||
'http://[::1]:11434',
|
||||
'http://127.5.6.7',
|
||||
'0.0.0.0',
|
||||
]) {
|
||||
assert.equal(isLoopbackHost(host), true, `loopback: ${host}`);
|
||||
}
|
||||
for (const host of [
|
||||
'http://10.0.0.5:11434',
|
||||
'http://192.168.1.20',
|
||||
'https://ollama.example.com',
|
||||
'http://8.8.8.8',
|
||||
'http://ollama.internal',
|
||||
]) {
|
||||
assert.equal(isLoopbackHost(host), false, `remote: ${host}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('validateOllamaHost blocks remote hosts unless explicitly allowed', () => {
|
||||
assert.equal(validateOllamaHost('http://127.0.0.1:11434').ok, true);
|
||||
const blocked = validateOllamaHost('http://10.0.0.5:11434');
|
||||
assert.equal(blocked.ok, false);
|
||||
assert.match(blocked.reason, /remote/i);
|
||||
assert.equal(validateOllamaHost('http://10.0.0.5:11434', { allowRemote: true }).ok, true);
|
||||
assert.equal(validateOllamaHost('').ok, false);
|
||||
assert.equal(validateOllamaHost('ftp://127.0.0.1').ok, false);
|
||||
});
|
||||
|
||||
// ---- host policy enforced by the service -----------------------------------
|
||||
|
||||
test('AI connection test refuses a remote host without the opt-in', async (t) => {
|
||||
const root = makeTmpDir('ai-remote-block');
|
||||
t.after(() => rmrf(root));
|
||||
let called = false;
|
||||
const service = new TextIntelService({
|
||||
store: { settingsDir: root },
|
||||
settings: makeSettings({ ollama: { host: 'http://10.0.0.5:11434', model: 'llama3.2:1b' } }),
|
||||
dataDir: root,
|
||||
fetchImpl: async () => { called = true; return { ok: true, json: async () => ({}) }; },
|
||||
});
|
||||
const result = await service.testAiConnection();
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.reason, /remote/i);
|
||||
assert.equal(called, false, 'must not contact a blocked host at all');
|
||||
});
|
||||
|
||||
test('AI connection test allows a remote host with the opt-in', async (t) => {
|
||||
const root = makeTmpDir('ai-remote-allow');
|
||||
t.after(() => rmrf(root));
|
||||
const service = new TextIntelService({
|
||||
store: { settingsDir: root },
|
||||
settings: makeSettings({
|
||||
allowRemoteHost: true,
|
||||
ollama: { host: 'http://10.0.0.5:11434', model: 'llama3.2:1b' },
|
||||
}),
|
||||
dataDir: root,
|
||||
fetchImpl: async (url) => {
|
||||
const pathname = new URL(url).pathname;
|
||||
if (pathname === '/api/tags') return { ok: true, json: async () => ({ models: [{ name: 'llama3.2:1b' }] }) };
|
||||
if (pathname === '/api/show') return { ok: true, json: async () => ({ capabilities: ['vision'] }) };
|
||||
throw new Error(`unexpected ${pathname}`);
|
||||
},
|
||||
});
|
||||
const result = await service.testAiConnection();
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.host, 'http://10.0.0.5:11434');
|
||||
});
|
||||
|
||||
// ---- request timeout / cancellation ----------------------------------------
|
||||
|
||||
test('a hung endpoint times out instead of hanging forever', async (t) => {
|
||||
const root = makeTmpDir('ai-timeout');
|
||||
t.after(() => rmrf(root));
|
||||
const service = new TextIntelService({
|
||||
store: { settingsDir: root },
|
||||
settings: makeSettings({ timeoutMs: 40 }),
|
||||
dataDir: root,
|
||||
fetchImpl: (url, init) => new Promise((resolve, reject) => {
|
||||
// Never resolves on its own; only the abort signal ends it.
|
||||
init.signal.addEventListener('abort', () => {
|
||||
const err = new Error('aborted');
|
||||
err.name = 'AbortError';
|
||||
reject(err);
|
||||
});
|
||||
}),
|
||||
});
|
||||
const result = await service.testAiConnection();
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.reason, /timed out|timeout/i);
|
||||
});
|
||||
|
||||
test('cancelInflight aborts an outstanding request', async (t) => {
|
||||
const root = makeTmpDir('ai-cancel');
|
||||
t.after(() => rmrf(root));
|
||||
const service = new TextIntelService({
|
||||
store: { settingsDir: root },
|
||||
settings: makeSettings({ timeoutMs: 60000 }),
|
||||
dataDir: root,
|
||||
fetchImpl: (url, init) => new Promise((resolve, reject) => {
|
||||
init.signal.addEventListener('abort', () => {
|
||||
const err = new Error('aborted');
|
||||
err.name = 'AbortError';
|
||||
reject(err);
|
||||
});
|
||||
}),
|
||||
});
|
||||
const pending = service.testAiConnection();
|
||||
// Give the request a tick to register in the inflight set.
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(service.inflight.size, 1);
|
||||
service.cancelInflight();
|
||||
const result = await pending;
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.reason, /cancel/i);
|
||||
});
|
||||
|
||||
// ---- typed-text capture is off by default ----------------------------------
|
||||
|
||||
function makeCaptureService(settingsOverrides = {}) {
|
||||
const settingsData = { 'capture.mode': 'fullscreen', ...settingsOverrides };
|
||||
return new CaptureService({
|
||||
store: {},
|
||||
settings: { get: (k) => (k in settingsData ? settingsData[k] : null) },
|
||||
getWindow: () => null,
|
||||
notify: () => {},
|
||||
screenApi: { getCursorScreenPoint: () => ({ x: 0, y: 0 }), getAllDisplays: () => [] },
|
||||
});
|
||||
}
|
||||
|
||||
test('raw typed text is ignored unless explicitly enabled', () => {
|
||||
const off = makeCaptureService();
|
||||
off.onKeyboardEvent('CHAR', 'h'.charCodeAt(0), Date.now());
|
||||
off.onKeyboardEvent('CHAR', 'i'.charCodeAt(0), Date.now());
|
||||
assert.equal(off.snapshotKeyContext().recentTyped, '', 'typed text must not be buffered by default');
|
||||
|
||||
const on = makeCaptureService({ 'capture.captureTypedText': true });
|
||||
on.onKeyboardEvent('CHAR', 'h'.charCodeAt(0), Date.now());
|
||||
on.onKeyboardEvent('CHAR', 'i'.charCodeAt(0), Date.now());
|
||||
assert.equal(on.snapshotKeyContext().recentTyped, 'hi');
|
||||
});
|
||||
|
||||
test('shortcut detection still works with typed text disabled', () => {
|
||||
const off = makeCaptureService();
|
||||
off.onKeyboardEvent('KEY', 'Ctrl+T', Date.now());
|
||||
assert.equal(off.snapshotKeyContext().recentShortcut, 'Ctrl+T');
|
||||
});
|
||||
|
||||
// ---- stale AI responses cannot overwrite user edits --------------------------
|
||||
|
||||
test('an AI patch built from stale data does not clobber a mid-generation user edit', async (t) => {
|
||||
const { GuideStore } = require('../../core/store');
|
||||
const root = makeTmpDir('ai-stale-write');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
const guide = store.createGuide({ title: 'G' });
|
||||
const step = store.addStep(guide.guideId, { title: 'original title' });
|
||||
|
||||
const service = new TextIntelService({
|
||||
store,
|
||||
settings: makeSettings(),
|
||||
dataDir: root,
|
||||
fetchImpl: async (url) => {
|
||||
const pathname = new URL(url).pathname;
|
||||
if (pathname === '/api/show') {
|
||||
return { ok: true, json: async () => ({ capabilities: ['completion'] }) };
|
||||
}
|
||||
if (pathname === '/api/chat') {
|
||||
// While the model "thinks", the user edits and saves the step.
|
||||
const current = store.getStep(guide.guideId, step.stepId);
|
||||
store.saveStep(guide.guideId, { ...current, title: 'user edit during generation' });
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ message: { content: JSON.stringify({ title: 'AI title from stale context' }) } }),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${pathname}`);
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.generateStepPatch({ guideId: guide.guideId, stepId: step.stepId, target: 'title' });
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.reason, /changed while AI was generating/i);
|
||||
assert.equal(store.getStep(guide.guideId, step.stepId).title, 'user edit during generation');
|
||||
});
|
||||
|
||||
test('an AI patch applies cleanly when nothing changed during generation', async (t) => {
|
||||
const { GuideStore } = require('../../core/store');
|
||||
const root = makeTmpDir('ai-clean-write');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
const guide = store.createGuide({ title: 'G' });
|
||||
const step = store.addStep(guide.guideId, { title: '' });
|
||||
|
||||
const service = new TextIntelService({
|
||||
store,
|
||||
settings: makeSettings(),
|
||||
dataDir: root,
|
||||
fetchImpl: async (url) => {
|
||||
const pathname = new URL(url).pathname;
|
||||
if (pathname === '/api/show') {
|
||||
return { ok: true, json: async () => ({ capabilities: ['completion'] }) };
|
||||
}
|
||||
if (pathname === '/api/chat') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ message: { content: JSON.stringify({ title: 'Generated title' }) } }),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${pathname}`);
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.generateStepPatch({ guideId: guide.guideId, stepId: step.stepId, target: 'title' });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(store.getStep(guide.guideId, step.stepId).title, 'Generated title');
|
||||
});
|
||||
|
||||
// ---- source-level guards ----------------------------------------------------
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const ROOT = path.resolve(__dirname, '..', '..');
|
||||
|
||||
test('the Windows keyboard hook only emits characters when opted in', () => {
|
||||
const src = fs.readFileSync(path.join(ROOT, 'app/capture.js'), 'utf8');
|
||||
// The CHAR emission must be guarded by the opt-in flag threaded into C#.
|
||||
assert.match(src, /CaptureTypedText = \$\{captureTypedText\}/);
|
||||
assert.match(src, /else if \(CaptureTypedText\) \{/);
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const CaptureService = require('../../app/capture');
|
||||
|
||||
function makeService({ settings: settingsOverrides, powerPolicy } = {}) {
|
||||
const settingsData = {
|
||||
'capture.mode': 'fullscreen',
|
||||
'capture.delayMs': 0,
|
||||
...settingsOverrides,
|
||||
};
|
||||
return new CaptureService({
|
||||
store: {},
|
||||
settings: { get: (k) => (k in settingsData ? settingsData[k] : null) },
|
||||
getWindow: () => null,
|
||||
notify: () => {},
|
||||
powerPolicy,
|
||||
screenApi: {
|
||||
getCursorScreenPoint: () => ({ x: 0, y: 0 }),
|
||||
getAllDisplays: () => [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---- region rect clamping (bug: out-of-bounds / negative drags) -------------
|
||||
|
||||
test('overlayRectToImageRect scales, clamps, and normalizes selections', () => {
|
||||
const svc = makeService();
|
||||
const display = { bounds: { x: 0, y: 0, width: 100, height: 100 } };
|
||||
const imgSize = { width: 200, height: 200 }; // 2x DPI
|
||||
|
||||
// Simple selection: display px -> image px (2x).
|
||||
assert.deepEqual(
|
||||
svc.overlayRectToImageRect({ x: 10, y: 20, w: 30, h: 40 }, display, imgSize),
|
||||
{ x: 20, y: 40, width: 60, height: 80 }
|
||||
);
|
||||
|
||||
// Negative-size drag (drawn up/left) normalizes to a positive rect.
|
||||
assert.deepEqual(
|
||||
svc.overlayRectToImageRect({ x: 40, y: 40, w: -20, h: -20 }, display, imgSize),
|
||||
{ x: 40, y: 40, width: 40, height: 40 }
|
||||
);
|
||||
|
||||
// Selection larger than the screen is clamped to the image bounds.
|
||||
const clamped = svc.overlayRectToImageRect({ x: -10, y: -10, w: 200, h: 200 }, display, imgSize);
|
||||
assert.deepEqual(clamped, { x: 0, y: 0, width: 200, height: 200 });
|
||||
|
||||
// Degenerate selections return null instead of an out-of-bounds crop.
|
||||
assert.equal(svc.overlayRectToImageRect({ x: 0, y: 0, w: 0, h: 0 }, display, imgSize), null);
|
||||
assert.equal(svc.overlayRectToImageRect({ x: 999, y: 999, w: 10, h: 10 }, display, imgSize), null);
|
||||
assert.equal(svc.overlayRectToImageRect(null, display, imgSize), null);
|
||||
});
|
||||
|
||||
// ---- power ownership follows recording state --------------------------------
|
||||
|
||||
test('the power blocker is held only while actively recording', () => {
|
||||
const calls = [];
|
||||
const powerPolicy = { setRecording: (on) => calls.push(on) };
|
||||
const svc = makeService({ powerPolicy });
|
||||
|
||||
// startSession begins PAUSED — must not hold power.
|
||||
svc.startSession('g1', { intervalSec: 0 });
|
||||
assert.deepEqual(calls, [], 'a paused new session must not start the power blocker');
|
||||
|
||||
// Resume records -> power on. (togglePause(false) arms recording.)
|
||||
svc.togglePause(false);
|
||||
assert.deepEqual(calls, [true]);
|
||||
|
||||
// Pause -> power off. This is the tray/second-instance path that used to leak.
|
||||
svc.togglePause(true);
|
||||
assert.deepEqual(calls, [true, false]);
|
||||
|
||||
// Resume again -> on, finish -> off.
|
||||
svc.togglePause(false);
|
||||
svc.finishSession();
|
||||
assert.deepEqual(calls, [true, false, true, false]);
|
||||
});
|
||||
|
||||
test('finishSession releases power even if it was recording', () => {
|
||||
const calls = [];
|
||||
const svc = makeService({ powerPolicy: { setRecording: (on) => calls.push(on) } });
|
||||
svc.startSession('g1', { intervalSec: 0 });
|
||||
svc.togglePause(false);
|
||||
calls.length = 0;
|
||||
svc.finishSession();
|
||||
assert.deepEqual(calls, [false]);
|
||||
});
|
||||
|
||||
// ---- explicit click-source reporting ----------------------------------------
|
||||
|
||||
test('click source is unavailable outside a session and after stop', () => {
|
||||
const svc = makeService();
|
||||
assert.equal(svc.state().clickSource, 'unavailable');
|
||||
assert.equal(svc.state().clickCapture, undefined); // no session -> no field
|
||||
svc.clickSource = 'evdev-x11';
|
||||
svc.stopClickWatcher();
|
||||
assert.equal(svc.clickSource, 'unavailable');
|
||||
});
|
||||
|
||||
test('state reports clickCapture true for a non-process (evdev) source', () => {
|
||||
const svc = makeService();
|
||||
svc.session = { guideId: 'g', paused: false, count: 0, intervalSec: 0 };
|
||||
// evdev has no child process; the old Boolean(clickWatcher) reported false.
|
||||
svc.clickSource = 'evdev-wayland';
|
||||
const st = svc.state();
|
||||
assert.equal(st.clickCapture, true);
|
||||
assert.equal(st.clickSource, 'evdev-wayland');
|
||||
});
|
||||
|
||||
// ---- drain never hangs quit -------------------------------------------------
|
||||
|
||||
test('drainPendingClicks resolves within the deadline even if the queue hangs', async () => {
|
||||
const svc = makeService();
|
||||
svc.clickQueue = new Promise(() => {}); // never settles
|
||||
const start = Date.now();
|
||||
await svc.drainPendingClicks(60);
|
||||
assert.ok(Date.now() - start < 1000, 'drain must not block on a hung queue');
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
test('capture worker requests the selected desktop source, not a plain camera stream', async () => {
|
||||
const scriptPath = path.join(__dirname, '../../app/renderer/capture-worker.js');
|
||||
const script = fs.readFileSync(scriptPath, 'utf8');
|
||||
|
||||
const mediaCalls = [];
|
||||
const messages = [];
|
||||
let onCommand = null;
|
||||
let resolveStreamReady;
|
||||
const streamReady = new Promise((resolve) => {
|
||||
resolveStreamReady = resolve;
|
||||
});
|
||||
|
||||
const context = {
|
||||
console: {
|
||||
error() {},
|
||||
log() {},
|
||||
warn() {},
|
||||
},
|
||||
captureWorkerBridge: {
|
||||
onCommand(fn) {
|
||||
onCommand = fn;
|
||||
},
|
||||
send(msg) {
|
||||
messages.push(msg);
|
||||
if (msg.type === 'stream-ready') resolveStreamReady();
|
||||
},
|
||||
},
|
||||
StepForgeClickFrames: {
|
||||
FrameRing: class {
|
||||
constructor() {
|
||||
this._frames = [];
|
||||
}
|
||||
|
||||
push(frame) {
|
||||
this._frames.push(frame);
|
||||
}
|
||||
|
||||
frames() {
|
||||
return [...this._frames];
|
||||
}
|
||||
|
||||
latest() {
|
||||
return this._frames[this._frames.length - 1] || null;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this._frames = [];
|
||||
}
|
||||
},
|
||||
selectFrameForClick() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
navigator: {
|
||||
mediaDevices: {
|
||||
async getUserMedia(constraints) {
|
||||
mediaCalls.push(constraints);
|
||||
return {
|
||||
getTracks() {
|
||||
return [{ stop() {} }];
|
||||
},
|
||||
};
|
||||
},
|
||||
async getDisplayMedia() {
|
||||
throw new Error('unexpected getDisplayMedia call');
|
||||
},
|
||||
},
|
||||
},
|
||||
document: {
|
||||
createElement(tag) {
|
||||
assert.equal(tag, 'video');
|
||||
return {
|
||||
muted: false,
|
||||
srcObject: null,
|
||||
readyState: 2,
|
||||
play: async () => {},
|
||||
videoWidth: 1920,
|
||||
videoHeight: 1080,
|
||||
};
|
||||
},
|
||||
},
|
||||
createImageBitmap: async () => ({ width: 1920, height: 1080, close() {} }),
|
||||
setInterval: () => 1,
|
||||
clearInterval: () => {},
|
||||
OffscreenCanvas: class {
|
||||
constructor(width, height) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
}
|
||||
|
||||
getContext() {
|
||||
return { drawImage() {} };
|
||||
}
|
||||
|
||||
async convertToBlob() {
|
||||
return {
|
||||
async arrayBuffer() {
|
||||
return new ArrayBuffer(0);
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
vm.createContext(context);
|
||||
vm.runInContext(script, context, { filename: scriptPath });
|
||||
|
||||
assert.equal(typeof onCommand, 'function', 'worker should register a command handler');
|
||||
|
||||
onCommand({
|
||||
type: 'start-stream',
|
||||
displayId: 7,
|
||||
sourceId: 'screen:1:0',
|
||||
display: {
|
||||
bounds: { width: 1920, height: 1080 },
|
||||
scaleFactor: 1,
|
||||
},
|
||||
sampleMs: 50,
|
||||
});
|
||||
await streamReady;
|
||||
|
||||
assert.equal(mediaCalls.length, 1);
|
||||
const constraints = JSON.parse(JSON.stringify(mediaCalls[0]));
|
||||
assert.deepEqual(constraints, {
|
||||
audio: false,
|
||||
video: {
|
||||
mandatory: {
|
||||
chromeMediaSource: 'desktop',
|
||||
chromeMediaSourceId: 'screen:1:0',
|
||||
minWidth: 1920,
|
||||
maxWidth: 1920,
|
||||
minHeight: 1080,
|
||||
maxHeight: 1080,
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.ok(messages.some((msg) => msg.type === 'stream-ready'));
|
||||
});
|
||||
@@ -33,6 +33,22 @@ function makeService({ settings: settingsOverrides, screenApi } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
test('XDG_SESSION_TYPE=x11 wins over a stray WAYLAND_DISPLAY value', () => {
|
||||
const prevSessionType = process.env.XDG_SESSION_TYPE;
|
||||
const prevWaylandDisplay = process.env.WAYLAND_DISPLAY;
|
||||
try {
|
||||
process.env.XDG_SESSION_TYPE = 'x11';
|
||||
process.env.WAYLAND_DISPLAY = 'wayland-0';
|
||||
const service = makeService();
|
||||
assert.equal(service.onWayland(), false);
|
||||
} finally {
|
||||
if (prevSessionType === undefined) delete process.env.XDG_SESSION_TYPE;
|
||||
else process.env.XDG_SESSION_TYPE = prevSessionType;
|
||||
if (prevWaylandDisplay === undefined) delete process.env.WAYLAND_DISPLAY;
|
||||
else process.env.WAYLAND_DISPLAY = prevWaylandDisplay;
|
||||
}
|
||||
});
|
||||
|
||||
// The raw/regular twin window plus margin: how long a test must wait for a
|
||||
// held Linux raw press to fire when no coordinate twin arrives.
|
||||
const TWIN_FLUSH_MS = 60;
|
||||
@@ -52,7 +68,9 @@ function makeFrame(name, ageMs = 0, overrides = {}) {
|
||||
// ---- fresh-shot fallback path ----------------------------------------------
|
||||
|
||||
test('click-triggered session capture uses the low-latency hide pause', async () => {
|
||||
const service = makeService();
|
||||
// The fresh-shot fallback only runs in non-strict (balanced) mode; strict
|
||||
// mode skips rather than storing a post-click shot.
|
||||
const service = makeService({ settings: { 'capture.strictClickFrames': false } });
|
||||
service.session = { guideId: 'guide-1', paused: false, count: 0, intervalSec: 0 };
|
||||
|
||||
let seenOptions = null;
|
||||
@@ -596,6 +614,7 @@ test('armRecording warms while visible, then hides and arms the session', async
|
||||
isVisible() { return this.visible; },
|
||||
isMinimized() { return false; },
|
||||
hide() { this.visible = false; },
|
||||
minimize() { this.visible = false; },
|
||||
show() { this.visible = true; },
|
||||
focus() {}, getTitle() { return 'StepForge'; },
|
||||
getBounds() { return { x: 0, y: 0, width: 800, height: 600 }; },
|
||||
@@ -637,6 +656,7 @@ test('a slow recorder start still arms within the warmup cap', async () => {
|
||||
isVisible() { return this.visible; },
|
||||
isMinimized() { return false; },
|
||||
hide() { this.visible = false; },
|
||||
minimize() { this.visible = false; },
|
||||
show() { this.visible = true; },
|
||||
focus() {}, getTitle() { return 'StepForge'; },
|
||||
getBounds() { return { x: 0, y: 0, width: 800, height: 600 }; },
|
||||
@@ -718,6 +738,27 @@ test('hook coordinates are converted physical → DIP via screenToDipPoint when
|
||||
assert.deepEqual(seen, [{ x: 640, y: 320 }]);
|
||||
});
|
||||
|
||||
test('bogus Windows screenToDipPoint results fall back to display geometry', () => {
|
||||
const service = makeService({
|
||||
screenApi: {
|
||||
screenToDipPoint: (p) => ({ x: p.x, y: p.y }), // raw physical point: wrong on scaled displays
|
||||
getAllDisplays: () => [
|
||||
{ id: 1, scaleFactor: 2, bounds: { x: 0, y: 0, width: 1440, height: 900 } },
|
||||
],
|
||||
getCursorScreenPoint: () => { throw new Error('must not fall back to a cursor read'); },
|
||||
},
|
||||
});
|
||||
service.session = { guideId: 'guide-dip-fallback', paused: false, count: 0, intervalSec: 0 };
|
||||
const seen = [];
|
||||
service.enqueueClickCapture = (clickPos) => {
|
||||
seen.push(clickPos);
|
||||
};
|
||||
|
||||
service.onOsClick(1770000000000, { x: 1500, y: 900 }, 'left');
|
||||
|
||||
assert.deepEqual(seen, [{ x: 750, y: 450 }]);
|
||||
});
|
||||
|
||||
test('without screenToDipPoint, coordinates convert via display geometry (Linux/X11)', () => {
|
||||
const service = makeService({
|
||||
screenApi: {
|
||||
@@ -746,6 +787,7 @@ test('clicks without event coordinates fall back to a live cursor read', () => {
|
||||
getAllDisplays: () => [],
|
||||
},
|
||||
});
|
||||
service._wayland = false; // X11/Windows: the live-cursor fallback applies
|
||||
service.session = { guideId: 'guide-cursor', paused: false, count: 0, intervalSec: 0 };
|
||||
const seen = [];
|
||||
service.enqueueClickCapture = (clickPos) => {
|
||||
@@ -757,6 +799,64 @@ test('clicks without event coordinates fall back to a live cursor read', () => {
|
||||
assert.deepEqual(seen, [{ x: 11, y: 22 }]);
|
||||
});
|
||||
|
||||
test('on Wayland an evdev click does not stamp a bogus top-left cursor position', () => {
|
||||
// Wayland's getCursorScreenPoint returns {0,0}; a fallback read would mark
|
||||
// every click in the corner. The click must capture with a null position
|
||||
// (no marker) instead.
|
||||
const service = makeService({
|
||||
screenApi: {
|
||||
getCursorScreenPoint: () => ({ x: 0, y: 0 }),
|
||||
getAllDisplays: () => [],
|
||||
},
|
||||
});
|
||||
service._wayland = true;
|
||||
service.session = { guideId: 'guide-wl-click', paused: false, count: 0, intervalSec: 0 };
|
||||
const seen = [];
|
||||
service.enqueueClickCapture = (clickPos) => { seen.push(clickPos); };
|
||||
|
||||
service.onOsClick(1770000000000, null, 'button-1');
|
||||
|
||||
assert.deepEqual(seen, [null], 'no position is passed, so no marker is drawn');
|
||||
});
|
||||
|
||||
// ---- evdev decoding ---------------------------------------------------------------
|
||||
|
||||
const { decodeEvdevButtonPresses } = CaptureService;
|
||||
|
||||
// Pack a 64-bit struct input_event (24 bytes): 16-byte timeval, u16 type,
|
||||
// u16 code, s32 value.
|
||||
function evdevRecord(type, code, value) {
|
||||
const b = Buffer.alloc(24);
|
||||
b.writeUInt16LE(type, 16);
|
||||
b.writeUInt16LE(code, 18);
|
||||
b.writeInt32LE(value, 20);
|
||||
return b;
|
||||
}
|
||||
|
||||
test('evdev decoder extracts left/right/middle button presses, ignores the rest', () => {
|
||||
const EV_KEY = 0x01;
|
||||
const EV_REL = 0x02;
|
||||
const buf = Buffer.concat([
|
||||
evdevRecord(EV_KEY, 272, 1), // BTN_LEFT down -> button-1
|
||||
evdevRecord(EV_KEY, 272, 0), // BTN_LEFT up -> ignored (release)
|
||||
evdevRecord(EV_REL, 0, 5), // motion -> ignored (not a key)
|
||||
evdevRecord(EV_KEY, 273, 1), // BTN_RIGHT down -> button-3
|
||||
evdevRecord(EV_KEY, 274, 1), // BTN_MIDDLE down-> button-2
|
||||
evdevRecord(EV_KEY, 0x110 + 8, 1), // a non-mouse key -> ignored
|
||||
]);
|
||||
const { presses, rest } = decodeEvdevButtonPresses(buf, 24);
|
||||
assert.deepEqual(presses, ['button-1', 'button-3', 'button-2']);
|
||||
assert.equal(rest.length, 0);
|
||||
});
|
||||
|
||||
test('evdev decoder keeps a trailing partial record for the next chunk', () => {
|
||||
const full = evdevRecord(0x01, 272, 1);
|
||||
const buf = Buffer.concat([full, full.subarray(0, 10)]); // 1.4 records
|
||||
const { presses, rest } = decodeEvdevButtonPresses(buf, 24);
|
||||
assert.deepEqual(presses, ['button-1']);
|
||||
assert.equal(rest.length, 10, 'the half record is returned to be completed later');
|
||||
});
|
||||
|
||||
// ---- watcher loss -----------------------------------------------------------------
|
||||
|
||||
test('losing the click watcher mid-session falls back to interval capture', () => {
|
||||
@@ -779,6 +879,49 @@ test('losing the click watcher mid-session falls back to interval capture', () =
|
||||
}
|
||||
});
|
||||
|
||||
test('losing the click watcher mid-session can fall back to hotkey-only', () => {
|
||||
const service = makeService();
|
||||
service.settings.get = (key) => {
|
||||
if (key === 'capture.fallbackTrigger') return 'hotkey';
|
||||
if (key === 'capture.autoIntervalSec') return 3;
|
||||
return null;
|
||||
};
|
||||
service.session = { guideId: 'guide-loss-hotkey', paused: false, count: 0, intervalSec: 0 };
|
||||
const states = [];
|
||||
service.notify = (channel, payload) => {
|
||||
states.push({ channel, payload });
|
||||
};
|
||||
|
||||
try {
|
||||
service.handleClickWatcherLoss('exited with code 1');
|
||||
|
||||
assert.equal(service.session.intervalSec, 0,
|
||||
'hotkey fallback must not silently start the 5-second timer');
|
||||
assert.equal(service.intervalTimer, null);
|
||||
assert.ok(states.some((s) => s.channel === 'capture:state'));
|
||||
} finally {
|
||||
service.finishSession();
|
||||
}
|
||||
});
|
||||
|
||||
test('starting a session with hotkey fallback keeps the timer off when clicks are unavailable', () => {
|
||||
const service = makeService();
|
||||
service.clickCaptureAvailable = () => false;
|
||||
service.settings.get = (key) => {
|
||||
if (key === 'capture.fallbackTrigger') return 'hotkey';
|
||||
if (key === 'capture.autoIntervalSec') return 7;
|
||||
if (key === 'capture.captureOutsideClicks') return false;
|
||||
return null;
|
||||
};
|
||||
|
||||
service.startSession('guide-hotkey-fallback');
|
||||
|
||||
assert.equal(service.session.intervalSec, 0);
|
||||
assert.equal(service.state().clickCaptureAvailable, false);
|
||||
assert.equal(service.state().clickCapture, false);
|
||||
service.finishSession();
|
||||
});
|
||||
|
||||
// ---- strict frame selection -----------------------------------------------------
|
||||
|
||||
test('a click is served instantly from the freshly buffered frame', async () => {
|
||||
@@ -865,7 +1008,9 @@ test('a buffered frame from a different display is ignored for click capture', a
|
||||
});
|
||||
|
||||
test('a stale buffered frame is not reused — the click falls back to a fresh shot', async () => {
|
||||
const service = makeService();
|
||||
// Balanced (non-strict) mode: a stale frame is rejected and the click takes
|
||||
// the fresh-shot fallback. (Strict mode skips instead — see below.)
|
||||
const service = makeService({ settings: { 'capture.strictClickFrames': false } });
|
||||
service.session = { guideId: 'guide-stale', paused: false, count: 0, intervalSec: 0 };
|
||||
service.latestFrame = makeFrame('stale-png', 10_000);
|
||||
|
||||
@@ -881,12 +1026,12 @@ test('a stale buffered frame is not reused — the click falls back to a fresh s
|
||||
assert.equal(shootCalled, true, 'a stale buffered frame must not be reused');
|
||||
});
|
||||
|
||||
test('strict mode: a frame whose grab started after the click is rejected', async () => {
|
||||
// This replaces the old "idle click waits for the imminent loop frame"
|
||||
// behavior: a grab that begins after the click can already show the
|
||||
// click's effects, so strict mode takes the explicit fresh-shot fallback
|
||||
// instead of passing it off as the click-time screen.
|
||||
const service = makeService();
|
||||
test('strict mode: no pre-click frame is skipped, never stored as a post-click shot', async () => {
|
||||
// A grab that begins after the click can already show the click's effects.
|
||||
// Strict mode's promise is that a stored step never shows the post-click
|
||||
// screen, so when no pre-click frame qualifies it SKIPS with a diagnostic
|
||||
// rather than taking a fresh (post-click) shot.
|
||||
const service = makeService(); // strict is the default
|
||||
service.session = { guideId: 'guide-strict', paused: false, count: 0, intervalSec: 0 };
|
||||
service.frameLoopRunning = true;
|
||||
service.frameLoopInFlight = false; // nothing in flight at click time
|
||||
@@ -900,11 +1045,18 @@ test('strict mode: a frame whose grab started after the click is rejected', asyn
|
||||
shootCalled = true;
|
||||
return { ok: true, step: { stepId: 'fresh-step' } };
|
||||
};
|
||||
const diagnostics = [];
|
||||
service.notify = (channel, payload) => {
|
||||
if (channel === 'capture:diagnostic') diagnostics.push(payload);
|
||||
};
|
||||
|
||||
const result = await service.sessionCapture('click', { x: 1, y: 1 }, { at: clickAt });
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(shootCalled, true);
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.reason, /strict/i);
|
||||
assert.equal(shootCalled, false, 'strict mode must not store a post-click shot');
|
||||
assert.equal(diagnostics.length, 1);
|
||||
assert.equal(diagnostics[0].kind, 'strict-click-skipped');
|
||||
});
|
||||
|
||||
test('balanced mode keeps the legacy slack: an imminent post-click frame is accepted', async () => {
|
||||
@@ -1004,6 +1156,7 @@ test('clicks during an in-flight pre-click grab wait for the frame instead of be
|
||||
|
||||
test('click frames come from the stream backend when it is active', async () => {
|
||||
const service = makeService();
|
||||
service._wayland = false; // X11/Windows: click frame-requests are failable
|
||||
const clickAt = Date.now();
|
||||
service.session = { guideId: 'guide-stream', paused: false, count: 0, intervalSec: 0 };
|
||||
const requests = [];
|
||||
@@ -1036,12 +1189,14 @@ test('click frames come from the stream backend when it is active', async () =>
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(added, ['stream-frame']);
|
||||
assert.deepEqual(requests, [{ clickPos: { x: 10, y: 10 }, clickAt, strict: true, leadMs: 0 }],
|
||||
'the worker receives the hook-time click timestamp, strictness, and lead');
|
||||
assert.deepEqual(requests, [{ clickPos: { x: 10, y: 10 }, clickAt, strict: true, leadMs: 0, failable: true }],
|
||||
'the worker receives the hook-time click timestamp, strictness, lead, and failability');
|
||||
});
|
||||
|
||||
test('a stream backend with no qualifying frame falls through to the fresh-shot path', async () => {
|
||||
const service = makeService();
|
||||
// Balanced (non-strict) mode: the fresh-shot fallback runs. Strict mode
|
||||
// would skip rather than store a post-click shot.
|
||||
const service = makeService({ settings: { 'capture.strictClickFrames': false } });
|
||||
service.session = { guideId: 'guide-stream-miss', paused: false, count: 0, intervalSec: 0 };
|
||||
service.streamBackend = {
|
||||
isActive: () => true,
|
||||
@@ -1080,6 +1235,9 @@ test('an unhealthy stream backend degrades to the in-process frame loop', () =>
|
||||
const service = makeService();
|
||||
service.session = { guideId: 'guide-degrade', paused: false, count: 0, intervalSec: 0 };
|
||||
service.streamBackend = { isActive: () => true, stop: () => {} };
|
||||
// Off Wayland the in-process loop is viable (getSources works), so an
|
||||
// unhealthy worker must degrade to the loop rather than silently stopping.
|
||||
service._wayland = false;
|
||||
let loopStarted = false;
|
||||
service.startFrameLoop = () => { loopStarted = true; };
|
||||
const states = [];
|
||||
@@ -1092,6 +1250,24 @@ test('an unhealthy stream backend degrades to the in-process frame loop', () =>
|
||||
assert.ok(states.includes('capture:state'));
|
||||
});
|
||||
|
||||
test('an unhealthy stream backend does NOT start the frame loop on Wayland', () => {
|
||||
// On Wayland the 200ms getSources() frame loop is broken (throws) and pops
|
||||
// the portal, so it's not a usable fallback — the portal stream is the only
|
||||
// capture path and the user must re-share if it stops.
|
||||
const service = makeService();
|
||||
service.session = { guideId: 'guide-degrade-wl', paused: false, count: 0, intervalSec: 0 };
|
||||
service.streamBackend = { isActive: () => true, stop: () => {} };
|
||||
service._wayland = true;
|
||||
let loopStarted = false;
|
||||
service.startFrameLoop = () => { loopStarted = true; };
|
||||
service.notify = () => {};
|
||||
|
||||
service.degradeToFrameLoop();
|
||||
|
||||
assert.equal(service.streamBackend, null);
|
||||
assert.equal(loopStarted, false, 'no frame loop on Wayland — getSources is unusable there');
|
||||
});
|
||||
|
||||
test('session state reports which frame recorder is serving clicks', () => {
|
||||
const service = makeService();
|
||||
service.session = { guideId: 'guide-state', paused: false, count: 0, intervalSec: 0 };
|
||||
@@ -1116,6 +1292,8 @@ test('click capture marks the click-time cursor position', async () => {
|
||||
if (key === 'capture.clickMarker') return true;
|
||||
if (key === 'capture.clickMarkerColor') return '#E5484D';
|
||||
if (key === 'editor.focusedViewDefaultForNewSteps') return false;
|
||||
// Exercise the fresh-shot fallback: strict mode would skip instead.
|
||||
if (key === 'capture.strictClickFrames') return false;
|
||||
return null;
|
||||
};
|
||||
service.session = { guideId: 'guide-4', paused: false, count: 0, intervalSec: 0 };
|
||||
@@ -1156,7 +1334,12 @@ test('a new session starts paused and does not hide the window until "Start reco
|
||||
isDestroyed() { return this.destroyed; },
|
||||
isVisible() { return this.visible; },
|
||||
isMinimized() { return this.minimized; },
|
||||
// The window is "tucked away" for recording either by hide() (with a tray)
|
||||
// or minimize() (Linux without a tray). Both make it not visible; assert on
|
||||
// visibility so the test is independent of which mechanism the platform picks.
|
||||
hide() { this.visible = false; this.hidden += 1; },
|
||||
minimize() { this.visible = false; this.minimized = true; },
|
||||
restore() { this.minimized = false; this.visible = true; },
|
||||
show() { this.visible = true; this.shown += 1; },
|
||||
showInactive() { this.visible = true; this.shown += 1; },
|
||||
focus() {},
|
||||
@@ -1171,15 +1354,15 @@ test('a new session starts paused and does not hide the window until "Start reco
|
||||
|
||||
assert.equal(service.session.paused, true, 'sessions start paused');
|
||||
assert.equal(service.state().paused, true);
|
||||
assert.equal(win.hidden, 0, 'window must stay visible until recording starts');
|
||||
assert.equal(win.visible, true, 'window must stay visible until recording starts');
|
||||
|
||||
// User clicks "Start recording" (the resume action).
|
||||
service.togglePause(false);
|
||||
assert.equal(service.session.paused, false);
|
||||
assert.equal(win.hidden, 0, 'hide is deferred until the resume path runs');
|
||||
assert.equal(win.visible, true, 'tuck-away is deferred until the resume path runs');
|
||||
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
assert.equal(win.hidden, 1, 'window hides once recording actually starts');
|
||||
assert.equal(win.visible, false, 'window is tucked away once recording actually starts');
|
||||
} finally {
|
||||
service.finishSession();
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@ const path = require('node:path');
|
||||
|
||||
const {
|
||||
buildMissingElectronError,
|
||||
repairElectronInstall,
|
||||
linuxSandboxLaunchArgs,
|
||||
noSandboxExplicitlyAllowed,
|
||||
resolveElectronBinary,
|
||||
} = require('../../scripts/electron-launcher');
|
||||
const { checkNodeVersion, assertSupportedNode } = require('../../scripts/check-node-version');
|
||||
const { makeTmpDir, rmrf } = require('./helpers');
|
||||
|
||||
test('resolves the Electron binary from path.txt when present', (t) => {
|
||||
@@ -39,135 +41,119 @@ test('falls back to the platform binary when path.txt is absent', (t) => {
|
||||
);
|
||||
});
|
||||
|
||||
test('repairs a broken Electron install before resolving the binary', (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) => {
|
||||
test('never runs npm when the runtime is missing: fails with npm ci diagnostics', (t) => {
|
||||
const root = makeTmpDir('electron-missing');
|
||||
t.after(() => rmrf(root));
|
||||
|
||||
fs.mkdirSync(path.join(root, 'dist'), { recursive: true });
|
||||
|
||||
assert.throws(
|
||||
() => resolveElectronBinary({ packageRoot: root, platform: 'win32' }),
|
||||
/npm install/
|
||||
// Point npm_execpath at a script that would create the binary if executed.
|
||||
// The launcher must NOT execute it: runtime self-repair is forbidden.
|
||||
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({
|
||||
packageRoot: root,
|
||||
distDir: path.join(root, 'dist'),
|
||||
candidatePaths: [path.join(root, 'dist', 'electron.exe')],
|
||||
});
|
||||
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('refuses an unsandboxed Linux launch unless explicitly allowed', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
linuxSandboxLaunchArgs({
|
||||
electronPath: '/tmp/stepforge/node_modules/electron/dist/electron',
|
||||
platform: 'linux',
|
||||
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', () => {
|
||||
const args = linuxSandboxLaunchArgs({
|
||||
electronPath: '/tmp/stepforge/node_modules/electron/dist/electron',
|
||||
platform: 'linux',
|
||||
statSync: () => ({ uid: 0, mode: 0o104755 }),
|
||||
env: {},
|
||||
});
|
||||
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/
|
||||
);
|
||||
});
|
||||
|
||||
@@ -21,6 +21,9 @@ test('Windows packaging uses an assisted NSIS installer', (t) => {
|
||||
assert.equal(config.nsis.createDesktopShortcut, true);
|
||||
assert.equal(config.nsis.createStartMenuShortcut, true);
|
||||
assert.equal(config.nsis.shortcutName, 'StepForge');
|
||||
assert.equal(config.buildVersion, '0.3.2.1');
|
||||
assert.equal(config.nsis.artifactName, '${productName} Setup ${buildVersion}.${ext}');
|
||||
assert.equal(config.nsis.include, 'build/installer.nsh');
|
||||
assert.equal(config.asar, true);
|
||||
assert.ok(config.files.includes('app/**/*'));
|
||||
assert.ok(config.files.includes('core/**/*'));
|
||||
@@ -34,7 +37,7 @@ test('Windows packaging uses an assisted NSIS installer', (t) => {
|
||||
const tmp = makeTmpDir('windows-installer');
|
||||
t.after(() => rmrf(tmp));
|
||||
fs.mkdirSync(path.join(tmp, 'nested', 'deeper'), { recursive: true });
|
||||
const installer = path.join(tmp, 'nested', 'deeper', 'StepForge Setup 0.2.0.exe');
|
||||
const installer = path.join(tmp, 'nested', 'deeper', 'StepForge Setup 0.3.2.1.exe');
|
||||
fs.writeFileSync(installer, Buffer.from('fake installer'));
|
||||
assert.equal(findInstallerExe(tmp), installer);
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -65,6 +65,7 @@ test('settings persist, deep-merge with defaults, and store global placeholders'
|
||||
assert.equal(s2.get('capture.delayMs'), 1500);
|
||||
assert.equal(s2.get('ai.ollama.model'), 'qwen3:0.6b');
|
||||
assert.equal(s2.get('capture.clickMarker'), DEFAULT_SETTINGS.capture.clickMarker);
|
||||
assert.equal(s2.get('capture.fallbackTrigger'), DEFAULT_SETTINGS.capture.fallbackTrigger);
|
||||
assert.deepEqual(s2.getGlobalPlaceholders(), { Company: 'Acme', Author: 'Tyler' });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { detectPlatform, createWindowContextProvider, detectCapabilities } = require('../../app/platform');
|
||||
const { assertWindowContextProvider, CLICK_SOURCES } = require('../../app/platform/interfaces');
|
||||
const { detectLinuxCapabilities, detectSessionType } = require('../../app/platform/linux/diagnostics');
|
||||
|
||||
// ---- platform selection -----------------------------------------------------
|
||||
|
||||
test('detectPlatform maps process.platform to an adapter family', () => {
|
||||
assert.equal(detectPlatform('win32'), 'windows');
|
||||
assert.equal(detectPlatform('darwin'), 'darwin');
|
||||
assert.equal(detectPlatform('linux'), 'linux');
|
||||
assert.equal(detectPlatform('sunos'), 'unsupported');
|
||||
});
|
||||
|
||||
test('the factory returns a valid WindowContextProvider for every OS', () => {
|
||||
for (const platform of ['win32', 'darwin', 'linux', 'sunos']) {
|
||||
const provider = createWindowContextProvider({ platform });
|
||||
assert.doesNotThrow(() => assertWindowContextProvider(provider));
|
||||
assert.equal(typeof provider.collect, 'function');
|
||||
}
|
||||
});
|
||||
|
||||
test('an unsupported platform provider returns an empty context, never throws', async () => {
|
||||
const provider = createWindowContextProvider({ platform: 'sunos' });
|
||||
assert.deepEqual(await provider.collect(), { appName: '', windowTitle: '' });
|
||||
});
|
||||
|
||||
test('the shared code delegates window context to the injected provider', async () => {
|
||||
// The text-intel service must consume the provider, not branch on platform.
|
||||
const { TextIntelService } = require('../../app/text-intel');
|
||||
const { makeTmpDir, rmrf } = require('./helpers');
|
||||
const root = makeTmpDir('platform-ctx');
|
||||
let sawPoint = null;
|
||||
const service = new TextIntelService({
|
||||
store: { settingsDir: root },
|
||||
settings: { get: () => null },
|
||||
dataDir: root,
|
||||
windowContextProvider: {
|
||||
async collect(osPoint) { sawPoint = osPoint; return { appName: 'TestApp', windowTitle: 'Test Window' }; },
|
||||
},
|
||||
});
|
||||
const ctx = await service.collectForegroundWindowContext({ x: 5, y: 6 });
|
||||
assert.deepEqual(ctx, { appName: 'TestApp', windowTitle: 'Test Window' });
|
||||
assert.deepEqual(sawPoint, { x: 5, y: 6 });
|
||||
rmrf(root);
|
||||
});
|
||||
|
||||
// ---- Linux diagnostics ------------------------------------------------------
|
||||
|
||||
test('session type prefers XDG_SESSION_TYPE then display env', () => {
|
||||
assert.equal(detectSessionType({ XDG_SESSION_TYPE: 'wayland' }), 'wayland');
|
||||
assert.equal(detectSessionType({ XDG_SESSION_TYPE: 'x11' }), 'x11');
|
||||
assert.equal(detectSessionType({ WAYLAND_DISPLAY: 'wayland-0' }), 'wayland');
|
||||
assert.equal(detectSessionType({ DISPLAY: ':0' }), 'x11');
|
||||
assert.equal(detectSessionType({}), 'unknown');
|
||||
});
|
||||
|
||||
test('X11 with xinput reports marker-capable per-click capture', () => {
|
||||
const caps = detectLinuxCapabilities({
|
||||
env: { XDG_SESSION_TYPE: 'x11', DISPLAY: ':0', DBUS_SESSION_BUS_ADDRESS: 'unix:x' },
|
||||
hasBinary: (n) => n === 'xinput' || n === 'xprop',
|
||||
existsSync: () => false,
|
||||
readdirSync: () => [],
|
||||
});
|
||||
assert.equal(caps.isWayland, false);
|
||||
assert.equal(caps.clickCapture, 'x11-xinput');
|
||||
assert.equal(caps.screenCapture, 'x11-direct');
|
||||
});
|
||||
|
||||
test('Wayland without PipeWire reports an actionable message', () => {
|
||||
const caps = detectLinuxCapabilities({
|
||||
env: { XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0' },
|
||||
hasBinary: () => false,
|
||||
existsSync: () => false,
|
||||
readdirSync: () => [],
|
||||
});
|
||||
assert.equal(caps.isWayland, true);
|
||||
assert.equal(caps.screenCapture, 'wayland-portal');
|
||||
assert.ok(caps.messages.some((m) => /PipeWire|portal/i.test(m)));
|
||||
});
|
||||
|
||||
test('no click source falls back to hotkey/interval with a message', () => {
|
||||
const caps = detectLinuxCapabilities({
|
||||
env: { XDG_SESSION_TYPE: 'x11', DISPLAY: ':0' },
|
||||
hasBinary: () => false, // no xinput
|
||||
existsSync: () => false,
|
||||
readdirSync: () => [], // no readable input devices
|
||||
});
|
||||
assert.equal(caps.clickCapture, 'hotkey-or-interval-only');
|
||||
assert.ok(caps.messages.some((m) => /hotkey|interval/i.test(m)));
|
||||
});
|
||||
|
||||
test('readable evdev devices enable an evdev click source', () => {
|
||||
const caps = detectLinuxCapabilities({
|
||||
env: { XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0' },
|
||||
hasBinary: (n) => n === 'pipewire',
|
||||
existsSync: () => true,
|
||||
readdirSync: () => ['event0', 'event1', 'mouse0'],
|
||||
});
|
||||
// event0/event1 are readable (accessSync is real, but /dev/input/eventN
|
||||
// likely won't exist in CI; the profile still resolves without throwing).
|
||||
assert.ok(['evdev-wayland', 'hotkey-or-interval-only'].includes(caps.clickCapture));
|
||||
assert.equal(caps.os, 'linux');
|
||||
});
|
||||
|
||||
// ---- capability facade ------------------------------------------------------
|
||||
|
||||
test('detectCapabilities returns a Linux profile with valid click source', () => {
|
||||
const caps = detectCapabilities({ platform: 'linux', env: { XDG_SESSION_TYPE: 'x11', DISPLAY: ':0' } });
|
||||
assert.equal(caps.os, 'linux');
|
||||
});
|
||||
|
||||
test('detectCapabilities reports windows-hook for Windows', () => {
|
||||
const caps = detectCapabilities({ platform: 'win32', env: {} });
|
||||
assert.equal(caps.os, 'windows');
|
||||
assert.equal(caps.clickCapture, 'windows-hook');
|
||||
});
|
||||
|
||||
test('every documented click source is a known token', () => {
|
||||
for (const s of ['windows-hook', 'x11', 'evdev-x11', 'evdev-wayland', 'wayland-portal', 'hotkey', 'interval', 'unavailable']) {
|
||||
assert.ok(CLICK_SOURCES.includes(s));
|
||||
}
|
||||
});
|
||||
|
||||
// ---- refactor guard ---------------------------------------------------------
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
test('text-intel no longer branches on process.platform for window context', () => {
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'app', 'text-intel.js'), 'utf8');
|
||||
assert.doesNotMatch(src, /collectWindowsWindowContext|collectMacWindowContext|collectLinuxWindowContext/);
|
||||
assert.doesNotMatch(src, /process\.platform === 'win32'/);
|
||||
assert.match(src, /this\.windowContext\.collect/);
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const zlib = require('node:zlib');
|
||||
|
||||
const { GuideStore } = require('../../core/store');
|
||||
const { SearchIndex } = require('../../core/search');
|
||||
const { unzipSync, zipSync, crc32 } = require('../../core/zip');
|
||||
const { exportGuideArchive, importGuideArchive } = require('../../core/archive');
|
||||
const { createSnapshot, restoreSnapshot, autoSnapshotIfDue } = require('../../core/snapshots');
|
||||
const { acquireLock, releaseLock } = require('../../core/locks');
|
||||
const { makeTmpDir, rmrf, TINY_PNG } = require('./helpers');
|
||||
|
||||
// ---- ZIP bomb / resource limits ---------------------------------------------
|
||||
|
||||
test('unzip rejects an archive that declares too many entries', () => {
|
||||
const many = [];
|
||||
for (let i = 0; i < 20; i += 1) many.push({ name: `f${i}.txt`, data: 'x' });
|
||||
const buf = zipSync(many);
|
||||
assert.throws(() => unzipSync(buf, { limits: { maxEntries: 5 } }), /too many entries/);
|
||||
});
|
||||
|
||||
test('unzip rejects an entry whose declared size exceeds the per-entry limit', () => {
|
||||
const buf = zipSync([{ name: 'big.txt', data: Buffer.alloc(1000, 65) }]);
|
||||
assert.throws(() => unzipSync(buf, { limits: { maxEntryUncompressed: 100 } }), /entry too large/);
|
||||
});
|
||||
|
||||
test('unzip caps inflation so a deflate bomb cannot exhaust memory', () => {
|
||||
// A hand-built entry whose deflate stream expands far past the cap. The
|
||||
// maxOutputLength guard must abort inflation rather than allocating it all.
|
||||
const bomb = Buffer.alloc(10 * 1024 * 1024, 0); // 10 MiB of zeros -> tiny deflate
|
||||
const raw = zlib.deflateRawSync(bomb, { level: 9 });
|
||||
const name = 'bomb';
|
||||
const nameBuf = Buffer.from(name);
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(20, 4);
|
||||
local.writeUInt16LE(0x0800, 6);
|
||||
local.writeUInt16LE(8, 8); // deflate
|
||||
local.writeUInt32LE(crc32(bomb), 14);
|
||||
local.writeUInt32LE(raw.length, 18);
|
||||
local.writeUInt32LE(bomb.length, 22);
|
||||
local.writeUInt16LE(nameBuf.length, 26);
|
||||
const central = Buffer.alloc(46);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(20, 4);
|
||||
central.writeUInt16LE(20, 6);
|
||||
central.writeUInt16LE(0x0800, 8);
|
||||
central.writeUInt16LE(8, 10);
|
||||
central.writeUInt32LE(crc32(bomb), 16);
|
||||
central.writeUInt32LE(raw.length, 20);
|
||||
central.writeUInt32LE(bomb.length, 24);
|
||||
central.writeUInt16LE(nameBuf.length, 28);
|
||||
central.writeUInt32LE(0, 42);
|
||||
const localBlock = Buffer.concat([local, nameBuf, raw]);
|
||||
const centralBlock = Buffer.concat([central, nameBuf]);
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0);
|
||||
eocd.writeUInt16LE(1, 8);
|
||||
eocd.writeUInt16LE(1, 10);
|
||||
eocd.writeUInt32LE(centralBlock.length, 12);
|
||||
eocd.writeUInt32LE(localBlock.length, 16);
|
||||
const buf = Buffer.concat([localBlock, centralBlock, eocd]);
|
||||
|
||||
assert.throws(() => unzipSync(buf, { limits: { maxEntryUncompressed: 64 * 1024 } }));
|
||||
});
|
||||
|
||||
// ---- transactional archive import -------------------------------------------
|
||||
|
||||
test('a corrupt step aborts the import leaving no partial guide', (t) => {
|
||||
const root = makeTmpDir('import-atomic');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
|
||||
const guide = store.createGuide({ title: 'Src' });
|
||||
store.addStep(guide.guideId, { title: 'S1' }, TINY_PNG, { width: 1, height: 1 });
|
||||
const archiveFile = path.join(root, 'out.sfgz');
|
||||
exportGuideArchive(store, guide.guideId, archiveFile);
|
||||
|
||||
// Corrupt the exported step so validation fails during import.
|
||||
const { unzipSync: uz } = require('../../core/zip');
|
||||
const entries = uz(fs.readFileSync(archiveFile));
|
||||
const tampered = entries.map((e) => {
|
||||
if (e.name.endsWith('step.json')) {
|
||||
const obj = JSON.parse(e.data.toString('utf8'));
|
||||
// Corrupt the image size to non-finite values — validateStep rejects
|
||||
// an image step with an invalid size.
|
||||
obj.image = { originalPath: 'original.png', workingPath: 'working.png', size: { width: 'x', height: null } };
|
||||
return { name: e.name, data: Buffer.from(JSON.stringify(obj)) };
|
||||
}
|
||||
return { name: e.name, data: e.data };
|
||||
});
|
||||
fs.writeFileSync(archiveFile, zipSync(tampered));
|
||||
|
||||
const before = store.listGuides().length;
|
||||
assert.throws(() => importGuideArchive(store, archiveFile, { mode: 'copy' }));
|
||||
// No partial guide was left behind, and no staging dir remains.
|
||||
assert.equal(store.listGuides().length, before);
|
||||
const leftover = fs.readdirSync(store.guidesDir).filter((n) => n.includes('.importing'));
|
||||
assert.deepEqual(leftover, []);
|
||||
});
|
||||
|
||||
test('a valid archive imports cleanly', (t) => {
|
||||
const root = makeTmpDir('import-ok');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
const guide = store.createGuide({ title: 'Src' });
|
||||
store.addStep(guide.guideId, { title: 'S1' }, TINY_PNG, { width: 1, height: 1 });
|
||||
const archiveFile = path.join(root, 'out.sfgz');
|
||||
exportGuideArchive(store, guide.guideId, archiveFile);
|
||||
|
||||
const imported = importGuideArchive(store, archiveFile, { mode: 'copy' });
|
||||
assert.equal(imported.title, 'Src');
|
||||
assert.equal(imported.stepsOrder.length, 1);
|
||||
});
|
||||
|
||||
// ---- atomic snapshot restore ------------------------------------------------
|
||||
|
||||
test('restoring a corrupt snapshot never destroys the live guide', (t) => {
|
||||
const root = makeTmpDir('snap-atomic');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
const guide = store.createGuide({ title: 'Live' });
|
||||
store.addStep(guide.guideId, { title: 'keep me' }, TINY_PNG, { width: 1, height: 1 });
|
||||
const snap = createSnapshot(store, guide.guideId, { label: 'good' });
|
||||
|
||||
// Corrupt the snapshot zip so restore must abort.
|
||||
const snapFile = path.join(store.guideDir(guide.guideId), 'history', 'snapshots', snap);
|
||||
fs.writeFileSync(snapFile, Buffer.from('not a zip at all'));
|
||||
|
||||
assert.throws(() => restoreSnapshot(store, guide.guideId, snap), /restore aborted|invalid|zip/i);
|
||||
// The live guide and its step are intact.
|
||||
const after = store.getGuide(guide.guideId);
|
||||
assert.equal(after.title, 'Live');
|
||||
assert.equal(after.stepsOrder.length, 1);
|
||||
});
|
||||
|
||||
test('restoring a valid snapshot swaps content and keeps history', (t) => {
|
||||
const root = makeTmpDir('snap-ok');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
const guide = store.createGuide({ title: 'V1' });
|
||||
const s1 = store.addStep(guide.guideId, { title: 'first' }, TINY_PNG, { width: 1, height: 1 });
|
||||
const snap = createSnapshot(store, guide.guideId, { label: 'v1' });
|
||||
|
||||
// Change the guide, then restore.
|
||||
store.saveGuide({ ...store.getGuide(guide.guideId), title: 'V2' });
|
||||
store.deleteStep(guide.guideId, s1.stepId);
|
||||
assert.equal(store.getGuide(guide.guideId).title, 'V2');
|
||||
|
||||
const restored = restoreSnapshot(store, guide.guideId, snap);
|
||||
assert.equal(restored.title, 'V1');
|
||||
assert.equal(restored.stepsOrder.length, 1);
|
||||
// history/ survived the restore (pre-restore snapshot exists too).
|
||||
assert.ok(fs.existsSync(path.join(store.guideDir(guide.guideId), 'history')));
|
||||
});
|
||||
|
||||
// ---- atomic locks -----------------------------------------------------------
|
||||
|
||||
test('another process holding a fresh lock is a conflict; release-by-token frees ours', (t) => {
|
||||
const root = makeTmpDir('lock');
|
||||
t.after(() => rmrf(root));
|
||||
const { lockPathFor } = require('../../core/locks');
|
||||
const target = path.join(root, 'shared.sfgz');
|
||||
fs.writeFileSync(target, 'x');
|
||||
|
||||
// Simulate a different process already holding a fresh lock.
|
||||
fs.writeFileSync(lockPathFor(target), JSON.stringify({
|
||||
host: 'other-host', user: 'someone-else', pid: 999999,
|
||||
token: 'their-token', acquiredAt: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
const attempt = acquireLock(target);
|
||||
assert.equal(attempt.acquired, false);
|
||||
assert.ok(attempt.conflict);
|
||||
// We must not be able to release their lock with a guessed/absent token.
|
||||
assert.equal(releaseLock(target, { token: 'wrong' }), false);
|
||||
|
||||
// Force-steal (user confirmed), then release by our own acquisition token.
|
||||
const stolen = acquireLock(target, { force: true });
|
||||
assert.equal(stolen.acquired, true);
|
||||
assert.equal(releaseLock(target, { lock: stolen.lock }), true);
|
||||
assert.equal(acquireLock(target).acquired, true);
|
||||
});
|
||||
|
||||
test('the same process can re-acquire its own lock', (t) => {
|
||||
const root = makeTmpDir('lock-reacquire');
|
||||
t.after(() => rmrf(root));
|
||||
const target = path.join(root, 'shared.sfgz');
|
||||
fs.writeFileSync(target, 'x');
|
||||
assert.equal(acquireLock(target).acquired, true);
|
||||
// Same process, second acquire: not a conflict.
|
||||
assert.equal(acquireLock(target).acquired, true);
|
||||
});
|
||||
|
||||
test('force steal takes over a held lock', (t) => {
|
||||
const root = makeTmpDir('lock-force');
|
||||
t.after(() => rmrf(root));
|
||||
const target = path.join(root, 'shared.sfgz');
|
||||
fs.writeFileSync(target, 'x');
|
||||
acquireLock(target);
|
||||
const stolen = acquireLock(target, { force: true });
|
||||
assert.equal(stolen.acquired, true);
|
||||
});
|
||||
|
||||
// ---- search reconcile -------------------------------------------------------
|
||||
|
||||
test('reconcile rebuilds a missing index from the store', (t) => {
|
||||
const root = makeTmpDir('search-rebuild');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
const guide = store.createGuide({ title: 'Password reset guide' });
|
||||
store.addStep(guide.guideId, { title: 'Open admin portal' }, TINY_PNG, { width: 1, height: 1 });
|
||||
|
||||
// A brand-new index (nothing persisted) must recover by reconciling.
|
||||
const index = new SearchIndex(store.indexDir);
|
||||
const summary = index.reconcile(store);
|
||||
assert.equal(summary.reindexed, 1);
|
||||
assert.ok(index.search('password').length > 0);
|
||||
});
|
||||
|
||||
test('reconcile drops entries for deleted guides and reindexes changed ones', (t) => {
|
||||
const root = makeTmpDir('search-reconcile');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
const g1 = store.createGuide({ title: 'alpha guide' });
|
||||
const g2 = store.createGuide({ title: 'beta guide' });
|
||||
const index = new SearchIndex(store.indexDir);
|
||||
index.reconcile(store);
|
||||
assert.ok(index.search('alpha').length > 0);
|
||||
|
||||
// Delete g1 out from under the index and change g2's title.
|
||||
store.deleteGuide(g1.guideId);
|
||||
store.saveGuide({ ...store.getGuide(g2.guideId), title: 'beta renamed gamma' });
|
||||
|
||||
const summary = index.reconcile(store);
|
||||
assert.equal(index.search('alpha').length, 0, 'deleted guide is gone from search');
|
||||
assert.ok(index.search('gamma').length > 0, 'changed guide is reindexed');
|
||||
assert.equal(summary.removed, 1);
|
||||
});
|
||||
|
||||
test('a corrupt index file resets to a recoverable status', (t) => {
|
||||
const root = makeTmpDir('search-corrupt');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
store.createGuide({ title: 'recoverable' });
|
||||
fs.mkdirSync(store.indexDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(store.indexDir, 'search-index.json'), '{ corrupt json');
|
||||
|
||||
const index = new SearchIndex(store.indexDir);
|
||||
const summary = index.reconcile(store);
|
||||
assert.equal(summary.status, 'reset');
|
||||
assert.ok(index.search('recoverable').length > 0);
|
||||
});
|
||||
|
||||
// ---- automatic backups ------------------------------------------------------
|
||||
|
||||
test('autoSnapshotIfDue takes a snapshot every N saves and prunes', (t) => {
|
||||
const root = makeTmpDir('auto-backup');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
const guide = store.createGuide({ title: 'G' });
|
||||
const settings = {
|
||||
get: (k) => ({ automatic: true, everyNSaves: 3, keepLast: 2 }[k.replace('backups.', '')] ?? ({ backups: { automatic: true, everyNSaves: 3, keepLast: 2 } }[k])),
|
||||
};
|
||||
// The helper reads settings.get('backups'):
|
||||
const s = { get: (k) => (k === 'backups' ? { automatic: true, everyNSaves: 3, keepLast: 2 } : null) };
|
||||
|
||||
const dir = path.join(store.guideDir(guide.guideId), 'history', 'snapshots');
|
||||
const count = () => (fs.existsSync(dir) ? fs.readdirSync(dir).filter((n) => n.endsWith('.zip')).length : 0);
|
||||
|
||||
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), null); // 1
|
||||
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), null); // 2
|
||||
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), true); // 3 -> snapshot
|
||||
assert.equal(count(), 1);
|
||||
autoSnapshotIfDue(store, guide.guideId, s); // 1
|
||||
autoSnapshotIfDue(store, guide.guideId, s); // 2
|
||||
autoSnapshotIfDue(store, guide.guideId, s); // 3 -> snapshot
|
||||
assert.equal(count(), 2);
|
||||
autoSnapshotIfDue(store, guide.guideId, s);
|
||||
autoSnapshotIfDue(store, guide.guideId, s);
|
||||
autoSnapshotIfDue(store, guide.guideId, s); // 3rd snapshot, pruned to keepLast=2
|
||||
assert.equal(count(), 2, 'pruned to keepLast');
|
||||
});
|
||||
|
||||
test('autoSnapshotIfDue is a no-op when automatic backups are off', (t) => {
|
||||
const root = makeTmpDir('auto-backup-off');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
const guide = store.createGuide({ title: 'G' });
|
||||
const s = { get: () => ({ automatic: false, everyNSaves: 1 }) };
|
||||
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), null);
|
||||
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), null);
|
||||
const dir = path.join(store.guideDir(guide.guideId), 'history', 'snapshots');
|
||||
assert.equal(fs.existsSync(dir) ? fs.readdirSync(dir).length : 0, 0);
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const security = require('../../app/security');
|
||||
|
||||
const MAIN_URL = security.APP_PAGES.main;
|
||||
const WORKER_URL = security.APP_PAGES.captureWorker;
|
||||
const REGION_URL = security.APP_PAGES.region;
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..', '..');
|
||||
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
||||
|
||||
// ---- navigation policy -----------------------------------------------------
|
||||
|
||||
test('navigation: a window may only stay on its own app page', () => {
|
||||
assert.equal(security.navigationAllowed(MAIN_URL, 'main'), true);
|
||||
assert.equal(security.navigationAllowed(`${MAIN_URL}?q=1#frag`, 'main'), true);
|
||||
assert.equal(security.navigationAllowed(REGION_URL, 'region'), true);
|
||||
|
||||
// Hostile / cross-page navigations are all denied.
|
||||
for (const target of [
|
||||
'https://evil.example/phish.html',
|
||||
'http://127.0.0.1:8080/',
|
||||
'javascript:alert(1)',
|
||||
'data:text/html,<script>1</script>',
|
||||
'about:blank',
|
||||
REGION_URL, // a *different* app page is still a denial for 'main'
|
||||
'file:///etc/passwd',
|
||||
'not a url',
|
||||
'',
|
||||
]) {
|
||||
assert.equal(security.navigationAllowed(target, 'main'), false, `should deny ${target}`);
|
||||
}
|
||||
assert.equal(security.navigationAllowed(MAIN_URL, 'nonexistent-page'), false);
|
||||
});
|
||||
|
||||
// ---- permission policy -----------------------------------------------------
|
||||
|
||||
test('permissions: display capture only for the capture worker, nothing else for anyone', () => {
|
||||
assert.equal(security.permissionAllowed('display-capture', WORKER_URL), true);
|
||||
assert.equal(security.permissionAllowed('media', WORKER_URL), true);
|
||||
|
||||
// The main window gets nothing, including display capture.
|
||||
assert.equal(security.permissionAllowed('display-capture', MAIN_URL), false);
|
||||
assert.equal(security.permissionAllowed('media', MAIN_URL), false);
|
||||
|
||||
// Everything else is denied even for the worker.
|
||||
for (const permission of ['geolocation', 'notifications', 'clipboard-read', 'openExternal', 'fullscreen', 'pointerLock', 'hid', 'usb', 'serial']) {
|
||||
assert.equal(security.permissionAllowed(permission, WORKER_URL), false, permission);
|
||||
}
|
||||
|
||||
// Remote origins never get anything.
|
||||
assert.equal(security.permissionAllowed('display-capture', 'https://evil.example/'), false);
|
||||
assert.equal(security.permissionAllowed('media', undefined), false);
|
||||
});
|
||||
|
||||
// ---- external URL policy ---------------------------------------------------
|
||||
|
||||
test('external links: only well-formed http(s)/mailto pass', () => {
|
||||
assert.equal(security.validateExternalUrl('https://example.com/docs'), 'https://example.com/docs');
|
||||
assert.equal(security.validateExternalUrl('http://example.com'), 'http://example.com/');
|
||||
assert.match(security.validateExternalUrl('mailto:[email protected]'), /^mailto:/);
|
||||
|
||||
for (const url of [
|
||||
'javascript:alert(1)',
|
||||
'file:///etc/passwd',
|
||||
'data:text/html,x',
|
||||
'ftp://example.com/x',
|
||||
'smb://server/share',
|
||||
'chrome://settings',
|
||||
'vbscript:x',
|
||||
'https://',
|
||||
'not a url',
|
||||
123,
|
||||
null,
|
||||
`https://example.com/${'a'.repeat(3000)}`,
|
||||
]) {
|
||||
assert.equal(security.validateExternalUrl(url), null, `should reject ${String(url).slice(0, 60)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- IPC sender guard --------------------------------------------------------
|
||||
|
||||
function makeGuard(mainWc) {
|
||||
return security.makeIpcSenderGuard({ getMainWebContents: () => mainWc });
|
||||
}
|
||||
|
||||
test('ipc guard: accepts only the main window top frame on index.html', () => {
|
||||
const wc = { id: 1 };
|
||||
const guard = makeGuard(wc);
|
||||
|
||||
assert.equal(guard({ sender: wc, senderFrame: { parent: null, url: MAIN_URL } }), true);
|
||||
});
|
||||
|
||||
test('ipc guard: rejects other windows, subframes, navigated and disposed frames', () => {
|
||||
const wc = { id: 1 };
|
||||
const otherWc = { id: 2 };
|
||||
const guard = makeGuard(wc);
|
||||
|
||||
// Different webContents (popup, worker, region overlay).
|
||||
assert.equal(guard({ sender: otherWc, senderFrame: { parent: null, url: MAIN_URL } }), false);
|
||||
// Subframe of our own window.
|
||||
assert.equal(guard({ sender: wc, senderFrame: { parent: {}, url: MAIN_URL } }), false);
|
||||
// Frame that navigated somewhere else but kept the preload bridge.
|
||||
assert.equal(guard({ sender: wc, senderFrame: { parent: null, url: 'https://evil.example/' } }), false);
|
||||
assert.equal(guard({ sender: wc, senderFrame: { parent: null, url: WORKER_URL } }), false);
|
||||
// Disposed frame accessor throws.
|
||||
const disposed = {};
|
||||
Object.defineProperty(disposed, 'parent', { get() { throw new Error('disposed'); } });
|
||||
assert.equal(guard({ sender: wc, senderFrame: disposed }), false);
|
||||
// Missing frame or window entirely.
|
||||
assert.equal(guard({ sender: wc, senderFrame: null }), false);
|
||||
assert.equal(makeGuard(null)({ sender: wc, senderFrame: { parent: null, url: MAIN_URL } }), false);
|
||||
assert.equal(guard(null), false);
|
||||
});
|
||||
|
||||
// ---- argument hygiene --------------------------------------------------------
|
||||
|
||||
test('args must be plain object bags', () => {
|
||||
assert.equal(security.isPlainArgs({ a: 1 }), true);
|
||||
assert.equal(security.isPlainArgs(Object.create(null)), true);
|
||||
assert.equal(security.isPlainArgs(undefined), true);
|
||||
assert.equal(security.isPlainArgs(null), true);
|
||||
assert.equal(security.isPlainArgs([1, 2]), false);
|
||||
assert.equal(security.isPlainArgs('x'), false);
|
||||
class Weird {}
|
||||
assert.equal(security.isPlainArgs(new Weird()), false);
|
||||
});
|
||||
|
||||
test('payload budget rejects oversized and non-data values', () => {
|
||||
assert.equal(security.payloadWithinBudget({ a: 'small' }, 1000), true);
|
||||
assert.equal(security.payloadWithinBudget({ a: 'x'.repeat(2000) }, 1000), false);
|
||||
assert.equal(security.payloadWithinBudget({ fn: () => 1 }, 1000), false);
|
||||
// Deep nesting is cut off.
|
||||
let deep = 'leaf';
|
||||
for (let i = 0; i < 40; i += 1) deep = { deep };
|
||||
assert.equal(security.payloadWithinBudget(deep, 100000), false);
|
||||
// Numbers/booleans/null are fine.
|
||||
assert.equal(security.payloadWithinBudget({ n: 5, b: true, z: null }, 1000), true);
|
||||
});
|
||||
|
||||
test('field validators refuse traversal, separators, and pollution', () => {
|
||||
const c = security.check;
|
||||
assert.equal(c.id('guide-123_A.b'), true);
|
||||
assert.equal(c.id('../../etc/passwd'), false);
|
||||
assert.equal(c.id('a/b'), false);
|
||||
assert.equal(c.id('a\\b'), false);
|
||||
assert.equal(c.id(''), false);
|
||||
assert.equal(c.id(42), false);
|
||||
|
||||
assert.equal(c.fileName('My snapshot 2026-07-03'), true);
|
||||
assert.equal(c.fileName('..'), false);
|
||||
assert.equal(c.fileName('a/../b'), false);
|
||||
assert.equal(c.fileName('a/b'), false);
|
||||
assert.equal(c.fileName('a\\b'), false);
|
||||
assert.equal(c.fileName('a\0b'), false);
|
||||
assert.equal(c.fileName(' '), false);
|
||||
|
||||
assert.equal(c.settingsKeyPath('capture.hotkeyCapture'), true);
|
||||
assert.equal(c.settingsKeyPath('__proto__.polluted'), false);
|
||||
assert.equal(c.settingsKeyPath('a.constructor.b'), false);
|
||||
assert.equal(c.settingsKeyPath('a..b'), false);
|
||||
assert.equal(c.settingsKeyPath(''), false);
|
||||
|
||||
assert.equal(c.base64('aGVsbG8=', 100), true);
|
||||
assert.equal(c.base64('<script>', 100), false);
|
||||
});
|
||||
|
||||
test('produced-files registry only re-opens what main created', () => {
|
||||
const produced = new security.ProducedFiles(3);
|
||||
produced.add('/tmp/exports/guide.pdf');
|
||||
assert.equal(produced.has('/tmp/exports/guide.pdf'), true);
|
||||
assert.equal(produced.has('/tmp/exports/../exports/guide.pdf'), true, 'path normalization');
|
||||
assert.equal(produced.has('/etc/passwd'), false);
|
||||
assert.equal(produced.has(null), false);
|
||||
|
||||
produced.add('/tmp/a');
|
||||
produced.add('/tmp/b');
|
||||
produced.add('/tmp/c'); // evicts guide.pdf (LRU bound)
|
||||
assert.equal(produced.has('/tmp/exports/guide.pdf'), false);
|
||||
assert.equal(produced.has('/tmp/c'), true);
|
||||
});
|
||||
|
||||
// ---- source-level regression guards -----------------------------------------
|
||||
|
||||
test('main process never grants blanket permissions again', () => {
|
||||
const src = read('app/main.js');
|
||||
assert.doesNotMatch(src, /setPermissionCheckHandler\(\(\)\s*=>\s*true\)/);
|
||||
assert.doesNotMatch(src, /cb\(true\)\)/);
|
||||
assert.match(src, /security\.permissionAllowed/);
|
||||
assert.match(src, /installWindowSecurity\(mainWindow, 'main'\)/);
|
||||
});
|
||||
|
||||
test('no generic shell path channels remain on the bridge', () => {
|
||||
const preload = read('app/preload.js');
|
||||
assert.doesNotMatch(preload, /shell:openPath/);
|
||||
assert.doesNotMatch(preload, /shell:showItemInFolder/);
|
||||
assert.match(preload, /shell:openProduced/);
|
||||
assert.match(preload, /shell:openExternal/);
|
||||
});
|
||||
|
||||
test('every renderer window is created sandboxed', () => {
|
||||
for (const file of ['app/main.js', 'app/capture.js', 'app/stream-backend.js']) {
|
||||
const src = read(file);
|
||||
const created = src.match(/new BrowserWindow\(/g) || [];
|
||||
const sandboxed = src.match(/sandbox: true/g) || [];
|
||||
assert.ok(created.length > 0, `${file} should create a window`);
|
||||
assert.equal(
|
||||
sandboxed.length,
|
||||
created.length,
|
||||
`${file}: every BrowserWindow must set sandbox: true (${sandboxed.length}/${created.length})`
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { makeTmpDir, rmrf } = require('./helpers');
|
||||
const { stampVersion } = require('../../scripts/stamp-version');
|
||||
|
||||
test('stampVersion splits build labels into package and build versions', () => {
|
||||
const root = makeTmpDir('stamp-version');
|
||||
try {
|
||||
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({
|
||||
name: 'stepforge',
|
||||
version: '0.1.0',
|
||||
private: true,
|
||||
buildVersion: '0.1.0',
|
||||
}, null, 2));
|
||||
|
||||
fs.writeFileSync(path.join(root, 'package-lock.json'), JSON.stringify({
|
||||
name: 'stepforge',
|
||||
version: '0.1.0',
|
||||
lockfileVersion: 3,
|
||||
requires: true,
|
||||
packages: {
|
||||
'': {
|
||||
name: 'stepforge',
|
||||
version: '0.1.0',
|
||||
},
|
||||
},
|
||||
}, null, 2));
|
||||
|
||||
stampVersion(root, '0.3.2.1');
|
||||
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
||||
const lock = JSON.parse(fs.readFileSync(path.join(root, 'package-lock.json'), 'utf8'));
|
||||
|
||||
assert.equal(pkg.version, '0.3.2');
|
||||
assert.equal(pkg.buildVersion, '0.3.2.1');
|
||||
assert.equal(lock.version, '0.3.2');
|
||||
assert.equal(lock.packages[''].version, '0.3.2');
|
||||
} finally {
|
||||
rmrf(root);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { GuideStore, RevisionConflictError } = require('../../core/store');
|
||||
const { makeTmpDir, rmrf, TINY_PNG } = require('./helpers');
|
||||
|
||||
// ---- revisions --------------------------------------------------------------
|
||||
|
||||
test('revisions start at 0 and increment on every save', (t) => {
|
||||
const root = makeTmpDir('store-rev');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
|
||||
const guide = store.createGuide({ title: 'G' });
|
||||
assert.equal(guide.revision, 0);
|
||||
|
||||
const step = store.addStep(guide.guideId, { title: 'S' }, TINY_PNG, { width: 1, height: 1 });
|
||||
const r0 = store.getStep(guide.guideId, step.stepId).revision;
|
||||
|
||||
const saved1 = store.saveStep(guide.guideId, { ...step, title: 'S1' });
|
||||
assert.equal(saved1.revision, r0 + 1);
|
||||
const saved2 = store.saveStep(guide.guideId, { ...saved1, title: 'S2' });
|
||||
assert.equal(saved2.revision, r0 + 2);
|
||||
});
|
||||
|
||||
test('compare-and-swap: a stale expectedRevision is rejected, not clobbered', (t) => {
|
||||
const root = makeTmpDir('store-cas');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
const guide = store.createGuide({ title: 'G' });
|
||||
const step = store.addStep(guide.guideId, { title: 'S' }, TINY_PNG, { width: 1, height: 1 });
|
||||
|
||||
const base = store.getStep(guide.guideId, step.stepId);
|
||||
// A user edit lands first (no expectedRevision -> last-write-wins).
|
||||
store.saveStep(guide.guideId, { ...base, title: 'user edit' });
|
||||
|
||||
// A background writer that read `base` tries to save with the stale revision.
|
||||
assert.throws(
|
||||
() => store.saveStep(guide.guideId, { ...base, title: 'stale background' }, { expectedRevision: base.revision }),
|
||||
(err) => err instanceof RevisionConflictError && err.code === 'STEPFORGE_REVISION_CONFLICT'
|
||||
);
|
||||
// The user edit survived.
|
||||
assert.equal(store.getStep(guide.guideId, step.stepId).title, 'user edit');
|
||||
});
|
||||
|
||||
test('compare-and-swap succeeds when the revision still matches', (t) => {
|
||||
const root = makeTmpDir('store-cas-ok');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
const guide = store.createGuide({ title: 'G' });
|
||||
const step = store.addStep(guide.guideId, { title: 'S' }, TINY_PNG, { width: 1, height: 1 });
|
||||
const base = store.getStep(guide.guideId, step.stepId);
|
||||
|
||||
const saved = store.saveStep(guide.guideId, { ...base, title: 'ok' }, { expectedRevision: base.revision });
|
||||
assert.equal(saved.title, 'ok');
|
||||
assert.equal(saved.revision, base.revision + 1);
|
||||
});
|
||||
|
||||
test('guide saves are revision-aware too', (t) => {
|
||||
const root = makeTmpDir('store-guide-cas');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
const guide = store.createGuide({ title: 'G' });
|
||||
const base = store.getGuide(guide.guideId);
|
||||
store.saveGuide({ ...base, title: 'first' });
|
||||
assert.throws(
|
||||
() => store.saveGuide({ ...base, title: 'stale' }, { expectedRevision: base.revision }),
|
||||
RevisionConflictError
|
||||
);
|
||||
});
|
||||
|
||||
test('v1 data without a revision field reads as revision 0 and upgrades on save', (t) => {
|
||||
const root = makeTmpDir('store-v1');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
const guide = store.createGuide({ title: 'G' });
|
||||
|
||||
// Simulate legacy on-disk data: strip the revision field.
|
||||
const file = path.join(store.guidesDir, guide.guideId, 'guide.json');
|
||||
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
delete raw.revision;
|
||||
fs.writeFileSync(file, JSON.stringify(raw));
|
||||
|
||||
const loaded = store.getGuide(guide.guideId);
|
||||
assert.equal(loaded.revision, 0);
|
||||
const saved = store.saveGuide(loaded);
|
||||
assert.equal(saved.revision, 1);
|
||||
});
|
||||
|
||||
// ---- corruption quarantine --------------------------------------------------
|
||||
|
||||
test('a corrupt guide is quarantined and reported, not silently dropped', (t) => {
|
||||
const root = makeTmpDir('store-quarantine-guide');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
const good = store.createGuide({ title: 'Good' });
|
||||
const bad = store.createGuide({ title: 'Bad' });
|
||||
|
||||
// Corrupt the bad guide's JSON.
|
||||
fs.writeFileSync(path.join(store.guidesDir, bad.guideId, 'guide.json'), '{ not valid json');
|
||||
|
||||
const listed = store.listGuides();
|
||||
assert.deepEqual(listed.map((g) => g.guideId), [good.guideId]);
|
||||
|
||||
const report = store.getRecoveryReport();
|
||||
assert.equal(report.length, 1);
|
||||
assert.equal(report[0].kind, 'guide');
|
||||
// The original bytes are preserved in quarantine, not deleted.
|
||||
assert.ok(fs.existsSync(report[0].quarantined));
|
||||
// The bad guide dir is gone from the live library.
|
||||
assert.equal(fs.existsSync(path.join(store.guidesDir, bad.guideId)), false);
|
||||
});
|
||||
|
||||
test('a corrupt step is quarantined and reported', (t) => {
|
||||
const root = makeTmpDir('store-quarantine-step');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
const guide = store.createGuide({ title: 'G' });
|
||||
const good = store.addStep(guide.guideId, { title: 'good' }, TINY_PNG, { width: 1, height: 1 });
|
||||
const bad = store.addStep(guide.guideId, { title: 'bad' }, TINY_PNG, { width: 1, height: 1 });
|
||||
|
||||
fs.writeFileSync(path.join(store.stepDir(guide.guideId, bad.stepId), 'step.json'), 'nonsense');
|
||||
|
||||
const steps = store.listSteps(guide.guideId);
|
||||
assert.ok(steps.has(good.stepId));
|
||||
assert.equal(steps.has(bad.stepId), false);
|
||||
const report = store.getRecoveryReport();
|
||||
assert.equal(report.some((r) => r.kind === 'step'), true);
|
||||
});
|
||||
|
||||
test('an empty/in-progress guide directory is not treated as corruption', (t) => {
|
||||
const root = makeTmpDir('store-empty-dir');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
// A directory with no guide.json (e.g. mid-create) must be skipped quietly.
|
||||
fs.mkdirSync(path.join(store.guidesDir, 'orphan-dir'), { recursive: true });
|
||||
assert.doesNotThrow(() => store.listGuides());
|
||||
assert.equal(store.getRecoveryReport().length, 0);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||